mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 551d068ae2 |
@@ -1,14 +0,0 @@
|
||||
# cargo-mutants (docs/mutation-testing.md). Mutants are judged by the black-box suites that will
|
||||
# outlive the kernel backend: the reference tests (tests/oracle, through the oracle_cases test)
|
||||
# and the pull-request sample of the robustness goldens (every 8th seed, as ci.yml). The
|
||||
# kernel's own unit tests are left out on purpose: a mutant only they catch is a behaviour the
|
||||
# backend-neutral suites would miss when the kernel is swapped.
|
||||
profile = "mutants"
|
||||
additional_cargo_test_args = [
|
||||
"--test", "oracle_cases", "--lib", "--", "--include-ignored",
|
||||
"oracle_suite",
|
||||
"robustness_goldens::seed_00", "robustness_goldens::seed_08", "robustness_goldens::seed_16",
|
||||
"robustness_goldens::seed_24", "robustness_goldens::seed_32", "robustness_goldens::seed_40",
|
||||
"robustness_goldens::seed_48", "robustness_goldens::seed_56", "robustness_goldens::seed_64",
|
||||
"robustness_goldens::seed_72", "robustness_goldens::seed_80", "robustness_goldens::seed_88",
|
||||
]
|
||||
@@ -1,71 +0,0 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to twelve 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`. The
|
||||
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solidcraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(/^tests::robustness_goldens::seed_/)
|
||||
| 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-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
|
||||
| (package(solidcraft-kernel) & binary(boolean_props))
|
||||
| (package(solidcraft-engine) & binary(atomic_commands))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
# Every test's time in the log, so the slow ones are easy to find.
|
||||
status-level = "pass"
|
||||
final-status-level = "slow"
|
||||
failure-output = "immediate-final"
|
||||
slow-timeout = { period = "60s" }
|
||||
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
# The action matrix sample runs its designs in parallel workers (ACTION_MATRIX_JOBS, set to match in
|
||||
# ci.yml). Its workers reserve that many of nextest's threads, so the cores are not oversubscribed
|
||||
# (a pi-case hole that takes 8 s alone went past the matrix's 90 s action limit when they were),
|
||||
# and it starts first, beside the other slow tests.
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(action_matrix) & test(=action_matrix_sample)"
|
||||
threads-required = 12
|
||||
priority = 100
|
||||
|
||||
|
||||
# The slowest tests start first, so a shard does not end with one of them running alone on a
|
||||
# core while the others sit idle (seed_81 alone takes about 20 minutes under CI load). Medians
|
||||
# from the CI test timings (the junit artifacts), slowest first; a test over ~100 s belongs here.
|
||||
[[profile.ci.overrides]]
|
||||
filter = """
|
||||
(package(solidcraft-engine) & (
|
||||
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(=tests::hostile_params_never_panic)
|
||||
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
|
||||
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
|
||||
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
|
||||
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
|
||||
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
|
||||
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
|
||||
))
|
||||
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
|
||||
| (package(solidcraft-kernel) & (
|
||||
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
|
||||
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
|
||||
| test(=tspline::tests::hostile_tsplines_never_panic)
|
||||
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
|
||||
| test(/^issue_788_tests::equal_bores_crossing_/)
|
||||
))
|
||||
"""
|
||||
priority = 100
|
||||
@@ -1,4 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [bherbruck]
|
||||
ko_fi: bherbruck
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something in SolidCraft does the wrong thing
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What did you expect, and what happened instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
placeholder: |
|
||||
1. Sketch a circle on the XY plane
|
||||
2. Extrude it 10 mm
|
||||
3. Fillet the top edge with 6 mm
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SolidCraft version and OS
|
||||
description: Help → About shows the version.
|
||||
placeholder: 0.15.0, Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: media
|
||||
attributes:
|
||||
label: Screenshots or recording
|
||||
description: |
|
||||
For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report
|
||||
# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off.
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions and chat
|
||||
url: https://discord.gg/FtU4NQNVDH
|
||||
about: Ask questions and talk with users and contributors on the SolidCraft Discord.
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Feature request
|
||||
description: Something SolidCraft should do
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The part or workflow you have in mind, and where SolidCraft gets in the way today.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How you imagine it working
|
||||
description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation.
|
||||
@@ -1,11 +0,0 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
# Blacksmith (see the top of workflows/ci.yml).
|
||||
- blacksmith-2vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
@@ -1,17 +0,0 @@
|
||||
## Change
|
||||
|
||||
Describe the problem and resulting behavior. Link the issue when applicable.
|
||||
|
||||
## Validation
|
||||
|
||||
What did you run, and what were the results? Include expected and measured values for geometry changes.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
|
||||
## Integration status
|
||||
|
||||
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
|
||||
@@ -1,64 +0,0 @@
|
||||
SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://docs.solidcraft.io/)
|
||||
· [try it in the browser](https://solidcraft.io/app) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solidcraft-cli mcp` ([how](https://docs.solidcraft.io/guide/mcp.html)).
|
||||
|
||||
**What's new** in SolidCraft {version} is listed below, and the
|
||||
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
|
||||
the known limitations. Designs from every earlier version open in this one.
|
||||
|
||||
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
|
||||
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
|
||||
menu entry.
|
||||
|
||||
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
|
||||
container the default save format. `.solvecraft` designs, old settings folders and
|
||||
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
|
||||
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
|
||||
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
|
||||
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
|
||||
|
||||
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
|
||||
|
||||
**Which file?**
|
||||
- **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 `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>`), 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 solidcraft https://docs.solidcraft.io/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solidcraft
|
||||
flatpak run ai.storyteller.solidcraft
|
||||
```
|
||||
|
||||
A remote added from the old `bherbruck.github.io` address no longer finds updates: run
|
||||
`flatpak remote-modify --user --url=https://docs.solidcraft.io/flatpak/repo/ solidcraft`, then
|
||||
`flatpak update --user`.
|
||||
|
||||
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
|
||||
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
|
||||
|
||||
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
|
||||
still need an internet connection. [Linux installation details](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`.
|
||||
|
||||
@@ -1,918 +0,0 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
|
||||
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
|
||||
# packages that depend on them, and Clippy, the test archive and the test shards cover only
|
||||
# those. Test data under tests/, vendored crates under vendor/ and root files a crate embeds
|
||||
# select the packages that read them. Changes to the lockfile, root manifest,
|
||||
# toolchain/cargo/nextest config, this workflow, scripts/ or any file no package owns select the
|
||||
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
|
||||
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
|
||||
# no native job runs for them, on pull requests or on main.
|
||||
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
|
||||
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
|
||||
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
|
||||
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
|
||||
# only main commits.
|
||||
# A push to main runs only the repository checks when a passing merge-queue run tested that very
|
||||
# commit, or when nothing a build reads changed since the last push to main whose run passed.
|
||||
# Otherwise it runs everything.
|
||||
# Heavy checks wait for review: a pull request without the `approved-for-ci` label runs only
|
||||
# Select checks, the format and repository checks and a quick compile. Adding the label (after
|
||||
# review) runs everything above, and every later push to the PR does too, except a push that only
|
||||
# merges main into a change that already passed (scripts/ci/main_merge.py): it carries that
|
||||
# result and runs the repository checks. Merge groups, the nightly run and Run workflow are not
|
||||
# pull requests and always run the full suite.
|
||||
# GitHub cannot filter `labeled` events by label name, so any other label starts a run whose jobs
|
||||
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run. Its
|
||||
# `ci` job still runs and reports the commit's real result (see the `ci` job): the merge queue
|
||||
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
|
||||
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
|
||||
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
|
||||
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
|
||||
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
|
||||
# other run restores read-only. Nightly and by hand, Build tests also makes an archive the matrix
|
||||
# jobs run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
|
||||
# same.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
|
||||
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
|
||||
# only (other labels are skipped, see the top of this file).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled]
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cross_compile:
|
||||
description: "Also check WebAssembly and RISC-V (before a release)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
|
||||
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
|
||||
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
|
||||
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
|
||||
# unlabelled run with the full one.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install advisory scanner
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- name: Check locked dependencies
|
||||
run: cargo deny --locked check --warn unmaintained advisories bans sources
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
# Not on a draft: every other check needs this one, so they skip too. Not on a label other than
|
||||
# approved-for-ci either (the same skip, for the same reason).
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
actions: read
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
kernel: ${{ steps.paths.outputs.kernel }}
|
||||
goldens: ${{ steps.paths.outputs.goldens }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
|
||||
quick: ${{ steps.paths.outputs.quick }}
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
oracle: ${{ steps.paths.outputs.oracle }}
|
||||
doctests: ${{ steps.paths.outputs.doctests }}
|
||||
# The passing run a merge of main carries forward (its URL), when this push is only that.
|
||||
carried: ${{ steps.paths.outputs.carried }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
name: Detect code and CI changes
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
native=false
|
||||
kernel=false
|
||||
goldens=false
|
||||
windows=false
|
||||
full=true
|
||||
packages=
|
||||
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
|
||||
# something or the whole workspace. Documentation, packaging, other workflows and the bots'
|
||||
# scripts select nothing. Pull requests and pushes to main share it.
|
||||
select_packages() {
|
||||
python3 -I scripts/ci/affected.py --paths "$1" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
}
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# The queue lands the very commit its merge_group run tested. If that run passed,
|
||||
# testing the push again proves nothing new; otherwise (a merge that skipped the
|
||||
# queue, or the API cannot answer) test everything.
|
||||
# The API through curl and grep, which every runner image has.
|
||||
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
|
||||
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
|
||||
if [ "${tested:-0}" -gt 0 ]; then
|
||||
echo "The merge queue already tested $PUSH_SHA: repository checks only."
|
||||
elif tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=push&branch=main&status=success&per_page=1" \
|
||||
| grep -o '"head_sha": *"[0-9a-f]*"' | head -n 1 | grep -o '[0-9a-f]\{40\}') \
|
||||
&& [ -n "$tested" ] && git merge-base --is-ancestor "$tested" "$PUSH_SHA" \
|
||||
&& git diff --no-renames --name-only -z "$tested" "$PUSH_SHA" > "$RUNNER_TEMP/ci-paths" \
|
||||
&& select_packages "$RUNNER_TEMP/ci-paths" && [ "$full" != true ] && [ -z "$packages" ]; then
|
||||
# Nothing a build reads changed since the last push to main whose run passed (the
|
||||
# push before may never have run: a newer push cancels a waiting one).
|
||||
echo "$tested..$PUSH_SHA changes no code: repository checks only."
|
||||
else
|
||||
# Code changed (or the push can't be compared): main runs everything.
|
||||
full=true
|
||||
packages=
|
||||
native=true
|
||||
windows=true
|
||||
fi
|
||||
elif [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
windows=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
|
||||
# What a pull request re-checks the goldens for: the kernel, truck and the goldens
|
||||
# themselves (a re-record is checked before it is queued). The goldens are frozen, so
|
||||
# on any other change they would only re-check unchanged results; the merge queue,
|
||||
# main and the nightly run every seed before anything lands.
|
||||
case "$path" in
|
||||
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
|
||||
esac
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
case "$path" in
|
||||
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
|
||||
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
|
||||
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
|
||||
&& git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \
|
||||
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
|
||||
windows=true
|
||||
fi
|
||||
# Pull requests build and test the touched packages and their dependents; nothing
|
||||
# selected means no code changed.
|
||||
select_packages "$RUNNER_TEMP/ci-paths"
|
||||
if [ "$full" = true ] || [ -n "$packages" ]; then native=true; fi
|
||||
# The goldens are solidcraft-engine tests: its tests must be in the archive.
|
||||
if [ "$goldens" = true ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-engine "*) ;; *) packages="$packages solidcraft-engine" ;; esac
|
||||
fi
|
||||
fi
|
||||
# The reference tests replay recipes through solidcraft-cli: needed when it, or anything
|
||||
# it depends on, is selected.
|
||||
oracle=$native
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
|
||||
fi
|
||||
# The pull request fast lane: Windows, the reference tests and the doc tests run on main
|
||||
# (and the nightly), not on every pull request push; a break there is fixed forward on
|
||||
# main. Over three days of pull requests the doc tests were the only failing job 3 times
|
||||
# in about 7,900 runner-minutes, and the reference tests never were.
|
||||
doctests=$native
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; doctests=false; fi
|
||||
# Until a maintainer adds approved-for-ci, a pull request gets a quick compile instead of
|
||||
# Clippy, the tests, the doc tests and the goldens.
|
||||
quick=false
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$APPROVED" != true ]; then
|
||||
quick=$native
|
||||
native=false
|
||||
oracle=false
|
||||
goldens=false
|
||||
fi
|
||||
# A push that only merges main into the change (Merge branch 'main', Update branch) when
|
||||
# the change itself already passed: nothing new to test here, and main tests the merged
|
||||
# result after it lands. Over three days, 872 of 1,241 such merges brought in nothing that
|
||||
# touched the pull request's files, and each restarted the whole run.
|
||||
carried=
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$native" = true ]; then
|
||||
carried=$(python3 -I scripts/ci/main_merge.py --base "$BASE_SHA" --head "$HEAD_SHA" --repo "$REPO" \
|
||||
--pr "$PR_NUMBER" --head-repo "$HEAD_REPO" --head-ref "$HEAD_REF" | sed -n 's/^carried=//p')
|
||||
if [ -n "$carried" ]; then
|
||||
echo "Only a merge of main into a change that passed in $carried: repository checks only."
|
||||
native=false
|
||||
goldens=false
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
echo "goldens=$goldens"
|
||||
echo "windows=$windows"
|
||||
echo "oracle=$oracle"
|
||||
echo "doctests=$doctests"
|
||||
echo "carried=$carried"
|
||||
echo "full=$full"
|
||||
echo "quick=$quick"
|
||||
echo "packages=$packages"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Asset attribution
|
||||
run: cargo xtask assets
|
||||
- name: UI icon standards and review tool
|
||||
run: cargo xtask icons
|
||||
- name: Licences
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Universal UI containers
|
||||
run: |
|
||||
cargo xtask ui-containers
|
||||
cargo test -p xtask ui_containers --locked
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Design format pending changes
|
||||
run: bash scripts/test-format-pending.sh
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim, triage and kernel-paused bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
name: Quick compile
|
||||
needs: changes
|
||||
if: needs.changes.outputs.quick == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: quick-compile
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Clippy
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests lint the affected packages (scripts/ci/affected.py); everything else, all.
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
# The archive is for the action matrix and the property tests, which run only nightly and by
|
||||
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
# Fork builds start from main's cache, read-only; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-archive
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Compile tests into a nextest archive
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests archive only the affected packages (scripts/ci/affected.py).
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo nextest archive "${scope[@]}" --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
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
|
||||
# Builds the tests and runs them in one job on a big runner: there is no archive to upload
|
||||
# and download (3 GB on a full run), and the job starts beside the repository checks, not
|
||||
# after them. nextest hands tests to its threads as they free up, the slowest first (priority
|
||||
# in .config/nextest.toml), so they start at once and no core idles at the end.
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
|
||||
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
|
||||
# would skip for want of an adapter fails instead.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
ACTION_MATRIX_JOBS: "12"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-tests
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GOLDENS: ${{ needs.changes.outputs.goldens }}
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
|
||||
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
|
||||
# queue and the nightly run every seed.
|
||||
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
|
||||
# below is that filter, so a pull request still runs only what it ran before.
|
||||
base='default()'
|
||||
if [ "$EVENT_NAME" = pull_request ]; then
|
||||
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
|
||||
# main and nightly, not on every pull request push.
|
||||
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
|
||||
fi
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
base='not (package(solidcraft-kernel) & binary(boolean_props))'
|
||||
elif [ "$GOLDENS" = true ]; then
|
||||
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
|
||||
# also checks every seed but the slow ones, in the same run so they share its cores.
|
||||
base="(default() & not (binary(action_matrix) & test(=action_matrix_sample))) | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))"
|
||||
fi
|
||||
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
|
||||
rm -f "$junit"
|
||||
# Pull requests test the affected packages (scripts/ci/affected.py).
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
status=0
|
||||
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
|
||||
--ignore-default-filter -E "$base" || status=1
|
||||
if [ "$status" != 0 ]; then
|
||||
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
|
||||
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
|
||||
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
|
||||
# the whole matrix runs nightly (or by hand) in shards of about equal cost, cut by
|
||||
# tests/action_matrix/costs.json. Keep SHARDS in step with the matrix below.
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 55
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run one shard of the matrix
|
||||
# A step limit below the job's, so the outcomes so far are still uploaded.
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
SHARDS: 16
|
||||
SHARD: ${{ matrix.shard }}
|
||||
OUT: ${{ github.workspace }}/action-matrix
|
||||
run: |
|
||||
mkdir -p "$OUT"
|
||||
export ACTION_MATRIX_SHARD="$SHARD/$SHARDS" ACTION_MATRIX_RESULTS="$OUT/results-$SHARD.json" \
|
||||
ACTION_MATRIX_TIMINGS="$OUT/timings-$SHARD.jsonl" ACTION_MATRIX_WORKDIR="$OUT/raw-$SHARD"
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_shard)"
|
||||
- name: Upload the outcomes and timings
|
||||
# `cargo xtask action-matrix --from DIR --bless` (with every shard's files in DIR)
|
||||
# records them in the baseline and costs.json.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: action-matrix-${{ matrix.shard }}
|
||||
path: action-matrix
|
||||
retention-days: 14
|
||||
|
||||
action-matrix-determinism:
|
||||
# A sample of the matrix run twice on one runner: every action must come out the same.
|
||||
name: Action matrix determinism
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run a sample twice
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
|
||||
|
||||
property-tests:
|
||||
# The boolean property tests and the atomic command tests with many cases. Pull requests skip
|
||||
# them (.config/nextest.toml); pushes to main run atomic_commands with small defaults (seeds
|
||||
# 0..4) and skip boolean_props, whose random inputs would fail main at random.
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
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: Boolean properties and atomic commands
|
||||
# The defaults (8 cases, seeds 0..4) take 159 s and 802 s on a 2-core VM; four times the
|
||||
# cases and twice the seeds are estimated at about 40 minutes there (not yet timed on CI).
|
||||
env:
|
||||
PROPTEST_CASES: "32"
|
||||
ATOMIC_SEEDS: "0..8"
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --success-output final -E "binary(boolean_props) | binary(atomic_commands)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.doctests == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-doc
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- 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.
|
||||
- name: Doc tests
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests test the affected packages that have a library (only libraries have doc
|
||||
# tests); everything else, the whole workspace.
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then
|
||||
scope=()
|
||||
libs=$(cargo metadata --no-deps --format-version 1 --locked \
|
||||
| python3 -I -c 'import json, sys; print(" ".join(p["name"] for p in json.load(sys.stdin)["packages"] if any("lib" in t["kind"] for t in p["targets"])))')
|
||||
for p in $PACKAGES; do
|
||||
case " $libs " in *" $p "*) scope+=(-p "$p") ;; esac
|
||||
done
|
||||
fi
|
||||
if [ ${#scope[@]} -eq 0 ]; then
|
||||
echo "No affected package has a library: no doc tests to run."
|
||||
exit 0
|
||||
fi
|
||||
cargo test "${scope[@]}" --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-oracle
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- 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 "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: oracle-report
|
||||
path: docs/oracle.md
|
||||
|
||||
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
|
||||
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
|
||||
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
|
||||
# results with the CPU, and draw headless pictures with the viewport's pipelines there
|
||||
# (crates/gpu/tests/capture.rs).
|
||||
windows-fxc:
|
||||
name: Windows D3D12 FXC shaders
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
runs-on: blacksmith-8vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
WGPU_BACKEND: dx12
|
||||
WGPU_DX12_COMPILER: fxc
|
||||
# WARP is a software (Cpu) adapter.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-fxc
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# One test at a time: WARP's shader JIT crashes now and then (a null read in
|
||||
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
|
||||
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
|
||||
# in SolidCraft or FXC; serial runs never hit it.
|
||||
- run: cargo test -p solidcraft-gpu --profile ci --locked -- --test-threads=1
|
||||
|
||||
# The Windows app starts D3D12 first and only the backend it uses (a Vulkan driver crashed the
|
||||
# app at startup on Intel). Start the real app on the runner (its D3D12 adapter is WARP) with
|
||||
# several saved settings and safe mode, and check each draws a frame and quits cleanly.
|
||||
windows-startup:
|
||||
name: Windows app startup
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
# GitHub-hosted: the startup smoke needs an interactive desktop that presents frames; on
|
||||
# Blacksmith's Windows runners the window never presents ("no frame was presented").
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-startup
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Backend order and probing on Windows
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
|
||||
- name: Build the app
|
||||
run: cargo build -p solidcraft --profile ci --locked
|
||||
- name: Start the app under different settings
|
||||
shell: pwsh
|
||||
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
|
||||
- name: Resize border from the first frame
|
||||
shell: pwsh
|
||||
run: tools/windows-resize-smoke.ps1 -Exe target/ci/solidcraft.exe
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
# On a run another label starts (every other job skipped), this job reports the result of the
|
||||
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
|
||||
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
|
||||
name: ci
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
# To read the commit's other runs of this workflow.
|
||||
actions: read
|
||||
steps:
|
||||
- name: Report the commit's real result (a run another label started)
|
||||
if: github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
# The other runs of ci.yml for this commit. While one is still going, wait for it (at
|
||||
# most 90 minutes); then pass only when the newest one that ran its checks passed.
|
||||
deadline=$((SECONDS + 5400))
|
||||
while :; do
|
||||
runs=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&per_page=100" \
|
||||
--jq "[.workflow_runs[] | select(.id != $RUN_ID)]")
|
||||
pending=$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs")
|
||||
if [ "$pending" -eq 0 ] || [ "$SECONDS" -ge "$deadline" ]; then break; fi
|
||||
echo "Waiting for $pending run(s) of this commit to finish."
|
||||
sleep 30
|
||||
done
|
||||
# Runs with every job skipped (labels, before this job reported) are not a result.
|
||||
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
|
||||
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
|
||||
echo "The commit's latest run: $latest"
|
||||
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
|
||||
test "${latest%% *}" = success
|
||||
- name: Verify required checks
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci'
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
QUICK: ${{ needs.changes.outputs.quick }}
|
||||
QUICK_COMPILE: ${{ needs.quick-compile.result }}
|
||||
WINDOWS: ${{ needs.changes.outputs.windows }}
|
||||
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
|
||||
DOCTESTS: ${{ needs.changes.outputs.doctests }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
|
||||
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
# An unapproved pull request: the heavy jobs are skipped on purpose.
|
||||
if [ "$QUICK" = true ]; then
|
||||
test "$QUICK_COMPILE" = success
|
||||
fi
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
# The archive for the action matrix and property tests exists nightly and by hand only.
|
||||
if [ "$EVENT_NAME" = schedule ] || [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
test "$BUILD" = success
|
||||
fi
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
if [ "$DOCTESTS" = true ]; then
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
if [ "$ORACLE_RUN" = true ]; then
|
||||
test "$ORACLE" = success
|
||||
fi
|
||||
if [ "$WINDOWS" = true ]; then
|
||||
test "$WINDOWS_FXC" = success
|
||||
test "$WINDOWS_STARTUP" = success
|
||||
fi
|
||||
- name: Note a carried result
|
||||
if: needs.changes.outputs.carried != ''
|
||||
env:
|
||||
CARRIED: ${{ needs.changes.outputs.carried }}
|
||||
run: |
|
||||
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask wasm
|
||||
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: riscv64
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
|
||||
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
|
||||
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://ports.ubuntu.com/ubuntu-ports
|
||||
Suites: noble noble-updates noble-backports noble-security
|
||||
Components: main restricted universe multiverse
|
||||
Architectures: riscv64
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
EOF
|
||||
sudo dpkg --add-architecture riscv64
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
|
||||
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Cargo check on RISC-V
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: 1
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
@@ -1,52 +0,0 @@
|
||||
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
|
||||
# frees claims nobody is working on. A pull request that references an issue (`<summary> (#N)`,
|
||||
# `Fixes #N`) claims it too. The logic is in scripts/claim/claim.js (tested in ci.yml).
|
||||
name: Claim
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
|
||||
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/claim
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
# One run at a time per issue, so two simultaneous /claim comments can't both win.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/claim
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const claim = require('./scripts/claim/claim.js');
|
||||
if (context.eventName === 'issue_comment') {
|
||||
await claim.handleComment({ github, context });
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
const claimed = await claim.handlePull({ github, context });
|
||||
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
|
||||
} else {
|
||||
const released = await claim.sweep({ github, context });
|
||||
core.info(`Released: ${released.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
# The web app as a container image on GitHub's container registry, for self-hosting
|
||||
# (packaging/docker; the book's "Self-hosting with Docker"):
|
||||
#
|
||||
# ghcr.io/<owner>/solidcraft-web:latest the latest published release
|
||||
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
|
||||
# ghcr.io/<owner>/solidcraft-web:edge main
|
||||
#
|
||||
# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and
|
||||
# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and
|
||||
# smoke-test it without publishing. Releases published by an agent or maintainer trigger this;
|
||||
# the nightly pre-releases (made with GITHUB_TOKEN) do not.
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The image holds the web app only: documentation, test data and the GitHub bots' scripts can't
|
||||
# change it, so a push touching only those builds nothing (edge stays at the last code change).
|
||||
# ATTRIBUTION.md is the exception among the Markdown files: the app embeds it (Help, About).
|
||||
# The last matching pattern wins.
|
||||
paths: ["**", "!docs/**", "!**/*.md", "ATTRIBUTION.md", "!tests/**", "!scripts/claim/**", "!scripts/triage/**"]
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
paths: ["packaging/docker/**", ".github/workflows/docker.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
TRUNK_VERSION: v0.21.14
|
||||
|
||||
jobs:
|
||||
image:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: docker-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release
|
||||
- name: Image name
|
||||
id: image
|
||||
# Registry names are lower case; a fork's owner may not be.
|
||||
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=edge,branch=main
|
||||
type=ref,event=pr
|
||||
labels: |
|
||||
org.opencontainers.image.title=SolidCraft (web)
|
||||
org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080
|
||||
org.opencontainers.image.licenses=MIT OR Apache-2.0
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Build for the smoke test
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: solidcraft-web:test
|
||||
- name: Smoke test
|
||||
run: bash packaging/docker/smoke-test.sh solidcraft-web:test
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and publish
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Publish Flatpak repository to Pages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published GitHub release tag to publish (for example, v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Request the combined docs, web, and Flatpak deployment
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
steps:
|
||||
- name: Deploy through the shared Pages workflow
|
||||
run: |
|
||||
set -euo pipefail
|
||||
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \
|
||||
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
|
||||
test "$published" = "$RELEASE_TAG"
|
||||
gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
echo "Requested the combined Pages deployment for $RELEASE_TAG" >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,47 +0,0 @@
|
||||
# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD
|
||||
# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner;
|
||||
# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded
|
||||
# files, then commit them in a pull request if the change is intended.
|
||||
name: FreeCAD reference values
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cases:
|
||||
description: "Case directories (space separated); empty = every case whose source is FreeCAD"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate FreeCAD expected values
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install FreeCAD
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends freecad
|
||||
freecadcmd --version || true
|
||||
- name: Rebuild the cases
|
||||
env:
|
||||
CASES: ${{ inputs.cases }}
|
||||
run: |
|
||||
if [ -z "$CASES" ]; then
|
||||
CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ')
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
tools/freecad-oracle/run.sh --write $CASES
|
||||
- name: Show the changes
|
||||
run: git diff --stat && git diff -- tests/oracle/*/expected.json
|
||||
- name: Upload the regenerated files
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freecad-expected
|
||||
path: |
|
||||
tests/oracle/*/expected.json
|
||||
tests/oracle/*/reference.stl
|
||||
@@ -1,62 +0,0 @@
|
||||
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
|
||||
# never edit them (two pull requests that each regenerated one would always conflict):
|
||||
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
|
||||
name: Generated files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
|
||||
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
|
||||
# read through solidcraft-cli. Other pushes regenerate nothing.
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "**/Cargo.toml"
|
||||
- "vendor/**"
|
||||
- "assets/fonts/**"
|
||||
- "crates/engine/**"
|
||||
- "apps/solidcraft-cli/**"
|
||||
- "xtask/**"
|
||||
- ".github/workflows/generated.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# One run at a time; a burst of merges leaves one run waiting, which sees all of them.
|
||||
concurrency:
|
||||
group: generated-files
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate and commit
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
# Always main itself, never pull request code, since this job can push.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
# main goes through the merge queue; this deploy key may push past it.
|
||||
ssh-key: ${{ secrets.GENERATED_FILES_DEPLOY_KEY }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: generated-files
|
||||
- name: THIRD-PARTY-LICENSES.txt
|
||||
run: cargo xtask licences
|
||||
- name: docs/features.md
|
||||
run: cargo xtask features
|
||||
- name: Commit if changed
|
||||
run: |
|
||||
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add THIRD-PARTY-LICENSES.txt docs/features.md
|
||||
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
|
||||
for _ in 1 2 3; do
|
||||
git pull --rebase origin main && git push origin HEAD:main && exit 0
|
||||
sleep 10
|
||||
done
|
||||
exit 1
|
||||
@@ -1,42 +0,0 @@
|
||||
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
|
||||
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
|
||||
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
discussion:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/kernel-paused
|
||||
issues: write
|
||||
pull-requests: read # to list a PR's changed paths
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
paused:
|
||||
name: Kernel paused bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/kernel-paused
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const paused = require('./scripts/kernel-paused/kernel-paused.js');
|
||||
const done = context.eventName === 'discussion'
|
||||
? await paused.handleDiscussion({ github, context })
|
||||
: await paused.handleOpened({ github, context });
|
||||
core.info(done);
|
||||
@@ -1,33 +0,0 @@
|
||||
# Files many pull requests add to are kept so that additions don't conflict: one file per change
|
||||
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
|
||||
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
|
||||
# per PR instead of three); this copy keeps main honest.
|
||||
name: Lists
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format-changes:
|
||||
name: Design format changes merge cleanly
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: bash scripts/test-format-pending.sh
|
||||
|
||||
sorted:
|
||||
name: Lists are sorted one item per line
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: lists
|
||||
- run: cargo xtask lists
|
||||
@@ -1,65 +0,0 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
|
||||
# packaging shell and PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: packaging-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml jq
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: '"$RUNNER_TEMP/actionlint" -color'
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Nightly release selection regression
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: Windows executable icon validation regression
|
||||
run: python3 packaging/tests/windows_icon_test.py
|
||||
- name: Flatpak desktop smoke validation regression
|
||||
run: python3 packaging/tests/flatpak_test.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout assets/platforms/windows/solidcraft.wxs assets/platforms/windows/solidcraft.en-us.wxl assets/platforms/macos/*.plist* assets/platforms/linux/*.xml*
|
||||
desktop-file-validate assets/platforms/linux/ai.storyteller.solidcraft.desktop
|
||||
# The window's X11 class is its app id; the old id stays only as the rename shims.
|
||||
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' assets/platforms/linux/ai.storyteller.solidcraft.desktop
|
||||
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
|
||||
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
|
||||
python3 -m py_compile packaging/linux/snap-smoke.py
|
||||
@@ -1,93 +0,0 @@
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at https://docs.solidcraft.io/
|
||||
# and the bundled web app at /app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# repository is private.
|
||||
name: Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The site is the book and the web app: test data and the GitHub bots' scripts change neither.
|
||||
paths-ignore: ["tests/**", "scripts/claim/**", "scripts/triage/**"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published release for Flatpak (empty: latest stable release)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
MDBOOK_VERSION: v0.5.4
|
||||
TRUNK_VERSION: v0.21.14
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Install mdbook and trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# The book's pictures are `solidcraft-cli snapshot`s, drawn on the GPU; the runner has none.
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Build the book
|
||||
env:
|
||||
SOLIDCRAFT_GPU: software
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release --public-url ./
|
||||
- name: Test the web app offline and across an update (headless Chrome)
|
||||
run: cargo xtask web-offline-test --site dist/web
|
||||
- name: Assemble the site
|
||||
run: |
|
||||
mkdir -p site
|
||||
cp -r target/book/. site/
|
||||
cp -r dist/web site/app
|
||||
- uses: actions/configure-pages@v5
|
||||
id: pages
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
- name: Include the published Flatpak repository
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag || '' }}
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: bash packaging/flatpak-site.sh site
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,543 +0,0 @@
|
||||
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
|
||||
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
|
||||
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
# The main installers are also attached without the version in their names
|
||||
# (solidcraft-windows-x64.msi, solidcraft-linux-x86_64.AppImage, ...) so that
|
||||
# releases/latest/download/<name> links in the README keep working across releases.
|
||||
#
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
timezone: America/New_York
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build: ${{ steps.changed.outputs.build }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: changed
|
||||
name: Skip unchanged nightly builds
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash packaging/nightly.sh
|
||||
- id: v
|
||||
if: steps.changed.outputs.build == 'true'
|
||||
name: Resolve version
|
||||
env:
|
||||
REQUEST_DRAFT: ${{ inputs.draft || false }}
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
|
||||
# Unique SemVer without committing daily version bumps to main.
|
||||
VERSION="${VERSION%%-*}-nightly.$(TZ=America/New_York date +%Y%m%d).$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
|
||||
fi
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
|
||||
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
|
||||
echo "::error::Draft releases must be built from the release branch"
|
||||
exit 1
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# 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
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends desktop-file-utils appstream zsync rpm
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
|
||||
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
|
||||
rpm -qip dist/release/*.rpm
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solidcraft
|
||||
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 }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
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 flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common xvfb xauth weston dbus-x11
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
|
||||
snap:
|
||||
name: Snap ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
|
||||
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install snapcraft and the smoke-test tools
|
||||
run: |
|
||||
sudo snap install snapcraft --classic
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends xvfb xauth weston dbus-x11
|
||||
- name: Build the snap
|
||||
run: |
|
||||
sudo --preserve-env=SOLIDCRAFT_VERSION,SOLIDCRAFT_BUILD_DATE env "PATH=$PATH" \
|
||||
packaging/linux/snap-pack.sh --destructive-mode "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
sudo chown -R "$(id -u):$(id -g)" dist target
|
||||
- name: Install it and start the app on headless X11 and Wayland
|
||||
run: |
|
||||
snap_file="dist/release/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.snap"
|
||||
sudo snap install --dangerous "$snap_file"
|
||||
snap info --verbose "$snap_file" | head -n 12
|
||||
snap connections solidcraft
|
||||
solidcraft.cli --version
|
||||
# Snaps expect the login session's runtime directory, which runners don't create.
|
||||
sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "/run/user/$(id -u)"
|
||||
python3 packaging/linux/snap-smoke.py --out snap-smoke
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: snap-smoke-${{ matrix.arch }}
|
||||
path: snap-smoke/*.png
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-snap-${{ matrix.arch }}
|
||||
path: dist/release/*.snap
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
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
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Version-less copies for permanent download links
|
||||
# The README links to releases/latest/download/<name>, which must not change between
|
||||
# releases: solidcraft-<version>-windows-x64.msi is also uploaded as
|
||||
# solidcraft-windows-x64.msi, and so on for the main installers.
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for f in "solidcraft-$VERSION-"*; do
|
||||
case "$f" in
|
||||
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
|
||||
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
|
||||
esac
|
||||
done
|
||||
for f in solidcraft-windows-x64.msi solidcraft-macos-universal.dmg \
|
||||
solidcraft-linux-x86_64.AppImage solidcraft-linux-x86_64.deb solidcraft-linux-x86_64.rpm; do
|
||||
test -s "$f" || { echo "::error::missing version-less copy $f"; exit 1; }
|
||||
done
|
||||
ls -l solidcraft-[!0-9]*
|
||||
- name: Checksums and notes
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Sign the checksum list for in-app updates
|
||||
# Optional until the UPDATE_SIGNING_KEY secret exists (docs/releasing.md, "Update
|
||||
# signing"). The trusted comment names the release, which the app checks.
|
||||
env:
|
||||
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
|
||||
run: |
|
||||
# The key the app checks against (crates/ui-egui/src/update/install.rs, PUBLIC_KEY).
|
||||
public_key=$(sed -n 's/^pub const PUBLIC_KEY: Option<&str> = Some("\([^"]*\)");/\1/p' crates/ui-egui/src/update/install.rs)
|
||||
if [ -z "$UPDATE_SIGNING_KEY" ]; then
|
||||
if [ -n "$public_key" ]; then
|
||||
echo "::error::the app expects signed updates (PUBLIC_KEY is set) but the UPDATE_SIGNING_KEY secret is missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "UPDATE_SIGNING_KEY isn't set: SHA256SUMS.txt stays unsigned"
|
||||
exit 0
|
||||
fi
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y --no-install-recommends minisign
|
||||
umask 077
|
||||
printf '%s\n' "$UPDATE_SIGNING_KEY" >"$RUNNER_TEMP/update.key"
|
||||
minisign -S -s "$RUNNER_TEMP/update.key" -m artifacts/SHA256SUMS.txt \
|
||||
-x artifacts/SHA256SUMS.txt.minisig -t "SolidCraft v$VERSION" </dev/null
|
||||
rm -f "$RUNNER_TEMP/update.key"
|
||||
if [ -n "$public_key" ]; then
|
||||
minisign -V -P "$public_key" -m artifacts/SHA256SUMS.txt -x artifacts/SHA256SUMS.txt.minisig
|
||||
fi
|
||||
- name: Publish nightly pre-release
|
||||
if: github.event_name == 'schedule'
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Create or update the draft release
|
||||
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
# Refuse existing tags: a draft must point at this build, never an older tag.
|
||||
# matching-refs returns [] for an absent tag and fails on API/auth errors.
|
||||
existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \
|
||||
--jq '.[].ref')"
|
||||
if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then
|
||||
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
|
||||
exit 1
|
||||
fi
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != true ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version before releasing."
|
||||
exit 1
|
||||
fi
|
||||
# Replace the complete draft asset set, including assets removed in this build.
|
||||
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
|
||||
while IFS= read -r asset; do
|
||||
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
|
||||
done <<<"$assets"
|
||||
gh release edit "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Snap Store: nightlies go to the edge channel and release drafts to candidate, only after
|
||||
# every package built and the GitHub release was staged. Publishing the GitHub release
|
||||
# promotes candidate to stable (snap-store.yml). Dry runs never upload. Skipped until the
|
||||
# owner adds the SNAPCRAFT_STORE_CREDENTIALS secret (docs/releasing.md).
|
||||
- id: snapstore
|
||||
name: Check for Snap Store credentials
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
|
||||
echo "configured=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: skipping the Snap Store upload."
|
||||
fi
|
||||
- name: Upload the snaps to the Snap Store
|
||||
if: >-
|
||||
steps.snapstore.outputs.configured == 'true' && (github.event_name == 'schedule' ||
|
||||
(github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)))
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sudo snap install snapcraft --classic
|
||||
channel=candidate
|
||||
if [ "$GITHUB_EVENT_NAME" = schedule ]; then channel=edge; fi
|
||||
for arch in x86_64 aarch64; do
|
||||
snapcraft upload --release="$channel" "solidcraft-$VERSION-linux-$arch.snap"
|
||||
done
|
||||
echo "Uploaded the snaps to the Snap Store's $channel channel." >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Keep the files (dry run)
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/*
|
||||
@@ -1,40 +0,0 @@
|
||||
# Publishing a GitHub release (not a prerelease) promotes the snaps that release.yml uploaded to
|
||||
# the Snap Store's candidate channel to stable, so `sudo snap install solidcraft` gets it.
|
||||
# Skipped until the owner adds the SNAPCRAFT_STORE_CREDENTIALS secret; see docs/releasing.md.
|
||||
name: Snap Store
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: snap-store
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: creds
|
||||
name: Check for Snap Store credentials
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
run: |
|
||||
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
|
||||
echo "configured=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: nothing to promote."
|
||||
fi
|
||||
- name: Promote candidate to stable
|
||||
if: steps.creds.outputs.configured == 'true'
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
run: |
|
||||
sudo snap install snapcraft --classic
|
||||
snapcraft status solidcraft
|
||||
snapcraft promote solidcraft --from-channel=latest/candidate --to-channel=latest/stable --yes
|
||||
snapcraft status solidcraft
|
||||
@@ -1,86 +0,0 @@
|
||||
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
|
||||
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
|
||||
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
|
||||
name: Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled, edited]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
schedule:
|
||||
- cron: '41 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Only report what would be labelled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/triage
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
|
||||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-info') &&
|
||||
github.event.sender.login == github.event.issue.user.login) ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority:') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
|
||||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/triage
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
with:
|
||||
script: |
|
||||
const triage = require('./scripts/triage/triage.js');
|
||||
const info = require('./scripts/triage/needs-info.js');
|
||||
const event = context.eventName;
|
||||
const action = context.payload.action;
|
||||
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
const th = (...names) => names.map((data) => ({ data, header: true }));
|
||||
if (event === 'issue_comment') {
|
||||
core.info(await triage.handleComment({ github, context }));
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'issues' && action === 'labeled') {
|
||||
await triage.handleLabeled({ github, context });
|
||||
} else if (event === 'issues' && action === 'edited') {
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'schedule') {
|
||||
const closed = await info.sweep({ github, context });
|
||||
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
|
||||
} else if (event === 'workflow_dispatch') {
|
||||
const dryRun = process.env.DRY_RUN !== 'false';
|
||||
const plan = await triage.backfill({ github, context, dryRun });
|
||||
const asks = await info.backfill({ github, context, dryRun });
|
||||
await core.summary
|
||||
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
|
||||
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
|
||||
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
|
||||
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
|
||||
.write();
|
||||
} else {
|
||||
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
|
||||
if (event === 'issues') await info.handleOpened({ github, context });
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
# Publishing a GitHub release (not a prerelease) opens a pull request at microsoft/winget-pkgs
|
||||
# that adds the new version of bherbruck.SolidCraft, so `winget install bherbruck.SolidCraft`
|
||||
# and `winget upgrade` get it. Komac (https://github.com/russellbanks/Komac) downloads the three
|
||||
# versioned MSIs, reads their product codes and copies the rest from the previous manifest.
|
||||
# Skipped until the owner adds the WINGET_TOKEN secret, and while the package isn't in
|
||||
# winget-pkgs yet; see docs/releasing.md.
|
||||
name: WinGet
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Published release tag to submit (e.g. v0.11.1)
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: winget
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PACKAGE_ID: bherbruck.SolidCraft
|
||||
KOMAC_VERSION: 2.16.0
|
||||
KOMAC_SHA256: 7d2707fa6210f2789a3702de49fbd150b736dbf426ee0b9bc8e098736f9fd82d
|
||||
|
||||
jobs:
|
||||
submit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
name: Check for the token and the package
|
||||
env:
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
if [ -z "$WINGET_TOKEN" ]; then
|
||||
echo "No WINGET_TOKEN secret: nothing to submit."
|
||||
exit 0
|
||||
fi
|
||||
case "$TAG" in
|
||||
v[0-9]*.[0-9]*.[0-9]*-*|*nightly*) echo "$TAG is a prerelease: not submitted."; exit 0 ;;
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "::error::Unexpected tag '$TAG'"; exit 1 ;;
|
||||
esac
|
||||
if ! gh api "repos/microsoft/winget-pkgs/contents/manifests/b/bherbruck/SolidCraft" --silent; then
|
||||
echo "$PACKAGE_ID isn't in winget-pkgs yet: nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
echo "version=${TAG#v}" >>"$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
- name: Install Komac
|
||||
if: steps.check.outputs.version != ''
|
||||
run: |
|
||||
archive="komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSLo "$RUNNER_TEMP/$archive" \
|
||||
"https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/$archive"
|
||||
echo "$KOMAC_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c -
|
||||
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" komac
|
||||
- name: Open the winget-pkgs pull request
|
||||
if: steps.check.outputs.version != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
TAG: ${{ steps.check.outputs.tag }}
|
||||
run: |
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG"
|
||||
"$RUNNER_TEMP/komac" update "$PACKAGE_ID" --version "$VERSION" \
|
||||
--urls "$base/solidcraft-$VERSION-windows-x64.msi" \
|
||||
"$base/solidcraft-$VERSION-windows-x86.msi" \
|
||||
"$base/solidcraft-$VERSION-windows-arm64.msi" \
|
||||
--release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/$TAG" \
|
||||
--submit
|
||||
@@ -6,7 +6,3 @@
|
||||
*.swp
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
# SolidCraft — instructions for agents
|
||||
# SolveCraft — instructions for agents
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name
|
||||
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
|
||||
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
|
||||
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
|
||||
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
|
||||
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
|
||||
`/solidcraft/`; do not add permanent old-name Pages aliases.
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section.
|
||||
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
@@ -25,60 +20,37 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
|
||||
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
|
||||
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
|
||||
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
|
||||
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
|
||||
artwork, help text, tooltips, templates or materials, and never commit files produced by
|
||||
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
|
||||
holds ids and names, nothing else). File formats come from public specifications. **Never read,
|
||||
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
|
||||
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — ever.** Command icons are the original SVG masters in
|
||||
`assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
|
||||
**Before any icon, icon assignment or media edit, read `assets/AGENTS.md`,
|
||||
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical
|
||||
bases and named finished references there; never invent a parallel icon style. Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
|
||||
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
|
||||
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
|
||||
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
|
||||
@@ -88,127 +60,32 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **One outer UI container**: all panels, floating sheets, menus, overlays and platform UI
|
||||
windows use `ui_kit::Container` (directly or through `Panel`, `Window`, `Confirm`, `Menu`).
|
||||
Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
|
||||
System/report windows use `ui_kit::Window`; its low-level host builder is kit-private.
|
||||
Do not construct raw egui hosts, outer popup/window frames or shadows in feature modules.
|
||||
`cargo xtask ui-containers` recursively checks source/examples in all current and future crates
|
||||
and apps. It runs in the fast repository CI job and `cargo xtask ci`, with no caller allowances.
|
||||
See `docs/ui-container-audit.md` for the inventory and legitimate content/native exceptions.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **Application code is Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
Local asset authoring, lint and review tools use Python 3.11+ as documented in
|
||||
`assets/AGENTS.md`; Python is not an application runtime dependency. The application exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
|
||||
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
|
||||
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
|
||||
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
|
||||
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
|
||||
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
|
||||
check `git ls-files` before pushing.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
|
||||
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
|
||||
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
|
||||
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
|
||||
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
|
||||
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
|
||||
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
|
||||
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
|
||||
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
|
||||
make its pending status clear at handoff; do not claim it merged without checking.
|
||||
|
||||
## Before you start: look at what is already open
|
||||
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
|
||||
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
|
||||
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
|
||||
work on the same issue, the same feature under another number, and PRs that change the files
|
||||
you are about to change (`gh pr list ... --json number,title,files`).
|
||||
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
|
||||
PRs for one change can't both land, and one of them is wasted work and CI.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
|
||||
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
|
||||
a claim before you have a branch.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
|
||||
request on a connection carries the launch's token as `"token"`: read it from
|
||||
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
|
||||
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
|
||||
- `{"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}`
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
|
||||
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
|
||||
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
|
||||
`ui.render` or `solidcraft-cli snapshot`.
|
||||
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
|
||||
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
`ui.render` or `solvecraft-cli snapshot`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
|
||||
with Fusion's `measure.json` (writes `docs/oracle.md`).
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
`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`.
|
||||
|
||||
+13
-525
@@ -4,553 +4,41 @@ Every non-code asset in this repository (images, icons, fonts, example parts) is
|
||||
its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset
|
||||
file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** SolidCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolidCraft contributors or
|
||||
**Policy (mandatory):** SolveCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolveCraft contributors or
|
||||
third-party material under an open licence. Screenshots of Autodesk software are never committed.
|
||||
|
||||
Editable drawing code, native projects and shipped exports are collected under `assets/`.
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
- the UI icon set (`crates/ui-egui/src/icons.rs`);
|
||||
- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
`crates/ui-egui/src/timeline.rs`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
|
||||
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
|
||||
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | retained editable historical VectorCraft source, with named face and background layers; redundant exports retired | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/0.png | SolidCraft contributors | original Sample Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/1.png | SolidCraft contributors | original Sheet Metal Bracket from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/2.png | SolidCraft contributors | original Hinge Assembly from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/3.png | SolidCraft contributors | original Plastic Enclosure from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/4.png | SolidCraft contributors | original Bolted Flange from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/5.png | SolidCraft contributors | original Vent Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/scripts.json | SolidCraft contributors | SHA-256 fingerprints of the original sample scripts, generated with the shipped thumbnails | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-floating.png | SolidCraft contributors | original UX mockup (Model tab, floating layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-compact.png | SolidCraft contributors | original UX mockup (Model tab, compact docked layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sketch.png | SolidCraft contributors | original UX mockup (Sketch tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sheet-metal.png | SolidCraft contributors | original UX mockup (Sheet Metal contextual tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/start-page.png | SolidCraft contributors | original UX mockup (start page (File view)) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/workspaces.png | SolidCraft contributors | original UX mockup (workspace switcher) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/command-map.png | SolidCraft contributors | original UX mockup (ribbon command map) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf | IBM Corp. (IBM Plex Mono) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-mono/OFL.txt; the interface monospace font | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf | IBM Corp. (IBM Plex Sans, variable wdth and wght) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-sans/OFL.txt; the default interface font | SIL OFL 1.1 |
|
||||
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_inch.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_placed.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_seam.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_twice.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/dome.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/dome_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/far_cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/grooved_shaft.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/plate_100_holes.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/sheet_disc.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/hu.json | SolidCraft contributors | Original Hungarian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/id.json | SolidCraft contributors | Original Indonesian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
|
||||
| assets/locales/nl.json | SolidCraft contributors | Original Dutch translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/sv.json | SolidCraft contributors | Original Swedish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ro.json | SolidCraft contributors | Original Romanian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh-TW.json | SolidCraft contributors | Original Traditional Chinese (Taiwan) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/platinum-9.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/cupertino.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/green-phosphor.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/amber-phosphor.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/blueprint.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/one-dark.json | SolidCraft contributors | original SolidCraft theme file with colours adapted from the One Dark palette (Atom One Dark by GitHub Atom, https://github.com/atom/atom, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/tokyo-night.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Tokyo Night by enkia, https://github.com/enkia/tokyo-night-vscode-theme, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solarized-light.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Solarized by Ethan Schoonover, https://github.com/altercation/solarized, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/nord.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Nord by Sven Greb and the Nord contributors, https://github.com/nordtheme/nord, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/high-contrast.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/graphite.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/nosferatu.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Dracula by Zeno Rocha and the Dracula Theme contributors, https://github.com/dracula/dracula-theme, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/catppuccin-mocha.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Catppuccin by the Catppuccin organisation, https://github.com/catppuccin/catppuccin, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/aliases.toml | SolidCraft contributors | icon ids that share a master, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/palette.toml | SolidCraft contributors | icon colour roles (light and dark) and accents, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/chevron_right.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/close.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/disclosure_closed.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/disclosure_open.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/fit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/folder.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/help.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/home.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/lookat.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/moon.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/nav_settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/orbit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/plus.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/pan.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/perspective.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/redo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/save.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/spin.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/style.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/undo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/zoom.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/accessibility.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/activate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/align.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/appearance.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/as_built.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/auto_constrain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/bendtable.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/blend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/blend_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/boss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/boundary.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/boundary_fill.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/box.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/break.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_coincident.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_collinear.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_concentric.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_equal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_fix.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_hv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_midpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_parallel.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_perpendicular.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_smooth.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_symmetry.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/c_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/camera.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/canvas.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/capture.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/center_of_mass.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/centerline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/chain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/checkpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle2.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/configuration_table.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/conic.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/constrainer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/convert_sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/copy.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/curvature_comb.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/curvature_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/cylinder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/decal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/delete.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/dimension.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/dots.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/draft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/draft_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/drive.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/dxf.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/edge_continuity.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/edit.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/ellipse.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/emboss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/environment_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/explode.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/export.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/extend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/extrude.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/eye.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fastener.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/filter.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/finish.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fit_section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/flange.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/flat.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/folder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/graph.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/hem.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/hole.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/import.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/include.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/inspect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/interference.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/intersect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/intersection_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/iso_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/iso_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/joint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/joint_origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/library.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/line.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/line_mid.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/lip.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/list.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/loft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/material.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/measure.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/min_radius.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/motion_link.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/move.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/new.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/open.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/params.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/paste.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/patch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_circ.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_path.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/assistant.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/agent_link.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_two_sides.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_symmetric.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/import_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/export_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/send.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/stop.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/shield.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plastic_assign.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plastic_rule.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/play.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/presspull.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/project.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/project_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rect_rounded.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/refold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rename.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/render_image.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/render_scene.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/reorder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rest.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/restore.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/reverse_normal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/revert.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/revolve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rib.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rigid_group.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rollback.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/ruled.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/scale.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/select.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sheet_rules.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/shell.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/snap.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sphere.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/spline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/spline_cv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/split_body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/split_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/spun.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/star.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/status_error.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/status_warning.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/stitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/suppress.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/svg.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sweep.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/text.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/thicken.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/thread.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/torus.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/trim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/unfold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/unstitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/untrim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/update.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/zebra.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/as_built.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/component.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/curvature_comb.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/interference.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/iso_analysis.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/joint.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/pattern.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/small/section.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_animate.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/polygon_circumscribed.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/polygon_edge.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/slot_overall.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/arc_slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/gear.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/rack_pinion.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/screw.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/cam.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/trace.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/agent_chat.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/normal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/infinite.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_flip.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pattern_center.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/reach.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/cut.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/compare.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/web.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/replace_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/bounding_solid.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/delete_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/snap_fit.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_sets.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_all.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_off.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/contact_new.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_through_cylinder.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_normal_to_face.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_two_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_two_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/axis_edge.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_vertex.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_three_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_center.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_edge_plane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/point_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_midplane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_three_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_tangent.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_perpendicular.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_design.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_drawing.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/workspace/ws_render.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/references.toml | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/bases.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/new_icon.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/index.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/guide.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/review.css | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review/review.js | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/icon_palette.py | SolidCraft contributors | Original shared semantic icon palette resolver for repository tools | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/icon_status.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/lint_icons.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/preview.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review_state.py | SolidCraft contributors | icon review state helper (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/requested_icons.txt | SolidCraft contributors | Runtime icon requests collected by the headless menu audit, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/review.json | SolidCraft contributors | icon review state (accepted icons and notes), original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
|
||||
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
||||
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
| SolidCraft file | Adapted from | Licence |
|
||||
| SolveCraft file | Adapted from | Licence |
|
||||
|---|---|---|
|
||||
| xtask/src/layers.rs | CADCraft `xtask/src/layers.rs` (dependency layering rules and tests) | MIT OR Apache-2.0 |
|
||||
| apps/solidcraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
|
||||
| apps/solvecraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/lib.rs (screenshot queue, synthetic input) | CADCraft `crates/ui-egui/src/lib.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/control.rs (method dispatch and screenshot saving) | CADCraft `crates/ui-egui/src/control.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs (paint-callback structure) | CADCraft `crates/ui-egui/src/gpu.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solidcraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| apps/solidcraft/src/open_documents.rs (macOS: files opened from Finder and the Dock, its test) | VectorCraft `apps/vectorcraft/src/open_documents.rs` (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||
|
||||
## Incremental HDR codec adapter
|
||||
|
||||
`crates/render/src/lighting/hdr.rs` adapts the scanline/component/old-RLE decoding
|
||||
state machines from the Radiance decoder in `image` 0.25.10
|
||||
([source](https://docs.rs/crate/image/0.25.10/source/src/codecs/hdr/decoder.rs)),
|
||||
MIT OR Apache-2.0. Metadata parsing still uses that library. The adapter makes row
|
||||
work cooperative and adds checked run lengths, zero-run rejection, width validation,
|
||||
header/input/radiance bounds and cancellation. Original licence texts are retained
|
||||
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
|
||||
## File-format crates
|
||||
|
||||
| Crate | Used for | Licence |
|
||||
|---|---|---|
|
||||
| gltf 1.4.1, gltf-json 1.4.1, gltf-derive 1.4.1 (https://github.com/gltf-rs/gltf) | glTF 2.0 import (crates/io/src/gltf_in.rs): the JSON document model and its reference checks; the GLB container, buffers and accessors are read by SolidCraft's own code | MIT OR Apache-2.0 |
|
||||
| inflections 1.1.1 (build-time, through gltf-derive) | case conversion in gltf's derive macros | MIT |
|
||||
| lazy_static 1.5.1 (through gltf-json) | | MIT OR Apache-2.0 |
|
||||
|
||||
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
|
||||
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
|
||||
|
||||
## FreeCAD and Rhino import library
|
||||
|
||||
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
|
||||
0.6.1 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
|
||||
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
|
||||
in native builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
|
||||
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
|
||||
codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is
|
||||
copied into SolidCraft.
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolidCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solidcraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solidcraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solidcraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui
|
||||
(MIT OR Apache-2.0; both original licence texts are included). Rust sources and
|
||||
its original `egui.wgsl` shader are retained; no images or other artwork are copied.
|
||||
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
|
||||
and shares that effective count with the surface attachments and CAD callbacks.
|
||||
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
|
||||
A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
|
||||
skipped with a warning instead of panicking (#1469).
|
||||
Paint callbacks are prepared only after a drawable is acquired, so a skipped
|
||||
surface frame cannot strand cached meshes without their GPU initialization.
|
||||
| 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) |
|
||||
|
||||
@@ -1,3 +1,91 @@
|
||||
@AGENTS.md
|
||||
# SolveCraft — instructions for agents
|
||||
|
||||
The agent instructions for this repo live in AGENTS.md; edit that file only.
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
unless it lists the decision as the owner's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
|
||||
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
|
||||
artwork, help text, tooltips, templates or materials, and never commit files produced by
|
||||
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
|
||||
holds ids and names, nothing else). File formats come from public specifications. **Never read,
|
||||
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
|
||||
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
|
||||
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
|
||||
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
|
||||
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
|
||||
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **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/solvecraft (private). After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
|
||||
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` 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`.
|
||||
|
||||
## 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`.
|
||||
|
||||
-398
@@ -1,398 +0,0 @@
|
||||
# Contributing to SolidCraft
|
||||
|
||||
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
|
||||
You sketch on a plane, constrain the sketch, turn profiles into solids, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
|
||||
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
|
||||
model with.
|
||||
|
||||
Help is welcome, whether that's a one-line fix or owning a whole area. This page covers how to
|
||||
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
|
||||
needed most.
|
||||
|
||||
Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Kernel work is paused
|
||||
|
||||
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
|
||||
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
|
||||
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
|
||||
`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused`
|
||||
label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss
|
||||
are still taken as issues, and everything outside the kernel carries on as normal.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
|
||||
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://github.com/trunk-rs/trunk)
|
||||
([documentation](https://trunk-rs.github.io/trunk/)).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/tangency-labs/solidcraft && cd solidcraft
|
||||
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, repository, Cargo package and executable name.
|
||||
Build the desktop app with `cargo build --release -p solidcraft`, or the CLI with
|
||||
`cargo build --release -p solidcraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-cli commands`
|
||||
after building). The old name appears only in the compatibility shims listed in
|
||||
[docs/compatibility.md](docs/compatibility.md); don't add new uses of it.
|
||||
|
||||
Web: `cd apps/solidcraft-web && trunk serve --release`, then open the printed URL (`?sample`
|
||||
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
|
||||
HTTPS or localhost, because it uses the Origin Private File System.
|
||||
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
NixOS has no `/usr/lib`, so nothing is on `LD_LIBRARY_PATH` and a plain `cargo run` finds none of
|
||||
the libraries the app loads at run time: the Wayland ones winit opens the window through, the EGL
|
||||
loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-desktop-portal
|
||||
through (a dialog then never appears and the pick returns no path):
|
||||
|
||||
```
|
||||
ERROR solidcraft: winit EventLoopError: ... The wayland library could not be loaded
|
||||
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
|
||||
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
|
||||
```
|
||||
|
||||
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) supplies Wayland,
|
||||
X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS
|
||||
driver manifests in `/run/opengl-driver/share`, so Vulkan can discover the system
|
||||
drivers. Enable `hardware.graphics.enable` on NixOS; other Linux distributions
|
||||
may need a matching [nixGL wrapper](https://github.com/nix-community/nixGL).
|
||||
The default development shell retains the Rust/C toolchain already on `PATH`;
|
||||
`nix develop .#rust` supplies the pinned toolchain and builds in `target/nix`.
|
||||
|
||||
To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal
|
||||
commands, desktop entry and generated icons, use `nix profile install .#solidcraft`.
|
||||
`nix build` alone creates a build result; it does not install a launcher in your
|
||||
desktop menu. The installed `solidcraft` command runs the wrapped app; `solidcraft-cli` is included as
|
||||
well. See
|
||||
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
|
||||
desktop integration and how to distinguish an old executable on `PATH`.
|
||||
|
||||
## How the code is laid out
|
||||
|
||||
The [architecture page](docs/architecture.md) has the layer table. The short version:
|
||||
|
||||
- `crates/geom`: vectors, planes, meshes, measures.
|
||||
- `crates/sketch`: our own constraint solver, plus profile finding.
|
||||
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
|
||||
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
|
||||
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
|
||||
copies of three truck crates live in `vendor/` and are excluded from our fmt/clippy rules.
|
||||
- `crates/doc`: the document. It holds parameters and expressions, the feature timeline and its
|
||||
incremental rebuild, components and occurrences, joints, appearances, and persistent naming.
|
||||
- `crates/io`: design files and mesh formats (3MF, STL, OBJ).
|
||||
- `crates/engine`: the session and the command registry. **Everything the user can do is a
|
||||
command**, and the toolbar, dialogs, scripts, CLI, control channel and MCP all run the same
|
||||
commands.
|
||||
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
|
||||
depends on egui.
|
||||
- `crates/mcp`, `apps/solidcraft`, `apps/solidcraft-cli`, `apps/solidcraft-web`.
|
||||
|
||||
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
|
||||
|
||||
## Rules that aren't negotiable
|
||||
|
||||
1. **Independent.** SolidCraft is independent. Behaviour is designed from public
|
||||
documentation, general CAD conventions and user feedback. Reference tests use analytic values
|
||||
and open-source tools. We don't drive, script, automate or extract data from commercial CAD
|
||||
products, and we never copy another product's code, icons, artwork, help text, tooltips,
|
||||
templates or materials. Command ids are our own (`solid.fillet`).
|
||||
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
|
||||
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolidCraft is
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
|
||||
open-source application to get reference values for a test is fine; reading its source is
|
||||
not.) Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
|
||||
lists it once the change lands: the Generated files workflow rewrites it on `main`.
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
4. **Never crash.** People trust a CAD app with their work.
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
|
||||
`apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and
|
||||
`crates/threedx-client/src/macos.rs` (the 3Dconnexion driver's client API on macOS), and the
|
||||
compiler denies it.
|
||||
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||
- 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,
|
||||
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
|
||||
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
|
||||
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
|
||||
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
|
||||
`NavCaps` saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use
|
||||
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
|
||||
`hand_rolled_camera_input_is_rejected` test fails if a page does.
|
||||
|
||||
Labels, parameter docs, messages and docs use the terms in the
|
||||
[glossary](https://docs.solidcraft.io/reference/glossary.html): one term for each idea. A change that adds a new
|
||||
idea adds its term there.
|
||||
|
||||
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
|
||||
the most complete description of the house style.
|
||||
|
||||
## Tests
|
||||
|
||||
| What | Where | Run |
|
||||
|---|---|---|
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
|
||||
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
|
||||
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
|
||||
|
||||
A geometry change that changes the robustness goldens re-records them in the same pull request:
|
||||
`ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::`
|
||||
(see [docs/robustness.md](docs/robustness.md)); CI runs every seed on pull requests that touch geometry.
|
||||
[docs/mutation-testing.md](docs/mutation-testing.md) shows how to find kernel behaviour these
|
||||
black-box suites miss.
|
||||
|
||||
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
|
||||
press keys, then assert on the result.
|
||||
|
||||
```json
|
||||
[
|
||||
{"start": "sketch.create"},
|
||||
{"click": {"plane": "XY"}},
|
||||
{"start": "sketch.rectangle.two_point"},
|
||||
{"click": {"sketch": [0, 0]}},
|
||||
{"click": {"sketch": [40, 30]}},
|
||||
{"expect": {"errors": 0}}
|
||||
]
|
||||
```
|
||||
|
||||
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
|
||||
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
|
||||
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
|
||||
runs Clippy, the test build and tests for those and their dependents. The
|
||||
[package selector](scripts/ci/affected.py) and [CI workflow](.github/workflows/ci.yml) define
|
||||
which changes require native checks; documentation, packaging, other-workflow and bot-script-only
|
||||
changes run the repository checks only, on pull requests and on `main`. Workspace-wide changes,
|
||||
merge-queue groups, nightly runs and Run workflow select the full workspace. A push to `main`
|
||||
reuses a successful merge-queue run for the same commit, and runs only the repository checks
|
||||
when no code changed since the last push to `main` that passed; otherwise it runs the full
|
||||
suite. Doc tests, reference tests and the Windows jobs run on `main` and nightly only.
|
||||
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive; two shards run on
|
||||
pull requests and four during full validation. 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 twelve minutes each), which run in full validation
|
||||
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
|
||||
Check that validation after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that change the kernel, truck or the goldens run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
`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`, the `atomic_commands` contract tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
|
||||
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
|
||||
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
|
||||
one that makes a passing action fail fails the sample every CI run checks.
|
||||
|
||||
**The reference tests** live 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. The generated
|
||||
[reference test report](docs/oracle.md) has the current case count and results. The reference-test
|
||||
CI job runs `cargo xtask oracle` during full validation; pull-request runs skip it. 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.
|
||||
|
||||
## Changing the design file format
|
||||
|
||||
A design file says which format it is in (`"format": "solidcraft/N"`). If your change saves
|
||||
something new in a design (a field, a feature type, a new kind of name), or makes older designs
|
||||
mean something else, it needs a format change. **Add one file, and edit nothing shared:**
|
||||
|
||||
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a
|
||||
`//!` note: what is saved, what older designs get without it, and that the last release asks
|
||||
for an update instead of meeting it.
|
||||
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) ->
|
||||
crate::Result<()>` in the same file. Pending steps run in file-name order, after the older
|
||||
formats' steps.
|
||||
3. Don't touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another
|
||||
change's file, or any `v<N>/` folder: those are fixed once released.
|
||||
|
||||
That's all. The build counts the files: while any are pending it writes the next format number,
|
||||
which no release reads, so a release refuses a design that has your change instead of losing it.
|
||||
A file also lists the pending changes it has (`format_pending`), so a later development build
|
||||
runs only the steps that file is missing. Two pull requests that each add a change merge without
|
||||
a conflict. `crates/doc/src/format/pending/README.md` has an example.
|
||||
|
||||
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
|
||||
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
|
||||
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
|
||||
|
||||
## Lists many changes add to
|
||||
|
||||
Some files gain an item in many pull requests at once. They are kept so that two changes that
|
||||
each add an item only meet when their items are neighbours, which spares everyone a conflict
|
||||
and a fresh CI run:
|
||||
|
||||
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
|
||||
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
|
||||
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
|
||||
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
|
||||
sorted). Add a new module into the existing group, not as a new group at the end.
|
||||
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
|
||||
checks it).
|
||||
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
|
||||
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
|
||||
features`). Don't regenerate or edit them in a pull request.
|
||||
- **Design format**: one file per change, never an edit to a shared number (above).
|
||||
|
||||
## Sending a change
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
|
||||
PR** before you start, so two people don't build the same thing:
|
||||
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- The bot records the claim when the PR is opened: it assigns you, adds `in progress` and
|
||||
says so on the issue. It reads `#N` in the PR title and `Fixes #N` in the body. If someone
|
||||
already holds the issue, or an older open PR references it, it says so on your PR instead.
|
||||
- Want to stake a claim before you have a branch? Comment `/claim` on the issue (below).
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
2. Branch from `main` and keep the PR to one topic.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
|
||||
Heavy CI runs after review: a pull request gets the formatting and repository checks and a
|
||||
quick compile (`cargo check`) when you push. Once a maintainer has reviewed it they add the
|
||||
`approved-for-ci` label when the PR is ready to merge, which runs the full CI (build, tests,
|
||||
goldens, Clippy); every push after that runs the full set again. Maintainers merge approved
|
||||
PRs in groups by area, so PRs that touch the same code meet a conflict at most once.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Commenting `/claim` assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
|
||||
without pushes. These automatic cleanup deadlines are separate from the daily progress and
|
||||
72-hour takeover policy above. A draft PR that references the issue claims it the same way.
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
geometry, assembly and backend work. Coding agents should run a small reproduction and
|
||||
capture the evidence themselves; please don't ask a human for evidence the agent can produce.
|
||||
Show the actual failure or result, preferably before and after, with brief steps and
|
||||
expected/actual behavior. An arrow or annotation can help identify the detail that matters.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot replace evidence of visible behavior. Diagrams are
|
||||
useful for architectural reasoning when actual screenshots cannot illustrate the point.
|
||||
Changes with no useful observable behavior do not need screenshot or video evidence. Use a
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
|
||||
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
|
||||
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
|
||||
push a change anyway, or GitHub reports a conflict, rebase onto the latest `main` (push your
|
||||
branch with `--force-with-lease`) or merge `main` in once, and resolve conflicts without losing
|
||||
others' changes. Don't re-run CI on an unchanged commit either: a real failure fails again.
|
||||
Re-run only a job that runner trouble cancelled, with "Re-run failed jobs". While a task remains
|
||||
active, follow an open PR through its status changes; at handoff, clearly identify anything
|
||||
still awaiting merge.
|
||||
|
||||
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
owning one area, with a human setting direction and reporting bugs. Their working notes are in
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
## Labels and triage
|
||||
|
||||
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
|
||||
correct them freely and it won't put them back.
|
||||
|
||||
- **PRs** get `area:*` from the paths they change (`crates/kernel` and `crates/geom` →
|
||||
`area:kernel`, `crates/ui-egui` and the apps → `area:ui`, `.github/`, `scripts/`, `docs/` →
|
||||
`area:infra`, and so on).
|
||||
- **Issues** get `bug` (in-app reports and the bug template), `enhancement` (feature requests),
|
||||
`priority:P1` for crashes, and an `area:*` from the title or the reported command. Until it has
|
||||
a priority, an issue carries `needs-triage`; setting a priority removes it.
|
||||
- **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a
|
||||
priority, which replaces the old one; `-label` removes one) to sort an issue or PR.
|
||||
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
|
||||
the steps (and the version, when it's missing). An app report that arrived empty is asked to
|
||||
paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the
|
||||
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
|
||||
reopened any time. Feature requests, app reports with their details, and maintainers' issues
|
||||
are never asked.
|
||||
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
|
||||
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
|
||||
In order of how much they'd help users:
|
||||
|
||||
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
|
||||
Constant, variable-radius and chord fillets work, and so do corners where fillets of
|
||||
different radii meet, including three rounds meeting at a vertex. Next are setback corners, fillets along curved edges between curved
|
||||
faces, and fillets that consume neighbouring faces.
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
|
||||
## Updating the generated feature list
|
||||
|
||||
After adding or renaming commands, run `cargo xtask features`. The registry is the
|
||||
source of truth for `docs/features.md`; do not hand-edit the generated rows.
|
||||
The generator sorts commands by ID and omits global and per-section counters,
|
||||
so additions to unrelated areas no longer rewrite shared summary lines.
|
||||
|
||||
If two branches still conflict in this file (for example, adjacent additions in
|
||||
the same area), first resolve the command registry and source conflicts, then run
|
||||
`cargo xtask features` again and stage the regenerated file. Do not use Git's
|
||||
`union` merge driver: it can silently retain stale or duplicate command rows.
|
||||
The generator tests cover stable ordering and additions that leave existing lines
|
||||
unchanged. The initial switch to sorted rows is a one-time larger diff.
|
||||
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
|
||||
Generated
+188
-1786
File diff suppressed because it is too large
Load Diff
+18
-94
@@ -5,44 +5,30 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
authors = ["SolidCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solidcraft"
|
||||
rust-version = "1.90"
|
||||
authors = ["SolveCraft contributors"]
|
||||
repository = "https://github.com/storytold/solvecraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
solidcraft-geom = { path = "crates/geom" }
|
||||
solidcraft-sketch = { path = "crates/sketch" }
|
||||
solidcraft-kernel = { path = "crates/kernel" }
|
||||
solidcraft-render = { path = "crates/render" }
|
||||
solidcraft-gpu = { path = "crates/gpu" }
|
||||
solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
solvecraft-geom = { path = "crates/geom" }
|
||||
solvecraft-sketch = { path = "crates/sketch" }
|
||||
solvecraft-kernel = { path = "crates/kernel" }
|
||||
solvecraft-render = { path = "crates/render" }
|
||||
solvecraft-doc = { path = "crates/doc" }
|
||||
solvecraft-io = { path = "crates/io" }
|
||||
solvecraft-engine = { path = "crates/engine" }
|
||||
solvecraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solvecraft-mcp = { path = "crates/mcp" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
# derive macros (crates/derive); already in the tree through serde_derive
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
# command parameter and result schemas
|
||||
schemars = "1.2"
|
||||
serde_path_to_error = "0.1"
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
|
||||
semver = "1"
|
||||
|
||||
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
|
||||
truck-modeling = "0.6"
|
||||
@@ -50,43 +36,16 @@ truck-shapeops = { version = "0.4", default-features = false }
|
||||
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
|
||||
truck-stepio = { version = "0.3", default-features = false }
|
||||
truck-topology = "0.6"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0); already in the
|
||||
# tree through truck-meshalgo.
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# STEP text in a legacy encoding (GBK, Shift_JIS, …) instead of Part 21 escapes (#2344): detect
|
||||
# and decode it. Both MIT OR Apache-2.0 (encoding_rs adds BSD-3-Clause data); encoding_rs is
|
||||
# already in the tree through cadmpeg-codec-step.
|
||||
chardetng = "0.1"
|
||||
encoding_rs = "0.8"
|
||||
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
|
||||
# read by crates/io itself, so no image decoding or file access comes with it.
|
||||
gltf = { version = "1.4", default-features = false, features = ["names"] }
|
||||
|
||||
# FreeCAD and Rhino import (Apache-2.0, pure Rust apart from zstd; native builds only).
|
||||
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
|
||||
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
|
||||
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
|
||||
cadmpeg-core = "=0.6.1"
|
||||
cadmpeg-ir = "=0.6.1"
|
||||
cadmpeg-registry = { version = "=0.6.1", default-features = false, features = ["fcstd", "rhino", "step"] }
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.38"
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||
png = "0.18.1"
|
||||
# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff).
|
||||
weezl = "0.1.12"
|
||||
# rasterises the UI icon SVGs (BSD-3-Clause, pure Rust, builds for wasm)
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }
|
||||
# build scripts: reads the icon palette (MIT OR Apache-2.0)
|
||||
toml = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
@@ -94,10 +53,6 @@ eframe = { version = "0.36", default-features = false, features = ["persistence"
|
||||
# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
|
||||
web-time = "1"
|
||||
egui-wgpu = "0.36"
|
||||
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
|
||||
naga = { version = "30", default-features = false }
|
||||
pollster = "0.4"
|
||||
rfd = "0.17"
|
||||
|
||||
[workspace.lints.rust]
|
||||
@@ -117,45 +72,14 @@ opt-level = 2
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
# Tests and lints (`cargo xtask ci`, GitHub CI): quick to build, quick enough to run. Light
|
||||
# optimisation, line tables only (file:line in backtraces), no LTO. Debug assertions are off because
|
||||
# truck's debug-only checks reject valid geometry (seam edges, closed edges); that was the only
|
||||
# reason the tests used to build with --release, which cost most of CI's time.
|
||||
[profile.ci]
|
||||
inherits = "dev"
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
debug-assertions = false
|
||||
overflow-checks = true
|
||||
incremental = false
|
||||
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 2
|
||||
# No line tables for dependencies: they were over a third of the test archive every CI shard
|
||||
# downloads. Our own crates keep theirs, so a backtrace still names file:line in them.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
# Line tables only: crash reports name file:line in their backtraces (no runtime cost; a larger
|
||||
# binary). See docs/crash-reports.md.
|
||||
debug = "line-tables-only"
|
||||
debug = 0
|
||||
|
||||
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
# truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
[patch.crates-io]
|
||||
truck-geometry = { path = "vendor/truck-geometry" }
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
|
||||
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
|
||||
# builds, since every mutant rebuilds the crates above the one it changes.
|
||||
[profile.mutants]
|
||||
inherits = "ci"
|
||||
incremental = true
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 SolidCraft contributors
|
||||
Copyright (c) 2026 SolveCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
SolidCraft
|
||||
Copyright (c) 2026 SolidCraft contributors.
|
||||
SolveCraft
|
||||
Copyright (c) 2026 SolveCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
Third-party material
|
||||
|
||||
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
|
||||
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
|
||||
`solidcraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
|
||||
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
|
||||
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolidCraft
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
|
||||
modifications (listed in vendor/README.md and marked in the source); its licence is in
|
||||
vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
|
||||
|
||||
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
|
||||
licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
|
||||
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
||||
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
||||
|
||||
The incremental Radiance RGBE row adapter in crates/render/src/lighting/hdr.rs adapts
|
||||
decoding state machines from image 0.25.10 (https://github.com/image-rs/image),
|
||||
MIT OR Apache-2.0. ATTRIBUTION.md describes the bounded/cancellable changes; the
|
||||
original licence texts are included in THIRD-PARTY-LICENSES.txt.
|
||||
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
|
||||
list is in ATTRIBUTION.md.
|
||||
|
||||
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
|
||||
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
|
||||
per-file list is in ATTRIBUTION.md.
|
||||
|
||||
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Dassault Systèmes, PTC, Siemens, Blender or any other CAD vendor. Fusion, AutoCAD,
|
||||
Inventor, Alias and Tinkercad are trademarks of Autodesk, Inc.; SolidWorks is a trademark of
|
||||
Dassault Systèmes; Onshape and Creo are trademarks of PTC; Blender is a trademark of the Blender
|
||||
Foundation. Product names appear only to describe compatibility, such as the mouse navigation
|
||||
presets in Preferences › Navigation and file formats from public specifications.
|
||||
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
describe compatibility goals.
|
||||
|
||||
@@ -1,144 +1,102 @@
|
||||
<p align="center">
|
||||
<img src="assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" width="96" height="96" alt="SolidCraft" />
|
||||
</p>
|
||||
# SolveCraft
|
||||
|
||||
<h1 align="center">SolidCraft</h1>
|
||||
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
|
||||
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
|
||||
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
|
||||
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
|
||||
storytold "craft" family.
|
||||
|
||||
<p align="center">
|
||||
<strong>An open-source parametric 3D CAD application built in pure Rust.</strong><br />
|
||||
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.
|
||||
</p>
|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
[![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]
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://solidcraft.io">Website</a> ·
|
||||
<a href="https://solidcraft.io/download">Download</a> ·
|
||||
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
|
||||
<a href="https://docs.solidcraft.io/">Documentation</a> ·
|
||||
<a href="https://www.blacksmith.sh"><img src="https://img.shields.io/badge/CI-Blacksmith-000000?logo=githubactions&logoColor=white" alt="CI powered by Blacksmith" /></a>
|
||||
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
|
||||
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/tangency-labs/solidcraft/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://github.com/tangency-labs/solidcraft/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases/latest"><img src="https://img.shields.io/github/v/release/tangency-labs/solidcraft?label=release" alt="Latest release" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases"><img src="https://img.shields.io/github/downloads/tangency-labs/solidcraft/total?label=downloads" alt="Downloads" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/tangency-labs/solidcraft" alt="Licence" /></a>
|
||||
<a href="https://discord.gg/FtU4NQNVDH"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://remade.wiki/projects/solidcraft/"><img src="https://remade.wiki/badges/listed-on-remade-classic.svg" alt="Listed on Remade" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
|
||||
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
|
||||
ARM64 and 32-bit builds, portable zip, .deb, .rpm, Flatpak and the other packages are on the
|
||||
[downloads page](docs/downloads.md). The downloads aren't signed yet: on Windows choose
|
||||
**More info**, then **Run anyway** in SmartScreen; on macOS right-click SolidCraft and choose **Open**
|
||||
the first time. On SolidCraft 0.11 to 0.14, download once by hand; see
|
||||
[updating](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014).
|
||||
**Status: early (milestone M0 done, M1 in progress).** See [ROADMAP.md](ROADMAP.md),
|
||||
[docs/parity.md](docs/parity.md) (command parity with Fusion's Design workspace) and
|
||||
[docs/oracle.md](docs/oracle.md) (parts built in Fusion, rebuilt and measured in SolveCraft).
|
||||
|
||||
## What works
|
||||
|
||||
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 ([oracle](docs/oracle.md)); the
|
||||
other is a tracked bug.
|
||||
- **Sketches** on the origin planes, offset planes or planar faces: lines, rectangles (2-point,
|
||||
3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots
|
||||
and points; construction geometry.
|
||||
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
|
||||
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
|
||||
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
|
||||
rejected; fully constrained geometry turns black.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start
|
||||
offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges),
|
||||
box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first
|
||||
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
|
||||
deleted.
|
||||
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
|
||||
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), 3MF, STL (binary/ASCII) and OBJ;
|
||||
3MF and STL import as mesh bodies (they render, measure, move and export; solid features need
|
||||
B-rep bodies);
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature
|
||||
that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it
|
||||
on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes.
|
||||
- **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
in the running app (see below).
|
||||
|
||||
- **Sketches and constraints:** lines, arcs, splines, text and more, solved by our own constraint solver, with parameter-driven dimensions.
|
||||
- **Features:** extrude, revolve, sweep, loft, fillet, chamfer, shell, draft, holes, threads, patterns, mirror, split and combine on an editable parametric timeline.
|
||||
- **Parameters and configurations** with unit-aware expressions.
|
||||
- **Assemblies** with joints, motion studies, contact sets, interference and mass properties.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold and flat patterns, and plastic enclosure features.
|
||||
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.solidcraft` designs.
|
||||
- **Desktop app, browser app, headless CLI and MCP server**, all running the same commands.
|
||||
|
||||
The full list is in [docs/capabilities.md](docs/capabilities.md) and [docs/features.md](docs/features.md);
|
||||
status and plans are in [ROADMAP.md](ROADMAP.md). See also [what people built](docs/showcase.md),
|
||||
[3D mouse support](docs/3d-mouse.md) and the [architecture](docs/architecture.md).
|
||||
|
||||
## Build from source
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solvecraft-cli -- commands # the command registry
|
||||
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
More commands, the Windows cross-build and the browser build are in [docs/building.md](docs/building.md).
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
|
||||
runs its own design session, or with `--connect` it drives the running app so you can watch.
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
|
||||
example session: [docs/mcp.md](docs/mcp.md).
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
## Scripts
|
||||
|
||||
A script is a JSON list of the same commands the UI runs, executed with `solidcraft-cli run`.
|
||||
Format and example: [docs/scripts.md](docs/scripts.md). Pages can also be driven through the
|
||||
[browser command API](docs/browser-api.md).
|
||||
A script is a JSON list of commands, the same ones the UI runs:
|
||||
|
||||
## Contributing
|
||||
```json
|
||||
{"commands": [
|
||||
{"command": "SketchCreate", "params": {"plane": "XY"}},
|
||||
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
|
||||
{"command": "Extrude", "params": {"distance": 20}},
|
||||
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}}
|
||||
]}
|
||||
```
|
||||
|
||||
Help is welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers how to build and test, the code map,
|
||||
the clean-room and licence rules, and where help is wanted most. Open work is in
|
||||
[GitHub issues](https://github.com/tangency-labs/solidcraft/issues); chat is on
|
||||
[Discord](https://discord.gg/FtU4NQNVDH).
|
||||
## AI agents (MCP)
|
||||
|
||||
## Sponsors
|
||||
`solvecraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solvecraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
|
||||
`save`. Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
[](https://www.blacksmith.sh)
|
||||
## Architecture
|
||||
|
||||
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
|
||||
sponsors its CI minutes.
|
||||
| Layer | Crates | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `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 |
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0, at your option. Developed under the
|
||||
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk, Dassault Systèmes, PTC,
|
||||
Siemens or any other CAD vendor. Fusion, AutoCAD, Inventor, SolidWorks, Onshape, Creo, Blender and
|
||||
Tinkercad are trademarks of their respective owners, named only to describe compatibility
|
||||
(see [NOTICE](NOTICE)).
|
||||
|
||||
[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/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
|
||||
[macos-dmg]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
|
||||
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://solidcraft.io/app
|
||||
MIT OR Apache-2.0, at your option. SolveCraft is not affiliated with Autodesk; Fusion is a
|
||||
trademark of Autodesk, Inc.
|
||||
|
||||
+37
-611
@@ -1,33 +1,22 @@
|
||||
# SolidCraft roadmap
|
||||
# SolveCraft roadmap
|
||||
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
Status as of **2026-10-07**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; 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 | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
|
||||
| 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. |
|
||||
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **72 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
|
||||
| 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) |
|
||||
|
||||
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
|
||||
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
|
||||
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
|
||||
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain |
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
|
||||
so edge-touching assembly neighbours stay separate regardless of body order (#226).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
@@ -42,626 +31,63 @@ In scope, in priority order:
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
|
||||
headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), 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, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), 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), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
|
||||
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. Left: sheet metal edges, kernel-reported face history (exact through coincident surfaces), more roles (sweep, loft, shell inner faces) | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
|
||||
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- Modelled threads preview their cut before OK using the background worker on desktop.
|
||||
Browser users apply the cut with OK to avoid blocking on every edit.
|
||||
|
||||
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
|
||||
pitch selectors expose the existing metric designation parameter; custom designations stay
|
||||
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
|
||||
full/partial-length options remain available. This does not add tolerance classes or
|
||||
left-hand threads.
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
|
||||
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
thermal data, per-property sources/conditions and isotropic/orthotropic stability
|
||||
checks through read-only `material.properties.validate`. It does not assign or
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
- Directional elasticity (#994) queries the validated card in material-local axes
|
||||
through read-only `material.properties.directional`, with source/condition review.
|
||||
It does not assign body frames, predict structural behavior or enable a solver.
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
|
||||
selected appearance/material revisions into existing design definitions in one undo step.
|
||||
Appearance offers local loading/search/export; physical materials use existing commands.
|
||||
This slice is independent of the render stack and does not change the document format.
|
||||
See [the contract and limits](docs/book/src/dev/local-libraries.md).
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
appearances, editable wood mapping, and parameterized component/assembly families.
|
||||
Stable identity/revision, copied design definitions, provenance and resource limits
|
||||
apply from the first implementation. Existing material, appearance and standard-part
|
||||
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
|
||||
for source/license review and acceptance gates. This is planning only: pack loading,
|
||||
texture rendering and expanded component families are not implemented by this entry.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
gamut clipping only when explicitly requested. It is not a renderer/resource
|
||||
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
|
||||
|
||||
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
|
||||
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
|
||||
stay unchanged. HDRI, transmission and textures remain separate gates.
|
||||
See [the consumer and limitations](docs/render-opaque-pbr.md).
|
||||
|
||||
- Combined integration is tracked in #842, with independent scalar libraries in
|
||||
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
|
||||
Render/library consumers use the merged asynchronous picker (#1127). Material-card
|
||||
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
|
||||
|
||||
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
|
||||
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
|
||||
results stay outside persistence/undo; native workers and cooperative browser tasks
|
||||
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
|
||||
|
||||
- R3 adds an overlay-free product scene and the integrated Render workspace, with
|
||||
runtime GPU host capabilities, per-document drafts and named camera/background
|
||||
controls. Design keeps its camera and drawing policy. Raster transparency remains
|
||||
approximate; PBR and environments are later slices. See
|
||||
[workspace and scene contracts](docs/render-workspace.md).
|
||||
|
||||
- R2 adds bounded named render setups in document format 21 and the read/save
|
||||
`render.settings.get/set` commands. Proven render-only changes preserve geometry
|
||||
and prepared world data through undo/redo and checkpoint restore. This slice does
|
||||
not add a Render workspace, PBR, environments or image jobs; see
|
||||
[settings and refresh policy](docs/render-settings.md).
|
||||
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
|
||||
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
|
||||
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
|
||||
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
|
||||
|
||||
- The initial opaque surface numerical contract for #1004 provides bounded linear
|
||||
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
||||
The numerical module alone advertises no capabilities; the installed consumer above
|
||||
provides rendering. See
|
||||
[the model, evidence and remaining consumer gates](docs/render-surface.md).
|
||||
|
||||
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
The legacy Design snapshot route remains unchanged. Later slices above provide the
|
||||
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
|
||||
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
|
||||
associative axis expressions and construction-axis references remain unsupported.
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
|
||||
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
|
||||
it), drives it like any other dimension and keeps the circle on its side of the line.
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
|
||||
and pocket depths, interference volumes and parts-library lengths also use design units.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
|
||||
reporting for unexpected results that did not produce an error.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
|
||||
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
|
||||
journal) leaves a local report with the backtrace, version and commit, the command and the
|
||||
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
|
||||
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
|
||||
Grok login. See [agent chat](docs/agent-chat.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
|
||||
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
|
||||
script after a step gives the design at that step. Appearances, folders, views and
|
||||
configurations are listed as left out; components and joints are not supported yet.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
|
||||
durable supported profile/face references, endpoint dimensioning and contextual reporting.
|
||||
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
|
||||
authored splits and actual creases remain protected. Wireframe retains physical edges.
|
||||
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
|
||||
prisms with separated circular holes; unsupported shapes retain the existing strict check.
|
||||
- Common-bore Union retries check additional boundary curves before recutting the bore,
|
||||
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
|
||||
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
|
||||
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## Workspace layouts (#2029)
|
||||
|
||||
- General preferences save Floating or Compact using the universal UI containers.
|
||||
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
|
||||
flush beneath them with a square join, and opens tool sheets below the compass.
|
||||
Sheets remain draggable, rounding all corners away from their right dock; footer
|
||||
backgrounds follow the same corners. A separate preference hides ribbon icon labels
|
||||
for a shorter ribbon in either layout.
|
||||
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
|
||||
retains the active tool, camera, selection and design; system windows and menus
|
||||
keep their existing behavior.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
|
||||
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
|
||||
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
|
||||
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
| M12 | Release: installers, signing, docs, performance | planned | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (FusionStartDesignConfigModeCmd, FusionShowDesignConfigPanelCmd, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
multi-face ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
curved chamfer chains with straight planar ends can terminate across two existing planar
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
- Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
|
||||
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
|
||||
checked within 0.1% of analytic offset slices), runs of
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
with the edge between them too, the corner becomes a sphere octant),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs); knife edges, where the two faces have opposite
|
||||
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
|
||||
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
|
||||
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
|
||||
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
|
||||
other, except a round after a round of the edge between the wall and a flat face that it
|
||||
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
|
||||
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
|
||||
curves is refused as too large rather than returning an unmeshable face (#1113).
|
||||
edges.
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
curved wall qualification rejects false transverse axes instead of filling holes.
|
||||
Bodies touching along one whole face
|
||||
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
their neighbours are planes or cylinders. Other coincident curved configurations can still
|
||||
fail. Bounded containment classification preserves fully internal cavities and nested
|
||||
material islands independently of shell order, with STEP round-trip and material-membership
|
||||
regressions. Near-contact curved shells use the general boolean path rather than a
|
||||
containment shortcut. Spheres are built from six pole-free patches.
|
||||
fail. Fully internal voids are not supported. Spheres are built from six pole-free patches.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
|
||||
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
|
||||
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
|
||||
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
|
||||
offset surfaces and pcurve-only edges are not read yet,
|
||||
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
|
||||
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
|
||||
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
|
||||
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
|
||||
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
|
||||
references resolve by name first, follow splits and merges, and warn when the entity split,
|
||||
merged or vanished, or was found by geometry only (operations that report no history yet).
|
||||
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
|
||||
source certificates with checked target and author occurrence paths. A missing or changed
|
||||
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
|
||||
retaining current geometry and dimension identities. Unsupported modified faces need an
|
||||
explicit fixed plane. Legacy attachments retain their original resolver behavior.
|
||||
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
|
||||
and Revolve To Object on a curved face cut the tool back to the target's surface.
|
||||
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
- No persistent naming (fillet edges are re-found by position; projected sketch geometry too).
|
||||
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
|
||||
turns the profile and scales it (or stretches it across to the rail) against the start; two
|
||||
rails carry it between them. Rails along a path with sharp corners are refused for now.
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
|
||||
speeds without changing stored nodes or relaxing the distance tolerance (#971).
|
||||
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
|
||||
editable source updates the derived boundary live. Break Association restores point editing.
|
||||
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
Newly captured adaptive profile references with complete source coverage retain their outer
|
||||
and hole identities through span-count changes, including parameter edits after extrusion.
|
||||
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
|
||||
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
|
||||
reselection. Format 12 stores the new bounded whole-boundary references.
|
||||
Projected analytic boundaries follow nested loop identities through resizing, including
|
||||
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
|
||||
can lose or regain corner arcs during an inset while preserving reserved curve identities
|
||||
and linked profile selections. Format 12 saves those associations; compatible older closed
|
||||
offsets upgrade structurally without changing their curve IDs. Breaking an association
|
||||
freezes its current boundary and materializes downstream profile references. Child offsets
|
||||
of a boundary with retired arcs still require breaking its association first; mixed or
|
||||
nonconvex junctions and retained references to disappearing individual arcs remain limited.
|
||||
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
|
||||
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
|
||||
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
|
||||
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
|
||||
use the sketch's actual target, author or uniquely placed third-component frame.
|
||||
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
|
||||
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
|
||||
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
|
||||
use the sketch-normal direction for each selected profile, including To Object and curved
|
||||
target trimming, independently of the selected axis sign.
|
||||
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
|
||||
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
|
||||
radial walls. New features infer a unique eligible cylinder when the face field is empty;
|
||||
ambiguous targets require an explicit face. Saved target
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
|
||||
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
|
||||
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
|
||||
between sections, and sections with holes cannot collapse to a point.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
the origin; clicked UI regressions verify geometry, closed profiles and undo.
|
||||
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
|
||||
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
|
||||
invalidate the display cache without changing CAD geometry or timeline history. Work
|
||||
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
|
||||
The Sketch Palette scrolls its options on short windows and enlarged displays while
|
||||
keeping constraint status and Finish Sketch visible.
|
||||
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
|
||||
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
|
||||
rejected values keep the editable field focused.
|
||||
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
|
||||
checked occurrence paths. Active nested occurrences author planar sketches and solids in
|
||||
their selected frame. Pending moves compose through parents; dialog cancellation restores
|
||||
its selected move. Source materials and body/face appearances follow transformed instances.
|
||||
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
|
||||
independent feature/input histories, styles and internal motion records, with bounded geometry
|
||||
checks and explicit external-link notices. Paste New selects the copy while preserving the active
|
||||
authoring context. Imported periodic STEP seams survive placement and reexport with shared
|
||||
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
|
||||
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
|
||||
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
|
||||
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
|
||||
- Movable window title bars follow the app's dark/light header palette. Radial right-click
|
||||
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
|
||||
- Desktop distribution: running Windows windows and executable resources use the app icon;
|
||||
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
|
||||
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
|
||||
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
|
||||
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
|
||||
callbacks to prevent black frames. Other backends retain the requested sample count, and
|
||||
stored preferences remain unchanged.
|
||||
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
|
||||
restrict values. Preview explains effective values without modifying the document; applying
|
||||
rules validates rows atomically. Length expressions retain their authoring units across
|
||||
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
|
||||
Component styles and certified semantic face styles are available; unsupported lineage refuses
|
||||
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
|
||||
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
|
||||
sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
check. Under-resolved fits return an error.
|
||||
- Contact driving checks the trajectory between clear endpoints, including linked joints,
|
||||
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
|
||||
construction-plane selection of 3D wires and loft guide rails remain separate work.
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
## Construction on 3D paths
|
||||
|
||||
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
|
||||
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Design properties (#1015)
|
||||
|
||||
- Designs carry project, part number, revision, author, organization, description and licence
|
||||
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
|
||||
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
|
||||
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
|
||||
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
|
||||
Per-component properties (a part number per component) can follow with the same type.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
to eight workers, retaining serial traversal, result order and persistent source identities.
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||
feature warnings and structured inspection data. Diagnostics survive cached
|
||||
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
|
||||
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
|
||||
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
|
||||
on such a pair, both faces are split along the line of their cylinder through the touching
|
||||
points (the solids are unchanged), the boolean is taken of those, and its history is composed
|
||||
back over the operands as given (#788). The result keeps the split lines as edges of the bore
|
||||
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
|
||||
in a 45 mm block with a tool of 80 mm) is still refused.
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
|
||||
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
|
||||
reporting, and check whether the problem is still on `main`.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest release | ✅ |
|
||||
| Nightly builds | ✅ (fixes land here first) |
|
||||
| Older releases | ❌ |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
|
||||
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
|
||||
**Report a vulnerability**
|
||||
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
|
||||
|
||||
Please include what you found, how to reproduce it (a design file, command or MCP request), the
|
||||
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
|
||||
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
|
||||
stay anonymous.
|
||||
|
||||
Areas where reports are especially useful:
|
||||
|
||||
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
|
||||
- the MCP server and the control API (file access outside the allowed folders, command injection)
|
||||
- the web app (anything reaching beyond the page's own storage)
|
||||
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "solidcraft-cli"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "solidcraft-cli"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solidcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
|
||||
# note on stderr. Shipped in every package for at least one release.
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Snapshot tests decode the pictures to compare them with a tolerance.
|
||||
image = { workspace = true }
|
||||
@@ -1,30 +0,0 @@
|
||||
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
|
||||
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
|
||||
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
|
||||
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
|
||||
//! stays clean for MCP's JSON-RPC), and exits with its status.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const TARGET: &str = "solidcraft-cli";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
let sibling = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
|
||||
.filter(|p| p.is_file());
|
||||
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
|
||||
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
|
||||
Ok(status) => match status.code() {
|
||||
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
|
||||
None => ExitCode::FAILURE,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
//! `bench-agent`: a large design built the way an agent builds one over MCP, one small command
|
||||
//! at a time, timed as it grows and then opened again.
|
||||
//!
|
||||
//! The design is a bicycle of about 1,300 bodies: frame tubes joined at root and then moved into
|
||||
//! a component, two wheels whose spokes and nipples are placed one by one, a chainring with
|
||||
//! patterned teeth, a cassette, a chain of individual links, cockpit, seat and a few hundred
|
||||
//! fasteners. Nothing is read from a file; the script is generated here, so the benchmark needs
|
||||
//! no fixture.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft]
|
||||
//! ```
|
||||
//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the
|
||||
//! build total, then the phases `bench-open` prints for the saved design.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
struct Script {
|
||||
commands: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
fn c(&mut self, command: &str, params: Value) {
|
||||
self.commands.push(json!({"command": command, "params": params}));
|
||||
}
|
||||
|
||||
/// A round tube from `a` to `b` (an agent's frame tube, spoke or post).
|
||||
fn tube(&mut self, a: [f64; 3], b: [f64; 3], radius: f64, body: &str) {
|
||||
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let axis = [d[0] / len, d[1] / len, d[2] / len];
|
||||
self.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": radius, "height": round3(len), "base": a.map(round3), "axis": axis.map(round6), "body_name": body}),
|
||||
);
|
||||
}
|
||||
|
||||
fn component(&mut self, name: &str) {
|
||||
self.c("component.create", json!({"name": name}));
|
||||
}
|
||||
|
||||
fn root(&mut self) {
|
||||
self.c("component.activate", json!({"component": "root"}));
|
||||
}
|
||||
|
||||
/// A wheel built at the origin in its own component, then placed where it goes (the
|
||||
/// component's occurrence moved), as an agent builds one part and positions it.
|
||||
fn wheel(&mut self, label: &str, at: f64, spokes: usize) {
|
||||
let cx = 0.0;
|
||||
self.component(&format!("{label} wheel"));
|
||||
self.c("solid.torus", json!({"major": 311, "minor": 12, "center": [cx, 0, 340], "body_name": format!("{label} rim")}));
|
||||
self.c("solid.torus", json!({"major": 330, "minor": 18, "center": [cx, 0, 340], "body_name": format!("{label} tyre")}));
|
||||
self.c("solid.cylinder", json!({"radius": 18, "height": 110, "base": [cx, -55, 340], "axis": "Y", "body_name": format!("{label} hub")}));
|
||||
for k in 0..spokes {
|
||||
let a = std::f64::consts::TAU * k as f64 / spokes as f64;
|
||||
let side = if k % 2 == 1 { 1.0 } else { -1.0 };
|
||||
let hub = [cx + 30.0 * a.cos(), side * 40.0, 340.0 + 30.0 * a.sin()];
|
||||
let (ca, sa) = ((a + 0.12).cos(), (a + 0.12).sin());
|
||||
let rim = [cx + 300.0 * ca, side * 4.0, 340.0 + 300.0 * sa];
|
||||
self.tube(hub, rim, 1.0, &format!("{label} spoke {k}"));
|
||||
let nipple = json!({"radius": 2, "height": 12, "base": rim.map(round3), "axis": [round6(ca), 0, round6(sa)], "body_name": format!("{label} nipple {k}")});
|
||||
self.c("solid.cylinder", nipple);
|
||||
}
|
||||
self.root();
|
||||
self.c("occurrence.move", json!({"occurrence": format!("{label} wheel:1"), "translate": [at, 0, 0]}));
|
||||
}
|
||||
}
|
||||
|
||||
fn round3(v: f64) -> f64 {
|
||||
(v * 1e3).round() / 1e3
|
||||
}
|
||||
|
||||
fn round6(v: f64) -> f64 {
|
||||
(v * 1e6).round() / 1e6
|
||||
}
|
||||
|
||||
/// The bicycle's command script. `scale` multiplies the counts of repeated parts (spokes,
|
||||
/// chain links, fasteners); 1.0 gives about 1,300 bodies.
|
||||
pub fn bicycle(scale: f64) -> Value {
|
||||
let n = |k: f64| ((k * scale).round() as usize).max(1);
|
||||
let mut s = Script { commands: Vec::new() };
|
||||
// Frame: tubes at root, the main triangle joined, then everything moved into its component.
|
||||
let (bb, head_top, head_bot, seat_top) = ([0.0, 0.0, 300.0], [600.0, 0.0, 860.0], [580.0, 0.0, 740.0], [-150.0, 0.0, 820.0]);
|
||||
s.tube(bb, head_bot, 19.0, "Down tube");
|
||||
s.tube(seat_top, head_top, 16.0, "Top tube");
|
||||
s.tube(bb, seat_top, 16.0, "Seat tube");
|
||||
s.tube(head_bot, head_top, 22.0, "Head tube");
|
||||
for (side, y) in [("L", 60.0), ("R", -60.0)] {
|
||||
s.tube([0.0, y * 0.3, 300.0], [-420.0, y, 340.0], 10.0, &format!("Chainstay {side}"));
|
||||
s.tube([-140.0, y * 0.3, 800.0], [-420.0, y, 340.0], 9.0, &format!("Seatstay {side}"));
|
||||
s.c("solid.box", json!({"length": 20, "width": 6, "height": 40, "corner": [-430.0, y - 3.0, 320], "body_name": format!("Dropout {side}")}));
|
||||
}
|
||||
s.c("solid.cylinder", json!({"radius": 24, "height": 80, "base": [0, -40, 300], "axis": "Y", "body_name": "BB shell"}));
|
||||
s.c("solid.combine", json!({"target": "Down tube", "tools": ["Top tube", "Seat tube", "Head tube", "BB shell"]}));
|
||||
s.component("Frame");
|
||||
s.root();
|
||||
let frame = ["Down tube", "Chainstay L", "Seatstay L", "Dropout L", "Chainstay R", "Seatstay R", "Dropout R"];
|
||||
s.c("component.move_bodies", json!({"bodies": frame, "component": "Frame"}));
|
||||
// Wheels: every spoke and nipple a body of its own.
|
||||
s.wheel("Front", 640.0, n(36.0));
|
||||
s.wheel("Rear", -420.0, n(36.0));
|
||||
// Drivetrain: patterned chainring teeth, a cassette and a chain of separate links.
|
||||
s.component("Drivetrain");
|
||||
s.c("solid.cylinder", json!({"radius": 95, "height": 4, "base": [0, 50, 300], "axis": "Y", "body_name": "Chainring"}));
|
||||
s.c("solid.box", json!({"length": 6, "width": 4, "height": 8, "corner": [-3, 50, 395], "name": "Tooth", "body_name": "Tooth"}));
|
||||
s.c("solid.pattern.circular", json!({"bodies": ["Tooth"], "axis": {"origin": [0, 50, 300], "dir": [0, 1, 0]}, "count": 44}));
|
||||
for g in 0..11 {
|
||||
let r = 22.0 + 3.5 * g as f64;
|
||||
s.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": r, "height": 2, "base": [-420.0, round3(30.0 + 2.6 * g as f64), 340.0], "axis": "Y", "body_name": format!("Cog {g}")}),
|
||||
);
|
||||
}
|
||||
let links = n(112.0);
|
||||
for k in 0..links {
|
||||
let a = std::f64::consts::TAU * k as f64 / links as f64;
|
||||
let (x, z) = (round3(-210.0 + 230.0 * a.cos()), round3(320.0 + 80.0 * a.sin()));
|
||||
for (side, y) in [("in", 46), ("out", 56)] {
|
||||
s.c("solid.box", json!({"length": 12, "width": 1, "height": 8, "corner": [round3(x - 6.0), y, round3(z - 4.0)], "body_name": format!("Link {k} {side}")}));
|
||||
}
|
||||
s.c("solid.cylinder", json!({"radius": 3.5, "height": 10, "base": [x, 46, z], "axis": "Y", "body_name": format!("Roller {k}")}));
|
||||
}
|
||||
for (side, y) in [("L", -90.0), ("R", 90.0)] {
|
||||
let x0 = if side == "L" { -15.0 } else { 0.0 };
|
||||
s.c("solid.box", json!({"length": 170, "width": 12, "height": 30, "corner": [x0, y - 6.0, 285], "body_name": format!("Crank {side}")}));
|
||||
let py = if y > 0.0 { y + 8.0 } else { y - 68.0 };
|
||||
s.c("solid.box", json!({"length": 90, "width": 60, "height": 15, "corner": [150.0, py, 280], "body_name": format!("Pedal {side}")}));
|
||||
}
|
||||
s.root();
|
||||
// Cockpit and seat.
|
||||
s.component("Cockpit");
|
||||
s.tube([600.0, 0.0, 860.0], [620.0, 0.0, 940.0], 14.0, "Steerer");
|
||||
s.tube([620.0, 0.0, 940.0], [700.0, 0.0, 950.0], 16.0, "Stem");
|
||||
s.tube([700.0, -380.0, 950.0], [700.0, 380.0, 950.0], 11.0, "Handlebar");
|
||||
s.c("solid.cylinder", json!({"radius": 16, "height": 120, "base": [700, 260, 950], "axis": "Y", "body_name": "Grip R"}));
|
||||
s.c("solid.mirror", json!({"bodies": ["Grip R"], "plane": "XZ"}));
|
||||
s.root();
|
||||
s.component("Seat");
|
||||
s.tube([-150.0, 0.0, 820.0], [-170.0, 0.0, 960.0], 13.0, "Seatpost");
|
||||
s.c("solid.box", json!({"length": 260, "width": 140, "height": 40, "center": [-180, 0, 980], "body_name": "Saddle"}));
|
||||
s.root();
|
||||
// Fasteners: head, shank and washer, each its own body.
|
||||
s.component("Hardware");
|
||||
let bolts = n(240.0);
|
||||
for k in 0..bolts {
|
||||
let a = std::f64::consts::TAU * k as f64 / bolts as f64;
|
||||
let p = [round3(-300.0 + 2.5 * k as f64), round3(120.0 + 10.0 * a.sin()), round3(400.0 + 10.0 * a.cos())];
|
||||
s.c("solid.cylinder", json!({"radius": 5, "height": 4, "base": p, "axis": "Z", "body_name": format!("Bolt {k} head")}));
|
||||
s.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": 2.5, "height": 16, "base": [p[0], p[1], round3(p[2] - 16.0)], "axis": "Z", "body_name": format!("Bolt {k} shank")}),
|
||||
);
|
||||
s.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": 4.5, "height": 1, "base": [p[0], p[1], round3(p[2] - 1.0)], "axis": "Z", "body_name": format!("Bolt {k} washer")}),
|
||||
);
|
||||
}
|
||||
s.root();
|
||||
json!({"commands": s.commands})
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1));
|
||||
let scale = match flag("--scale") {
|
||||
Some(v) => v.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 20.0).ok_or("--scale takes a number from 0 to 20")?,
|
||||
None => 1.0,
|
||||
};
|
||||
let script = bicycle(scale);
|
||||
if let Some(path) = flag("--script-out") {
|
||||
let text = serde_json::to_string_pretty(&script).map_err(|e| e.to_string())?;
|
||||
return solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"));
|
||||
}
|
||||
let commands = script["commands"].as_array().cloned().unwrap_or_default();
|
||||
let mut s = Session::default();
|
||||
let began = std::time::Instant::now();
|
||||
// Per command: the command itself, then what a window needs to draw its result (the
|
||||
// bodies placed in the world and their display meshes).
|
||||
let (mut run, mut shown): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
|
||||
for (i, c) in commands.iter().enumerate() {
|
||||
let id = c["command"].as_str().unwrap_or_default();
|
||||
let t = std::time::Instant::now();
|
||||
s.execute(id, &c["params"]).map_err(|e| format!("command {i} ({id}): {e}"))?;
|
||||
run.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let t = std::time::Instant::now();
|
||||
s.world_state().warm_meshes(|| true);
|
||||
shown.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if run.len() == 100 || i + 1 == commands.len() {
|
||||
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len().max(1) as f64;
|
||||
let bodies = s.model.state().bodies.len();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "build", "commands": i + 1, "bodies": bodies, "command_ms": mean(&run), "display_ms": mean(&shown),
|
||||
"max_ms": run.iter().zip(&shown).map(|(a, b)| a + b).fold(0.0, f64::max)})
|
||||
);
|
||||
run.clear();
|
||||
shown.clear();
|
||||
}
|
||||
}
|
||||
let world = s.world_state();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "built", "ms": began.elapsed().as_secs_f64() * 1000.0, "commands": commands.len(), "features": s.doc.features.len(), "bodies": world.bodies.len()})
|
||||
);
|
||||
let path = match flag("--save") {
|
||||
Some(p) => p.clone(),
|
||||
None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(),
|
||||
};
|
||||
s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
let r = crate::cmd_bench_open(std::slice::from_ref(&path));
|
||||
if flag("--save").is_none() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{path}.bak"));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The generated commands stay valid as the command set changes: a small bicycle builds
|
||||
/// without a failing command or feature.
|
||||
#[test]
|
||||
fn a_small_bicycle_builds() {
|
||||
let script = bicycle(0.03);
|
||||
let mut s = Session::default();
|
||||
for c in script["commands"].as_array().into_iter().flatten() {
|
||||
let id = c["command"].as_str().unwrap_or_default();
|
||||
if let Err(e) = s.execute(id, &c["params"]) {
|
||||
panic!("{id}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
let world = s.world_state();
|
||||
assert!(world.bodies.len() > 40, "{} bodies", world.bodies.len());
|
||||
assert!(world.bodies.iter().any(|b| b.name.contains("spoke")));
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//! `solidcraft-cli crashes`: the crash reports the desktop app wrote on this computer
|
||||
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::crash;
|
||||
|
||||
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
|
||||
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let reports = crash::list(&dir);
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
println!("{}", super::pretty(&json!({ "dir": dir.to_string_lossy(), "reports": reports })));
|
||||
return Ok(());
|
||||
}
|
||||
println!("crash reports in {}", dir.display());
|
||||
if reports.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
for r in reports {
|
||||
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
|
||||
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
|
||||
let replay = if r.replayable { " [replayable]" } else { "" };
|
||||
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some("show") => {
|
||||
println!("{}", super::pretty(&crash::read(&dir, id()?)?));
|
||||
Ok(())
|
||||
}
|
||||
Some("replay") => replay(&crash::read(&dir, id()?)?),
|
||||
Some("dir") => {
|
||||
println!("{}", dir.display());
|
||||
Ok(())
|
||||
}
|
||||
Some("clear") => {
|
||||
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics seen while replaying: (location, message).
|
||||
static PANICS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Run a report's commands on its design and say whether the crash happens again.
|
||||
fn replay(report: &Value) -> Result<(), String> {
|
||||
let id = report["id"].as_str().unwrap_or_default();
|
||||
let replay_path = report["replay_path"].as_str().ok_or_else(|| format!("report {id} cannot be replayed (see its notes)"))?;
|
||||
let script = super::read_json(replay_path)?;
|
||||
let commands = script["commands"].as_array().cloned().unwrap_or_default();
|
||||
let mut s = match report["design_path"].as_str() {
|
||||
Some(d) => super::load(d)?,
|
||||
None => solidcraft_engine::Session::default(),
|
||||
};
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
|
||||
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
|
||||
if let Ok(mut p) = PANICS.lock() {
|
||||
p.push((loc, msg));
|
||||
}
|
||||
}));
|
||||
if report["kind"] == "unclean_exit" {
|
||||
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
|
||||
}
|
||||
println!(
|
||||
"replaying {} command(s) on {}",
|
||||
commands.len(),
|
||||
report["design_path"].as_str().map_or_else(|| "a new design".to_string(), |d| d.to_string())
|
||||
);
|
||||
for (i, c) in commands.iter().enumerate() {
|
||||
let cmd = c["command"].as_str().unwrap_or_default();
|
||||
let r = s.execute(cmd, c.get("params").unwrap_or(&Value::Null));
|
||||
match r {
|
||||
Ok(_) => println!(" {:>3}. {cmd}: ok", i + 1),
|
||||
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
|
||||
}
|
||||
}
|
||||
let _ = std::panic::take_hook();
|
||||
let seen = PANICS.lock().map(|p| p.clone()).unwrap_or_default();
|
||||
let want = report["signature"].as_str().unwrap_or_default();
|
||||
let same = seen.iter().any(|(l, m)| format!("{:016x}", crash::signature(l, m)) == want);
|
||||
if same {
|
||||
println!(
|
||||
"reproduced: the same internal error ({}) at {}",
|
||||
report["message"].as_str().unwrap_or_default(),
|
||||
report["location"].as_str().unwrap_or_default()
|
||||
);
|
||||
} else if let Some((l, m)) = seen.first() {
|
||||
println!("an internal error happened, but not the reported one: {m} at {l}");
|
||||
println!("(the report says: {} at {})", report["message"].as_str().unwrap_or_default(), report["location"].as_str().unwrap_or_default());
|
||||
} else {
|
||||
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
//! `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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::fuzz;
|
||||
use solidcraft_engine::fuzz::boolean_case;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
|
||||
pub fn one(args: &[String]) -> Result<(), String> {
|
||||
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
let minimise = args.iter().any(|a| a == "--minimise");
|
||||
// Panics caught inside the kernel: harmless natively, fatal in the web build.
|
||||
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
eprintln!("design {seed}");
|
||||
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
|
||||
let mut design = json!({
|
||||
"outcome": run.outcome,
|
||||
"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() {
|
||||
design["script"] = run.script();
|
||||
if minimise {
|
||||
eprintln!("minimise");
|
||||
let m = fuzz::minimise(&run);
|
||||
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
|
||||
}
|
||||
}
|
||||
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
|
||||
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);
|
||||
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Job {
|
||||
seed: u64,
|
||||
child: std::process::Child,
|
||||
started: Instant,
|
||||
err: std::sync::mpsc::Receiver<String>,
|
||||
last: String,
|
||||
}
|
||||
|
||||
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
|
||||
/// write failing scripts to `--out` and a summary to `--report`.
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let from = flag(args, "--from", 0u64);
|
||||
let count = flag(args, "--count", 100u64);
|
||||
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
|
||||
let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable();
|
||||
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
|
||||
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
|
||||
let minimise = args.iter().any(|a| a == "--minimise");
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
if let Some(d) = &out_dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
|
||||
}
|
||||
let mut running: Vec<Job> = Vec::new();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let t0 = Instant::now();
|
||||
while seeds.peek().is_some() || !running.is_empty() {
|
||||
while running.len() < jobs {
|
||||
let Some(seed) = seeds.next() else { break };
|
||||
let mut cmd = Command::new(&exe);
|
||||
cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]);
|
||||
if minimise {
|
||||
cmd.arg("--minimise");
|
||||
}
|
||||
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if let Some(err) = child.stderr.take() {
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() });
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let mut i = 0;
|
||||
while i < running.len() {
|
||||
let Some(j) = running.get_mut(i) else { break };
|
||||
while let Ok(l) = j.err.try_recv() {
|
||||
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
|
||||
j.last = l;
|
||||
}
|
||||
}
|
||||
let done = match j.child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut s = String::new();
|
||||
if let Some(mut o) = j.child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut s);
|
||||
}
|
||||
Some(match serde_json::from_str::<Value>(s.trim()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
|
||||
})
|
||||
}
|
||||
Ok(None) if j.started.elapsed() > timeout => {
|
||||
let _ = j.child.kill();
|
||||
let _ = j.child.wait();
|
||||
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
|
||||
};
|
||||
match done {
|
||||
Some(v) => {
|
||||
record(&v, out_dir.as_deref());
|
||||
results.push(v);
|
||||
running.swap_remove(i);
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
let summary = summarise(&results, t0.elapsed());
|
||||
println!("{summary}");
|
||||
if let Some(r) = report {
|
||||
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a failure as it comes in and keep its script.
|
||||
fn record(v: &Value, out_dir: Option<&str>) {
|
||||
let seed = v["seed"].as_u64().unwrap_or(0);
|
||||
let d = &v["design"];
|
||||
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
|
||||
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
|
||||
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
|
||||
}
|
||||
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
|
||||
if bk != "ok" {
|
||||
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
|
||||
}
|
||||
if let Some(dir) = out_dir {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
|
||||
let _ = writeln!(f, "{v}");
|
||||
}
|
||||
if bug {
|
||||
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
|
||||
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
|
||||
}
|
||||
if bk != "ok" {
|
||||
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn summarise(results: &[Value], took: Duration) -> String {
|
||||
let n = results.len().max(1);
|
||||
let mut design: BTreeMap<String, usize> = BTreeMap::new();
|
||||
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() {
|
||||
"timeout".to_string()
|
||||
} else if v.get("crash").is_some() {
|
||||
"crash".to_string()
|
||||
} else {
|
||||
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
|
||||
};
|
||||
if k != "ok" {
|
||||
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
|
||||
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
|
||||
}
|
||||
*design.entry(k).or_default() += 1;
|
||||
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
|
||||
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
|
||||
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
|
||||
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
|
||||
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
|
||||
for (k, c) in &design {
|
||||
s += &format!("- {k}: {}\n", pct(*c));
|
||||
}
|
||||
s += "\nFailures by step:\n";
|
||||
for (k, c) in &at_step {
|
||||
s += &format!("- {k}: {c}\n");
|
||||
}
|
||||
s += "\nSteps refused as not supported (undone, the design went on):\n";
|
||||
for (k, c) in &unsupported {
|
||||
s += &format!("- {k}: {c}\n");
|
||||
}
|
||||
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
|
||||
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
|
||||
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,49 +0,0 @@
|
||||
//! What the engine logs (`log::info!`, `log::debug!`: why a body has no display plan, why a save
|
||||
//! left its model cache out), written to stderr. Off unless asked for, so the CLI's output and
|
||||
//! the scripts reading it stay as they are: `--debug` shows SolidCraft's own debug messages (and
|
||||
//! other crates' warnings, whose debug output is large), `RUST_LOG=error|warn|info|debug|trace`
|
||||
//! sets one level for every crate.
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
struct Stderr {
|
||||
/// The level for SolidCraft's crates.
|
||||
ours: LevelFilter,
|
||||
/// The level for every other crate.
|
||||
others: LevelFilter,
|
||||
}
|
||||
|
||||
impl Log for Stderr {
|
||||
fn enabled(&self, m: &Metadata) -> bool {
|
||||
m.level() <= if m.target().starts_with("solidcraft") { self.ours } else { self.others }
|
||||
}
|
||||
|
||||
fn log(&self, r: &Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
use std::io::Write as _;
|
||||
// A closed stderr is no reason to stop.
|
||||
let _ = writeln!(std::io::stderr().lock(), "[{} {}] {}", r.level().as_str().to_ascii_lowercase(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Removes `--debug` from `args`, wherever it is (every subcommand takes it), and installs the
|
||||
/// logger when it or `RUST_LOG` asks for one.
|
||||
pub fn init(args: &mut Vec<String>) {
|
||||
let before = args.len();
|
||||
args.retain(|a| a != "--debug");
|
||||
let debug = args.len() != before;
|
||||
let env = std::env::var("RUST_LOG").ok().and_then(|v| v.trim().parse::<LevelFilter>().ok());
|
||||
let (ours, others) = match (debug, env) {
|
||||
(false, Some(all)) => (all, all),
|
||||
(true, Some(all)) => (all.max(LevelFilter::Debug), all),
|
||||
(true, None) => (LevelFilter::Debug, LevelFilter::Warn),
|
||||
(false, None) => return,
|
||||
};
|
||||
// Installed once, for the life of the process.
|
||||
if log::set_logger(Box::leak(Box::new(Stderr { ours, others }))).is_ok() {
|
||||
log::set_max_level(ours.max(others));
|
||||
}
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
//! SolidCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
|
||||
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! 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)
|
||||
//!
|
||||
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
mod render;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
|
||||
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
|
||||
(--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;
|
||||
--profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)
|
||||
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
|
||||
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
|
||||
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
|
||||
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
|
||||
(the design's history as a command script that eval and run replay)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]
|
||||
(--ops: after opening, run each command of the script and undo it, timing the command, the
|
||||
evaluations it caused and the view it leaves: placing and meshing what changed)
|
||||
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
|
||||
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
|
||||
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
|
||||
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
|
||||
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
|
||||
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;
|
||||
--body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside
|
||||
it shows) and color #rrggbb, either or both, once per body;
|
||||
--tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body
|
||||
is visible inside the material it meets; a later --body overrides it;
|
||||
--section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world
|
||||
coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)
|
||||
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
[--param-check off|warn|strict]
|
||||
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
|
||||
(the GPU adapters a headless render can open, best first, the one the policy picks and the tier it
|
||||
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
|
||||
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
|
||||
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
logger::init(&mut args);
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => cmd_run(&args[1..]),
|
||||
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
||||
Some("script") => cmd_script(&args[1..]),
|
||||
Some("snapshot") => cmd_snapshot(&args[1..]),
|
||||
Some("render") => render::run(&args[1..]),
|
||||
Some("exec") => cmd_exec(&args[1..]),
|
||||
Some("gpu") => cmd_gpu(&args[1..]),
|
||||
Some("commands") => {
|
||||
use std::io::Write as _;
|
||||
let mut s = Session::default();
|
||||
s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| {
|
||||
// A pipeline consumer may stop early (e.g. `commands | head`).
|
||||
// Unlike println!, writing explicitly lets us handle that without a panic.
|
||||
match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
|
||||
Err(e) => Err(format!("stdout: {e}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
|
||||
let v = read_json(p)?;
|
||||
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
|
||||
}),
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("fuzz") => fuzz::run(&args[1..]),
|
||||
Some("fuzz-one") => fuzz::one(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
Some("crashes") => crashes::run(&args[1..]),
|
||||
Some("icon") => cmd_icon(&args[1..]),
|
||||
Some("licences" | "licenses") => {
|
||||
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(solidcraft_engine::licences::all().as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
// A ~1,300-body design built command by command, as an agent builds one over MCP.
|
||||
Some("bench-agent") => bench_agent::run(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
// Asked-for help is not an error: usage on stdout, exit 0.
|
||||
Some("--help" | "-h" | "help") => {
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(USAGE.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pretty(v: &Value) -> String {
|
||||
serde_json::to_string_pretty(v).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
// Through the engine's file access, which refuses anything that is not a regular file and
|
||||
// reads no more than the cap: a character device or a FIFO would otherwise never end.
|
||||
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
||||
pub fn load(path: &str) -> Result<Session, String> {
|
||||
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
|
||||
}
|
||||
|
||||
/// `load`, with scripts and recipes run at the parameter-check level `check`, which the
|
||||
/// session keeps.
|
||||
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
s.param_check = check;
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
if solidcraft_engine::io::is_design_container(&bytes) {
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::new(doc);
|
||||
session.path = Some(path.to_owned());
|
||||
session.param_check = check;
|
||||
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 {
|
||||
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
|
||||
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
|
||||
// (stderr, so stdout stays the caller's).
|
||||
let mut warn = |step: usize, r: &Value| {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
};
|
||||
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
|
||||
fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> {
|
||||
args.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
|
||||
}
|
||||
|
||||
/// The first argument that is not an option or an option's value.
|
||||
fn positional(args: &[String]) -> Option<&String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if a.starts_with("--") {
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--timings" | "--profile" | "--allow-errors" | "--no-grid" | "--no-axes") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `--json-out FILE`: where the JSON a command prints goes instead of stdout.
|
||||
fn json_out(args: &[String]) -> Result<Option<&str>, String> {
|
||||
if !args.iter().any(|a| a == "--json-out") {
|
||||
return Ok(None);
|
||||
}
|
||||
match flag(args, "--json-out") {
|
||||
Some(file) if !file.starts_with("--") => Ok(Some(file)),
|
||||
_ => Err("--json-out needs a FILE".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as indented JSON with a final newline: on stdout, or in `file`. The file is plain UTF-8
|
||||
/// with no byte-order mark, whatever the platform's shell does to redirected output (Windows
|
||||
/// PowerShell's `>` writes UTF-16, or UTF-8 with a BOM), so strict JSON readers take it.
|
||||
fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
match file {
|
||||
Some(file) => {
|
||||
let mut text = pretty(v);
|
||||
text.push('\n');
|
||||
std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
|
||||
}
|
||||
None => {
|
||||
println!("{}", pretty(v));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let json_out = json_out(args)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let want_results = args.iter().any(|a| a == "--results");
|
||||
// `--profile` times each command and splits out its kernel time; it implies `--timings`.
|
||||
let profile = args.iter().any(|a| a == "--profile");
|
||||
let timings = profile || args.iter().any(|a| a == "--timings");
|
||||
let (mut s, results) = if want_results || timings {
|
||||
// Collect every command's result or time (scripts only), then process requested files too.
|
||||
let mut s = match base {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
|
||||
let v = read_json(path.ok_or(needs)?)?;
|
||||
let out = if timings {
|
||||
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
|
||||
if want_results && let Some(o) = t.as_object_mut() {
|
||||
o.insert("results".into(), Value::Array(r));
|
||||
}
|
||||
t
|
||||
} else {
|
||||
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
|
||||
};
|
||||
(s, Some(out))
|
||||
} else {
|
||||
(open(base, path)?, None)
|
||||
};
|
||||
// A feature that fails leaves the bodies as they were before it (a parameter change can
|
||||
// break a feature without failing the command that made it): writing them would hand out
|
||||
// the wrong part with success, so fail the run unless asked to write it anyway.
|
||||
if !args.iter().any(|a| a == "--allow-errors") {
|
||||
timeline_ok(&s)?;
|
||||
}
|
||||
for out in flags(args, "--out") {
|
||||
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = flag(args, "--save") {
|
||||
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(results) = results {
|
||||
print_json(&results, json_out)?;
|
||||
} else if json_out.is_some() || !args.iter().any(|a| a == "--quiet") {
|
||||
// `--quiet` silences stdout; a file that was asked for is still written.
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
print_json(&m, json_out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
|
||||
/// positional script run on top of it, or either one alone.
|
||||
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
|
||||
match (base, path) {
|
||||
(Some(b), Some(p)) => {
|
||||
let mut s = load(b)?;
|
||||
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
|
||||
Ok(s)
|
||||
}
|
||||
(Some(b), None) => load(b),
|
||||
(None, Some(p)) => load(p),
|
||||
(None, None) => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
|
||||
/// command's option, would otherwise be ignored without a word.
|
||||
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if valued.contains(&a.as_str()) {
|
||||
match args.get(i + 1) {
|
||||
Some(v) if !v.starts_with("--") => i += 2,
|
||||
_ => return Err(format!("{cmd}: {a} needs a value")),
|
||||
}
|
||||
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
|
||||
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
|
||||
fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
|
||||
if failed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"{} timeline feature(s) failed, so nothing was written (--allow-errors writes the bodies anyway):\n{}",
|
||||
failed.len(),
|
||||
failed.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
|
||||
let mut s = load(path)?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||||
let out = json!({
|
||||
"body_count": m["body_count"],
|
||||
"bodies": m["bodies"],
|
||||
"total": m["total"],
|
||||
"timeline": d["timeline"],
|
||||
"params": d["params"],
|
||||
"sketches": d["sketches"],
|
||||
});
|
||||
print_json(&out, json_out)
|
||||
}
|
||||
|
||||
/// A design's history as a command script (`timeline.export_script`): printed, or written to
|
||||
/// `--out`; what the script leaves out goes to stderr.
|
||||
fn cmd_script(args: &[String]) -> Result<(), String> {
|
||||
let path = positional(args).ok_or_else(|| USAGE.to_string())?;
|
||||
let s = &mut load(path)?;
|
||||
let out = flag(args, "--out");
|
||||
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
eprintln!("note: the script leaves out {what}");
|
||||
}
|
||||
match out {
|
||||
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
|
||||
None => println!("{}", pretty(&r["script"])),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `gpu`: the headless adapter survey and the policy's pick.
|
||||
fn cmd_gpu(args: &[String]) -> Result<(), String> {
|
||||
use solidcraft_gpu::select::{Policy, survey};
|
||||
let policy = match args {
|
||||
[] => Policy::from_env()?,
|
||||
[flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?,
|
||||
_ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()),
|
||||
};
|
||||
let s = survey(policy);
|
||||
let chosen = s.chosen.and_then(|i| s.adapters.get(i)).cloned();
|
||||
// The tier pictures land on: a hardware GPU, a software adapter, or the CPU rasterizer.
|
||||
let renderer = s.lands_on.map(|l| l.renderer());
|
||||
println!(
|
||||
"{}",
|
||||
pretty(&json!({
|
||||
"policy": policy.name(),
|
||||
"backends": s.backends,
|
||||
"adapters": s.adapters,
|
||||
"chosen": chosen,
|
||||
"lands_on": s.lands_on,
|
||||
"renderer": renderer,
|
||||
}))
|
||||
);
|
||||
match s.lands_on {
|
||||
Some(solidcraft_gpu::select::Landing::Cpu) => {
|
||||
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
|
||||
Ok(())
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
None => Err(format!("no adapter qualifies under the {} policy", policy.name())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw this process's pictures (snapshots, MCP screenshots, saved thumbnails) on the GPU with the
|
||||
/// viewport's pipelines: the headless device the `SOLIDCRAFT_GPU` policy picks, opened on the first
|
||||
/// picture; under `auto` with no qualifying adapter (or `cpu`), the CPU rasterizer, and the
|
||||
/// picture says so. Under `hardware` or `software` with no such adapter a picture fails with an
|
||||
/// error that names the policy.
|
||||
fn install_renderer() {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
|
||||
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
|
||||
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
|
||||
}));
|
||||
}
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
known_flags(
|
||||
"snapshot",
|
||||
args,
|
||||
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
|
||||
&["--no-grid", "--no-axes"],
|
||||
)?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let size = |name: &str, default: u32| match flag(args, name) {
|
||||
None => Ok(default),
|
||||
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
|
||||
};
|
||||
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
// How the bodies are drawn and where the model is cut, through the commands the app's
|
||||
// Appearance dialog and Section Analysis run (`preview`).
|
||||
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
|
||||
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
|
||||
// standard view, a view saved in the design, or an explicit camera. A script that applies a
|
||||
// view itself sets the camera too; without any, the iso view framing the model.
|
||||
match (flag(args, "--view"), flag(args, "--camera")) {
|
||||
(Some(_), Some(_)) => return Err("snapshot: give --view or --camera, not both".into()),
|
||||
(Some(v), None) => {
|
||||
let hint = "standard views: iso, front, back, top, bottom, left, right";
|
||||
s.execute("view.apply", &json!({"view": v})).map_err(|e| format!("--view {v}: {e} ({hint}; or a view saved with view.save)"))?;
|
||||
}
|
||||
(None, Some(c)) => {
|
||||
let camera: Value = serde_json::from_str(c).map_err(|e| format!("--camera: not JSON: {e}"))?;
|
||||
s.execute("view.apply", &json!({"camera": camera})).map_err(|e| format!("--camera: {e}"))?;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
|
||||
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
|
||||
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
|
||||
let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?;
|
||||
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
|
||||
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
|
||||
eprintln!("warning: {w}");
|
||||
reply["warning"] = json!(w);
|
||||
}
|
||||
println!("{}", pretty(&reply));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app icon: the SolidCraft logo, drawn in code on a transparent background
|
||||
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
|
||||
fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
|
||||
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
|
||||
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
|
||||
let pngs: Vec<(usize, Vec<u8>)> =
|
||||
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
|
||||
ico(&pngs)?
|
||||
} else {
|
||||
icon_png(size)?
|
||||
};
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
/// A Windows icon file holding PNG images (Vista and later read these).
|
||||
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&[0, 0, 1, 0]);
|
||||
out.extend_from_slice(&n.to_le_bytes());
|
||||
let mut offset = 6 + 16 * images.len();
|
||||
for (size, data) in images {
|
||||
let dim = if *size >= 256 { 0 } else { *size as u8 };
|
||||
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
|
||||
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
|
||||
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
|
||||
offset += data.len();
|
||||
}
|
||||
for (_, data) in images {
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let p: Value = match args.get(1) {
|
||||
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
|
||||
None => json!({}),
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let v = s.execute(id, &p).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&v));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
|
||||
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
|
||||
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
|
||||
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
|
||||
if let Some(s) = &scope {
|
||||
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
|
||||
eprintln!(
|
||||
"solidcraft mcp: files limited to {} and the open design's folder (--allow-path DIR adds one, --allow-any-path lifts the limit)",
|
||||
roots.join(", ")
|
||||
);
|
||||
let design_dir = flag(args, "--in")
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
|
||||
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
|
||||
let param_check = match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
|
||||
None => solidcraft_engine::schema::ParamCheck::Off,
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
|
||||
}
|
||||
};
|
||||
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
|
||||
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
|
||||
}
|
||||
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => {
|
||||
let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()).filter(|p| *p != 0) else {
|
||||
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
|
||||
};
|
||||
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
|
||||
if let Err(e) = remote.try_connect() {
|
||||
eprintln!(
|
||||
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
|
||||
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
|
||||
);
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load_checked(path, param_check)?)),
|
||||
(None, None) => {
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
Box::new(headless)
|
||||
}
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
|
||||
/// parameter `save_round` set to the round number each time.
|
||||
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
|
||||
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solidcraft_engine::doc::Param {
|
||||
name: format!("p{i}"),
|
||||
expr: format!("{i} mm + 0.5 mm"),
|
||||
unit: "mm".into(),
|
||||
comment: "filler to make the file large".into(),
|
||||
model: false,
|
||||
});
|
||||
s.doc_mut().params.extend(filler);
|
||||
for r in 0..rounds {
|
||||
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
|
||||
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
|
||||
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
|
||||
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
|
||||
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
};
|
||||
let v = read_json(path)?;
|
||||
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
||||
let mut s = Session::default();
|
||||
let t = std::time::Instant::now();
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
let build = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
|
||||
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
|
||||
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
|
||||
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
|
||||
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
|
||||
Ok(())
|
||||
};
|
||||
let expr = expr.replace("{old}", &old);
|
||||
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
|
||||
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
|
||||
time(&mut s, "undo", "edit.undo", json!({}))?;
|
||||
time(&mut s, "redo", "edit.redo", json!({}))?;
|
||||
println!("{}", pretty(&json!(out)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bench-open --ops`: each command of `script` (a JSON array of `{command, params}`), then its
|
||||
/// undo, each timed as the command and the view it leaves (the placed model, its meshes warmed
|
||||
/// as the desktop does after a change).
|
||||
fn bench_ops(mut s: Session, script: &str) -> Result<Session, String> {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let ops: Value = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
|
||||
let ops = ops.as_array().ok_or("--ops: a JSON array of {command, params}")?;
|
||||
let time = |s: &mut Session, id: &str, p: &Value| -> Result<Value, String> {
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.execute(id, p).map_err(|e| format!("{id}: {e}"))?;
|
||||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let t = std::time::Instant::now();
|
||||
s.world_state().warm_meshes(|| true);
|
||||
let view_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "op", "command": id, "ms": ms, "view_ms": view_ms}));
|
||||
Ok(r)
|
||||
};
|
||||
for op in ops {
|
||||
let id = op.get("command").and_then(Value::as_str).ok_or("--ops: each step needs a `command`")?;
|
||||
let p = op.get("params").cloned().unwrap_or(Value::Null);
|
||||
let steps = s.undo.len();
|
||||
time(&mut s, id, &p)?;
|
||||
// Commands that are no undo step (showing and hiding) have nothing to undo.
|
||||
if op.get("undo").and_then(Value::as_bool) != Some(false) && s.undo.len() > steps {
|
||||
time(&mut s, "edit.undo", &json!({}))?;
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solidcraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
// The file as read, held until the timeline has evaluated it (as the import command does).
|
||||
let mut read = None;
|
||||
let (doc, saved) = if step {
|
||||
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
read = Some(imported.import);
|
||||
(doc, None)
|
||||
} else {
|
||||
// As Open reads it: with the model the file keeps.
|
||||
solidcraft_engine::io::read_design_file(path, None::<fn(solidcraft_engine::io::DesignPreview)>).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(), "saved_model": saved.is_some()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::opened(doc, saved.as_ref());
|
||||
drop(read);
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway).
|
||||
let adopted = saved.is_some() && s.model.last_recomputed == 0;
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
let place_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
// As the desktop app prepares a design it opened.
|
||||
st.warm_meshes(|| true);
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "place_ms": place_ms, "bodies": st.bodies.len(), "triangles": triangles})
|
||||
);
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
// `--save PATH`: save the opened design without its model cache (to PATH.plain.solidcraft) and
|
||||
// as the app does, with it, then read the cache back.
|
||||
let s = match flag("--save").and_then(|i| args.get(i + 1)) {
|
||||
Some(out) => {
|
||||
let mut s = s;
|
||||
// Without the cache first, for comparison.
|
||||
let plain = format!("{out}.plain.solidcraft");
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": plain, "cache": false})).map_err(|e| e.to_string())?;
|
||||
let plain_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let plain_bytes = std::fs::metadata(&plain).map(|m| m.len()).unwrap_or(0);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
let save_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let bytes = std::fs::read(out).map_err(|e| format!("{out}: {e}"))?;
|
||||
let t = std::time::Instant::now();
|
||||
let cached = solidcraft_engine::io::read_design_geometry(&bytes).map(|m| m.bodies.len());
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
// Why bodies could not be kept exactly, when the cache was left out.
|
||||
let mut refused = std::collections::BTreeMap::<String, usize>::new();
|
||||
if cached.is_none() {
|
||||
for b in &s.model.state().bodies {
|
||||
if let Err(e) = solidcraft_engine::kernel::encode_native_body(&b.body) {
|
||||
*refused.entry(e.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "save", "ms": save_ms, "bytes": bytes.len(), "plain_ms": plain_ms, "plain_bytes": plain_bytes, "cached_bodies": cached, "cache_read_ms": read_ms, "refused": refused})
|
||||
);
|
||||
s
|
||||
}
|
||||
None => s,
|
||||
};
|
||||
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
|
||||
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
|
||||
// evaluation and the plans of the bodies it changed.
|
||||
let s = match flag("--ops").and_then(|i| args.get(i + 1)) {
|
||||
Some(ops) => bench_ops(s, ops)?,
|
||||
None => s,
|
||||
};
|
||||
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
|
||||
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
|
||||
let plans = |st: &solidcraft_engine::doc::ModelState| {
|
||||
let t = std::time::Instant::now();
|
||||
solidcraft_engine::kernel::for_each_on_threads(&st.bodies, 16, |b| {
|
||||
let _ = b.plan_mesh_at(q);
|
||||
});
|
||||
let triangles: usize = st.bodies.iter().filter_map(|b| b.plan_mesh_at(q)).map(|m| m.triangles.len()).sum();
|
||||
(t.elapsed().as_secs_f64() * 1000.0, triangles)
|
||||
};
|
||||
let (ms, triangles) = plans(&st);
|
||||
println!("{}", json!({"step": "display", "quality": q.id(), "ms": ms, "triangles": triangles}));
|
||||
if let Some(i) = flag("--edit") {
|
||||
let (Some(name), Some(expr)) = (args.get(i + 1), args.get(i + 2)) else { return Err("--edit NAME EXPR".into()) };
|
||||
let mut s = s;
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("parameters.change", &json!({"name": name, "expression": expr})).map_err(|e| e.to_string())?;
|
||||
let eval_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let st = s.world_state();
|
||||
let t = std::time::Instant::now();
|
||||
st.warm_meshes(|| true);
|
||||
let mesh_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let (ms, triangles) = plans(&st);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "edit", "quality": q.id(), "evaluate_ms": eval_ms, "mesh_ms": mesh_ms, "display_ms": ms, "triangles": triangles})
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
|
||||
//! compare the part with `expected.json` (closed-form values, or values from an independent
|
||||
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::spatial::{self, Tri};
|
||||
use crate::{read_json, recipe};
|
||||
|
||||
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
pub const REL_TOL: f64 = 1e-3;
|
||||
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
|
||||
const ABS_FRAC: f64 = 1e-3;
|
||||
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
|
||||
const MESH_TOL: f64 = 0.01;
|
||||
/// Relative tolerance on the principal second moments of the two triangulations.
|
||||
const INERTIA_REL: f64 = 2e-3;
|
||||
|
||||
/// What SolidCraft built: totals over every body.
|
||||
struct Got {
|
||||
body_count: usize,
|
||||
volume: f64,
|
||||
area: f64,
|
||||
com: [f64; 3],
|
||||
bbox: ([f64; 3], [f64; 3]),
|
||||
faces: u64,
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<[f64; 3]> {
|
||||
let a = v.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
}
|
||||
|
||||
fn totals(m: &Value) -> Got {
|
||||
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
|
||||
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
|
||||
let mut moment = [0.0; 3];
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for b in &bodies {
|
||||
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
||||
volume += v;
|
||||
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
|
||||
faces += b["faces"].as_u64().unwrap_or(0);
|
||||
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
|
||||
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
|
||||
for k in 0..3 {
|
||||
moment[k] += v * c[k];
|
||||
lo[k] = lo[k].min(bl[k]);
|
||||
hi[k] = hi[k].max(bh[k]);
|
||||
}
|
||||
}
|
||||
let com = moment.map(|x| x / volume);
|
||||
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
|
||||
}
|
||||
|
||||
struct Checks {
|
||||
list: Vec<Value>,
|
||||
pass: bool,
|
||||
}
|
||||
|
||||
impl Checks {
|
||||
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
|
||||
self.pass &= ok;
|
||||
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
|
||||
}
|
||||
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
|
||||
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
|
||||
self.push(what, json!(g), json!(w), ok);
|
||||
}
|
||||
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
|
||||
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
|
||||
self.push(what, json!(g), json!(w), ok);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_case(dir: &str) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let recipe = match read_json(&format!("{dir}/recipe.json")) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let want = read_json(&format!("{dir}/expected.json")).ok();
|
||||
let script = match recipe::to_script(&recipe) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
|
||||
};
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
if let Err(e) = s.run_script(&script) {
|
||||
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
|
||||
}
|
||||
let got = match s.execute("inspect.measure", &json!({})) {
|
||||
Ok(m) => totals(&m),
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let got_json = json!({
|
||||
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
|
||||
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
|
||||
});
|
||||
let Some(want) = want else {
|
||||
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
|
||||
};
|
||||
let only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
|
||||
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
|
||||
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
|
||||
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
|
||||
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
|
||||
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
|
||||
let mut c = Checks { list: Vec::new(), pass: true };
|
||||
if wanted("body_count") {
|
||||
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
|
||||
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
|
||||
}
|
||||
if wanted("volume_mm3") {
|
||||
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
|
||||
}
|
||||
if wanted("area_mm2") {
|
||||
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
|
||||
}
|
||||
if wanted("center_of_mass") {
|
||||
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
|
||||
}
|
||||
if wanted("bbox") {
|
||||
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
|
||||
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
|
||||
}
|
||||
if wanted("faces") {
|
||||
let ok = want["faces"].as_u64() == Some(got.faces);
|
||||
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
|
||||
}
|
||||
let stl = format!("{dir}/reference.stl");
|
||||
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
|
||||
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
|
||||
reference_checks(&s, &stl, tol, &mut c);
|
||||
}
|
||||
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
|
||||
// same volume.
|
||||
let (ok, note) = step_export_check(&s, got.volume);
|
||||
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
|
||||
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
|
||||
if let Some(n) = want["note"].as_str() {
|
||||
out["note"] = json!(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Export the session's bodies as STEP, validate the file and import it again.
|
||||
fn step_export_check(s: &solidcraft_engine::Session, volume: f64) -> (bool, String) {
|
||||
let st = s.model.state();
|
||||
let bytes = match solidcraft_engine::io::export(&st, &[], solidcraft_engine::io::Format::Step, "oracle") {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (false, format!("export: {e}")),
|
||||
};
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
if let Err(e) = solidcraft_engine::kernel::step_validate(&text) {
|
||||
return (false, format!("invalid: {e}"));
|
||||
}
|
||||
match solidcraft_engine::kernel::step_orientation_errors(&text) {
|
||||
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
|
||||
Err(e) => return (false, format!("orientation check: {e}")),
|
||||
Ok(_) => {}
|
||||
}
|
||||
match solidcraft_engine::kernel::step_import(&text) {
|
||||
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
|
||||
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
|
||||
Ok(imp) => {
|
||||
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let v: f64 = m.iter().map(|x| x.volume).sum();
|
||||
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
|
||||
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
|
||||
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
|
||||
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
|
||||
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
|
||||
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
|
||||
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
|
||||
}
|
||||
Err(e) => (false, format!("import: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Our part as a triangle soup (curved faces refined onto the surface).
|
||||
fn our_mesh(s: &solidcraft_engine::Session) -> Result<Vec<Tri>, String> {
|
||||
let mut ours: Vec<Tri> = Vec::new();
|
||||
for b in &s.world_state().bodies {
|
||||
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
|
||||
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
|
||||
for t in &m.triangles {
|
||||
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
|
||||
ours.push([a, b, c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
|
||||
}
|
||||
|
||||
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
|
||||
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
|
||||
/// sits in space).
|
||||
fn reference_checks(s: &solidcraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
|
||||
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
|
||||
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
|
||||
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
|
||||
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
|
||||
};
|
||||
let (lo, hi) = spatial::bounds(&reference);
|
||||
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
|
||||
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
|
||||
let worst = h.a_to_b.max(h.b_to_a);
|
||||
c.push(
|
||||
"surface_distance_mm",
|
||||
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
|
||||
json!(format!("<= {tol}")),
|
||||
worst <= tol,
|
||||
);
|
||||
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
|
||||
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
|
||||
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
|
||||
}
|
||||
|
||||
fn report(args: &[String], results: &[Value]) {
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
for r in results {
|
||||
let status = if r["pass"].as_bool().unwrap_or(false) {
|
||||
"PASS"
|
||||
} else if r["skipped"].as_bool().unwrap_or(false) {
|
||||
"SKIP"
|
||||
} else {
|
||||
"FAIL"
|
||||
};
|
||||
let detail: Vec<String> = r["checks"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
|
||||
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
|
||||
.collect();
|
||||
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
|
||||
println!(
|
||||
"{status} {}{err}{}",
|
||||
r["case"].as_str().unwrap_or(""),
|
||||
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if dirs.is_empty() {
|
||||
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
|
||||
report(args, &results);
|
||||
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
|
||||
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
//! What a `snapshot` draws see-through, what it draws in a colour of its own, and where it cuts
|
||||
//! the model: the `--body`, `--tool` and `--section` options.
|
||||
//!
|
||||
//! None of this is a renderer of its own. Each option turns into the command the app itself runs
|
||||
//! — `appearance.assign` for the Appearance dialog, `inspect.section` for Section Analysis — so
|
||||
//! a picture of a tool body inside a translucent target is the picture the window would show
|
||||
//! with the same appearances assigned, and a script can do the same thing without the options.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
/// How see-through the bodies around a `--tool` are drawn: enough to read the tool through them,
|
||||
/// solid enough to keep the shape of the material it meets.
|
||||
pub const TARGET_OPACITY: f64 = 0.3;
|
||||
|
||||
/// A `--tool` body's colour: the hue the viewport's feature preview uses for removed material,
|
||||
/// so a cut's tool looks in a picture the way the preview looks in the window.
|
||||
pub const TOOL_COLOR: &str = "#e8542c";
|
||||
|
||||
/// The colour of a body with no appearance of its own ([`solidcraft_engine::view::colors::BODY`]),
|
||||
/// as `appearance.assign` takes it. Giving a body an opacity means giving it a look, and a look
|
||||
/// carries a colour: this keeps the one it is already drawn in.
|
||||
fn default_body_hex() -> String {
|
||||
let c = solidcraft_engine::view::colors::BODY;
|
||||
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
/// How one body is drawn: `--body NAME:opacity=0.3,color=#cc3333`. Either key alone is enough;
|
||||
/// the other keeps what the design already says.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct BodyLook {
|
||||
pub name: String,
|
||||
pub opacity: Option<f64>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// `NAME:key=value[,key=value]`, or `NAME` on its own (which says nothing and is an error:
|
||||
/// a body is named here to change how it is drawn).
|
||||
pub fn parse_body(spec: &str) -> Result<BodyLook, String> {
|
||||
let (name, rest) = spec
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("--body `{spec}` must be NAME:key=value (`Target:opacity=0.3`, `Tool:color=#cc3333`, keys separated by commas)"))?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(format!("--body `{spec}` names no body"));
|
||||
}
|
||||
let mut look = BodyLook { name: name.to_owned(), opacity: None, color: None };
|
||||
for part in rest.split(',') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = part.split_once('=').ok_or_else(|| format!("--body {name}: `{part}` must be key=value (opacity, color)"))?;
|
||||
let value = value.trim();
|
||||
match key.trim().to_ascii_lowercase().as_str() {
|
||||
"opacity" => {
|
||||
let o: f64 = value.parse().map_err(|_| format!("--body {name}: opacity must be a number 0 to 1, not `{value}`"))?;
|
||||
if !o.is_finite() || !(0.0..=1.0).contains(&o) {
|
||||
return Err(format!("--body {name}: opacity must be 0 to 1, not `{value}`"));
|
||||
}
|
||||
look.opacity = Some(o);
|
||||
}
|
||||
"color" => {
|
||||
let digits = value.strip_prefix('#').filter(|h| h.len() == 6 && h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
let digits = digits.ok_or_else(|| format!("--body {name}: color must be #rrggbb, not `{value}`"))?;
|
||||
look.color = Some(format!("#{}", digits.to_ascii_lowercase()));
|
||||
}
|
||||
other => return Err(format!("--body {name}: unknown key `{other}` (opacity, color)")),
|
||||
}
|
||||
}
|
||||
if look.opacity.is_none() && look.color.is_none() {
|
||||
return Err(format!("--body `{spec}` asks for nothing: give opacity, color, or both"));
|
||||
}
|
||||
Ok(look)
|
||||
}
|
||||
|
||||
/// The `inspect.section` parameters a `--section` spec asks for: `z=27` (the plane through that
|
||||
/// world coordinate, its normal along +Z), `XY` (a named plane through the origin), either with
|
||||
/// `,flip` to keep the other side, or the command's own JSON object.
|
||||
pub fn parse_section(spec: &str) -> Result<Value, String> {
|
||||
let spec = spec.trim();
|
||||
if spec.starts_with('{') {
|
||||
return serde_json::from_str(spec).map_err(|e| format!("--section: not JSON: {e}"));
|
||||
}
|
||||
let mut parts = spec.split(',').map(str::trim);
|
||||
let plane = parts.next().unwrap_or_default();
|
||||
let mut flip = false;
|
||||
for p in parts {
|
||||
match p.to_ascii_lowercase().as_str() {
|
||||
"" => {}
|
||||
"flip" => flip = true,
|
||||
other => return Err(format!("--section: unknown `{other}` (only `flip` follows the plane)")),
|
||||
}
|
||||
}
|
||||
// A named plane through the world origin, as `inspect.section` takes it.
|
||||
let mut params = match plane.to_ascii_uppercase().as_str() {
|
||||
p @ ("XY" | "XZ" | "YZ") => json!({"plane": p}),
|
||||
_ => {
|
||||
// AXIS=VALUE: the plane whose normal is that axis, cutting at that world coordinate.
|
||||
let (axis, at) = plane.split_once('=').ok_or_else(|| {
|
||||
format!("--section `{spec}` must be z=27 (a world coordinate on an axis), XY, XZ, YZ, or the JSON inspect.section takes")
|
||||
})?;
|
||||
let at: f64 = at.trim().parse().map_err(|_| format!("--section {axis}: `{}` is not a number", at.trim()))?;
|
||||
if !at.is_finite() {
|
||||
return Err(format!("--section {axis}: `{at}` is not a finite coordinate"));
|
||||
}
|
||||
// The plane normal to each axis: Z is XY, Y is XZ, X is YZ.
|
||||
let named = match axis.trim().to_ascii_lowercase().as_str() {
|
||||
"x" => "YZ",
|
||||
"y" => "XZ",
|
||||
"z" => "XY",
|
||||
other => return Err(format!("--section: unknown axis `{other}` (x, y or z)")),
|
||||
};
|
||||
json!({"plane": named, "at": at})
|
||||
}
|
||||
};
|
||||
if flip {
|
||||
params["flip"] = json!(true);
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// The design's body names, for an error that says which ones there are.
|
||||
fn body_names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The colour and opacity a body is drawn with now, as `appearance.assign` takes them: its own
|
||||
/// appearance, else its component's or material's, else the default body colour.
|
||||
fn current_look(s: &mut Session, name: &str) -> Result<(String, f64), String> {
|
||||
let reply = s.execute("appearance.list", &json!({"body": name})).map_err(|e| e.to_string())?;
|
||||
let body = reply.get("body");
|
||||
let look = body.and_then(|b| b.get("appearance")).filter(|a| !a.is_null());
|
||||
let hex = look
|
||||
.and_then(|a| a.get("color"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
// No appearance, but an imported colour: `body.color` is that colour, 0..1 per channel.
|
||||
.or_else(|| {
|
||||
let c = body?.get("color")?.as_array()?;
|
||||
let ch = |i: usize| -> Option<u8> { Some((c.get(i)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) };
|
||||
Some(format!("#{:02x}{:02x}{:02x}", ch(0)?, ch(1)?, ch(2)?))
|
||||
})
|
||||
.unwrap_or_else(default_body_hex);
|
||||
let opacity = look.and_then(|a| a.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(1.0);
|
||||
Ok((hex, opacity))
|
||||
}
|
||||
|
||||
/// Draw `name` with `look`, keeping whichever of colour and opacity it does not give.
|
||||
fn assign(s: &mut Session, look: &BodyLook) -> Result<(), String> {
|
||||
let name = &look.name;
|
||||
if !body_names(s).iter().any(|b| b == name) {
|
||||
return Err(format!("no body `{name}` in the design (it has {})", body_list(s)));
|
||||
}
|
||||
let (hex, opacity) = current_look(s, name)?;
|
||||
let params = json!({
|
||||
"bodies": [name],
|
||||
"color": look.color.clone().unwrap_or(hex),
|
||||
"opacity": look.opacity.unwrap_or(opacity),
|
||||
});
|
||||
s.execute("appearance.assign", ¶ms).map(|_| ()).map_err(|e| format!("drawing `{name}`: {e}"))
|
||||
}
|
||||
|
||||
/// The design's bodies, named, for an error message.
|
||||
fn body_list(s: &Session) -> String {
|
||||
let names = body_names(s);
|
||||
if names.is_empty() { "no bodies".to_owned() } else { names.join(", ") }
|
||||
}
|
||||
|
||||
/// Apply the options to the session, in the order a later one overrides an earlier: the `--tool`
|
||||
/// preview first (it speaks for every body), then each `--body`, then the section.
|
||||
pub fn apply(s: &mut Session, bodies: &[&str], tools: &[&str], section: Option<&str>) -> Result<(), String> {
|
||||
// Everything is read before anything is drawn, so a misspelt option is an error rather than
|
||||
// half a picture.
|
||||
let looks: Vec<BodyLook> = bodies.iter().map(|spec| parse_body(spec)).collect::<Result<_, _>>()?;
|
||||
let section = section.map(parse_section).transpose()?;
|
||||
if !tools.is_empty() {
|
||||
let names = body_names(s);
|
||||
for t in tools {
|
||||
if !names.iter().any(|b| b == t) {
|
||||
return Err(format!("--tool: no body `{t}` in the design (it has {})", body_list(s)));
|
||||
}
|
||||
}
|
||||
// Everything but the tool see-through, in the colour it already has, so the tool reads
|
||||
// against the material it meets. Bodies that share a colour are assigned together: a
|
||||
// design of many bodies then costs a command or two rather than one for each.
|
||||
let mut around: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
||||
for name in names.iter().filter(|n| !tools.contains(&n.as_str())) {
|
||||
around.entry(current_look(s, name)?.0).or_default().push(name.clone());
|
||||
}
|
||||
for (hex, sharing_it) in around {
|
||||
let params = json!({"bodies": sharing_it, "color": hex, "opacity": TARGET_OPACITY});
|
||||
s.execute("appearance.assign", ¶ms).map_err(|e| format!("--tool: {e}"))?;
|
||||
}
|
||||
// Then the tool itself, solid in the tool colour.
|
||||
let params = json!({"bodies": tools, "color": TOOL_COLOR, "opacity": 1.0});
|
||||
s.execute("appearance.assign", ¶ms).map_err(|e| format!("--tool: {e}"))?;
|
||||
}
|
||||
for look in &looks {
|
||||
assign(s, look)?;
|
||||
}
|
||||
if let Some(params) = section {
|
||||
s.execute("inspect.section", ¶ms).map_err(|e| format!("--section: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `--body` names a body and says how it is drawn. Either key alone is enough.
|
||||
#[test]
|
||||
fn a_body_spec_is_a_name_and_the_keys_appearance_assign_takes() {
|
||||
let look = |s: &str| parse_body(s).unwrap();
|
||||
assert_eq!(look("Target:opacity=0.3"), BodyLook { name: "Target".into(), opacity: Some(0.3), color: None });
|
||||
assert_eq!(look("Tool:color=#CC3333"), BodyLook { name: "Tool".into(), opacity: None, color: Some("#cc3333".into()) });
|
||||
assert_eq!(look("Tool: opacity = 1 , color = #00ff00 "), BodyLook { name: "Tool".into(), opacity: Some(1.0), color: Some("#00ff00".into()) });
|
||||
// A body name with a space in it, as the browser shows them.
|
||||
assert_eq!(look("Bracket arm:opacity=0").name, "Bracket arm");
|
||||
assert_eq!(look("A:opacity=0").opacity, Some(0.0));
|
||||
}
|
||||
|
||||
/// Every way of getting `--body` wrong is an error that says what it takes, never a panic
|
||||
/// and never a silently ignored option.
|
||||
#[test]
|
||||
fn a_bad_body_spec_says_what_it_takes() {
|
||||
for (spec, expect) in [
|
||||
("Target", "NAME:key=value"),
|
||||
(":opacity=0.3", "names no body"),
|
||||
("Target:", "asks for nothing"),
|
||||
("Target:opacity", "key=value"),
|
||||
("Target:opacity=high", "0 to 1"),
|
||||
("Target:opacity=1.5", "0 to 1"),
|
||||
("Target:opacity=-0.2", "0 to 1"),
|
||||
("Target:opacity=NaN", "0 to 1"),
|
||||
("Target:opacity=inf", "0 to 1"),
|
||||
("Target:color=red", "#rrggbb"),
|
||||
("Target:color=#ccc", "#rrggbb"),
|
||||
("Target:color=#gggggg", "#rrggbb"),
|
||||
("Target:alpha=0.3", "unknown key `alpha`"),
|
||||
] {
|
||||
let e = parse_body(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
|
||||
assert!(e.contains(expect), "{spec}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `--section` spec is the plane `inspect.section` cuts with: an axis and a world
|
||||
/// coordinate, a named plane, `flip` for the other side, or the command's own JSON.
|
||||
#[test]
|
||||
fn a_section_spec_is_the_plane_inspect_section_takes() {
|
||||
let p = |s: &str| parse_section(s).unwrap();
|
||||
// The plane normal to each axis, cutting at that coordinate.
|
||||
assert_eq!(p("z=27"), json!({"plane": "XY", "at": 27.0}));
|
||||
assert_eq!(p("y=-5.5"), json!({"plane": "XZ", "at": -5.5}));
|
||||
assert_eq!(p("X=0"), json!({"plane": "YZ", "at": 0.0}));
|
||||
assert_eq!(p("z=27,flip"), json!({"plane": "XY", "at": 27.0, "flip": true}));
|
||||
// A named plane through the origin, and the command's own parameters.
|
||||
assert_eq!(p("XY"), json!({"plane": "XY"}));
|
||||
assert_eq!(p("yz,flip"), json!({"plane": "YZ", "flip": true}));
|
||||
assert_eq!(p("{\"plane\": \"XY\", \"offset\": 4}"), json!({"plane": "XY", "offset": 4}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_section_spec_says_what_it_takes() {
|
||||
for (spec, expect) in [
|
||||
("27", "z=27"),
|
||||
("w=4", "unknown axis `w`"),
|
||||
("z=high", "is not a number"),
|
||||
("z=inf", "not a finite coordinate"),
|
||||
("z=27,upside-down", "unknown `upside-down`"),
|
||||
("{\"plane\":", "not JSON"),
|
||||
] {
|
||||
let e = parse_section(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
|
||||
assert!(e.contains(expect), "{spec}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool colour is the one the options document, in the form `appearance.assign` takes.
|
||||
#[test]
|
||||
fn the_tool_colour_is_a_hex_colour_the_command_takes() {
|
||||
assert_eq!(parse_body(&format!("Tool:color={TOOL_COLOR}")).unwrap().color, Some(TOOL_COLOR.to_owned()));
|
||||
assert!((0.0..1.0).contains(&TARGET_OPACITY), "a target must be see-through: {TARGET_OPACITY}");
|
||||
assert_eq!(default_body_hex(), "#b0bac6");
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//! Complete headless image-job workflow through the same engine commands as the UI/MCP.
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::{
|
||||
doc::render_settings::RenderSetup,
|
||||
render::job::{CpuHost, JobId, Phase},
|
||||
};
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let input = args.first().filter(|p| !p.starts_with('-')).ok_or("render requires an input design or script")?;
|
||||
let mut out = None;
|
||||
let mut setup = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
let mut transparent = false;
|
||||
let mut overwrite = false;
|
||||
let mut timeout = 120u64;
|
||||
let mut i = 1;
|
||||
while let Some(arg) = args.get(i) {
|
||||
match arg.as_str() {
|
||||
"--transparent" => transparent = true,
|
||||
"--overwrite" => overwrite = true,
|
||||
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
|
||||
i += 1;
|
||||
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
|
||||
match arg.as_str() {
|
||||
"--out" => out = Some(value.clone()),
|
||||
"--setup" => setup = Some(value.clone()),
|
||||
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
|
||||
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
|
||||
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown render option: {arg}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !(1..=3600).contains(&timeout) {
|
||||
return Err("render timeout must be 1..3600 seconds".into());
|
||||
}
|
||||
let out = out.ok_or("render requires --out FILE.png")?;
|
||||
let mut session = crate::load(input)?;
|
||||
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
|
||||
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut settings = if let Some(name) = setup {
|
||||
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
|
||||
} else {
|
||||
let mut settings = RenderSetup::default();
|
||||
if let Some(camera) = session.camera {
|
||||
settings.camera = camera;
|
||||
} else {
|
||||
settings.camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
|
||||
}
|
||||
settings
|
||||
};
|
||||
if let Some(width) = width {
|
||||
settings.width = width;
|
||||
}
|
||||
if let Some(height) = height {
|
||||
settings.height = height;
|
||||
}
|
||||
let params = json!({"settings":settings,"transparent_background":transparent});
|
||||
let preflight = session.execute("render.preflight", ¶ms).map_err(|e| e.to_string())?;
|
||||
let started = session.execute("render.start", ¶ms).map_err(|e| e.to_string())?;
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
|
||||
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
|
||||
let status = match handle.wait_timeout(std::time::Duration::from_secs(timeout)) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
let _ = session.execute("render.cancel", &json!({"job":id}));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(status.error.map_or_else(|| "render cancelled".into(), |e| e.to_string()));
|
||||
}
|
||||
let exported = session.execute("render.export", &json!({"job":id,"path":out,"overwrite":overwrite})).map_err(|e| e.to_string())?;
|
||||
println!("{}", crate::pretty(&json!({"preflight":preflight,"export":exported})));
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
|
||||
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
|
||||
//! triangle meshes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A triangle soup in mm.
|
||||
pub type Tri = [[f64; 3]; 3];
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// Read a binary (or ASCII) STL file.
|
||||
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if b.len() >= 84 {
|
||||
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
|
||||
if b.len() == 84 + 50 * n {
|
||||
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
|
||||
return Ok((0..n)
|
||||
.map(|i| {
|
||||
let o = 84 + 50 * i + 12;
|
||||
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
|
||||
[v(0), v(1), v(2)]
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let mut verts = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
if it.next() == Some("vertex") {
|
||||
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
|
||||
if c.len() == 3 {
|
||||
verts.push([c[0], c[1], c[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if verts.is_empty() || verts.len() % 3 != 0 {
|
||||
return Err(format!("{path}: not an STL file"));
|
||||
}
|
||||
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
}
|
||||
|
||||
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
|
||||
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
|
||||
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
|
||||
let (mut v6, mut m1) = (0.0, [0.0; 3]);
|
||||
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
|
||||
for t in tris {
|
||||
let d = dot(t[0], cross(t[1], t[2]));
|
||||
v6 += d;
|
||||
let s = add(add(t[0], t[1]), t[2]);
|
||||
m1 = add(m1, scale(s, d / 24.0));
|
||||
for (i, row) in c.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
|
||||
*cell += d / 120.0 * vv;
|
||||
}
|
||||
}
|
||||
}
|
||||
let vol = v6 / 6.0;
|
||||
if vol.abs() < 1e-12 {
|
||||
return (0.0, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
let com = scale(m1, 1.0 / vol);
|
||||
// Central covariance and inertia tensor I = tr(C)·1 − C.
|
||||
let mut cc = c;
|
||||
for (i, row) in cc.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell -= vol * com[i] * com[j];
|
||||
}
|
||||
}
|
||||
let tr = cc[0][0] + cc[1][1] + cc[2][2];
|
||||
let mut inertia = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
|
||||
}
|
||||
}
|
||||
let mut ev = eigen_sym3(inertia);
|
||||
ev.sort_by(f64::total_cmp);
|
||||
(vol, com, ev)
|
||||
}
|
||||
|
||||
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
|
||||
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
|
||||
for _ in 0..50 {
|
||||
let (mut p, mut q, mut big) = (0, 1, 0.0);
|
||||
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
|
||||
if a[i][j].abs() > big {
|
||||
(p, q, big) = (i, j, a[i][j].abs());
|
||||
}
|
||||
}
|
||||
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
|
||||
break;
|
||||
}
|
||||
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
|
||||
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||||
let t = if theta == 0.0 { 1.0 } else { t };
|
||||
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
|
||||
let mut b = a;
|
||||
for k in 0..3 {
|
||||
b[k][p] = cs * a[k][p] - sn * a[k][q];
|
||||
b[k][q] = sn * a[k][p] + cs * a[k][q];
|
||||
}
|
||||
let mut c2 = b;
|
||||
for k in 0..3 {
|
||||
c2[p][k] = cs * b[p][k] - sn * b[q][k];
|
||||
c2[q][k] = sn * b[p][k] + cs * b[q][k];
|
||||
}
|
||||
a = c2;
|
||||
}
|
||||
[a[0][0], a[1][1], a[2][2]]
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds of a triangle soup.
|
||||
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in tris.iter().flatten() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
|
||||
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
|
||||
let (a, b, c) = (t[0], t[1], t[2]);
|
||||
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
|
||||
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
|
||||
let q = if d1 <= 0.0 && d2 <= 0.0 {
|
||||
a
|
||||
} else {
|
||||
let bp = sub(p, b);
|
||||
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
b
|
||||
} else {
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
add(a, scale(ab, d1 / (d1 - d3)))
|
||||
} else {
|
||||
let cp = sub(p, c);
|
||||
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
c
|
||||
} else {
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
add(a, scale(ac, d2 / (d2 - d6)))
|
||||
} else {
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
|
||||
} else {
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let d = sub(p, q);
|
||||
dot(d, d).sqrt()
|
||||
}
|
||||
|
||||
/// Uniform grid of triangles for distance queries up to `cap`.
|
||||
struct Grid<'a> {
|
||||
tris: &'a [Tri],
|
||||
cell: f64,
|
||||
cells: HashMap<(i64, i64, i64), Vec<u32>>,
|
||||
}
|
||||
|
||||
impl<'a> Grid<'a> {
|
||||
fn new(tris: &'a [Tri], cell: f64) -> Self {
|
||||
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let key = |x: f64| (x / cell).floor() as i64;
|
||||
for (i, t) in tris.iter().enumerate() {
|
||||
let (lo, hi) = bounds(std::slice::from_ref(t));
|
||||
for x in key(lo[0])..=key(hi[0]) {
|
||||
for y in key(lo[1])..=key(hi[1]) {
|
||||
for z in key(lo[2])..=key(hi[2]) {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Grid { tris, cell, cells }
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
|
||||
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
|
||||
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
|
||||
let rmax = (cap / self.cell).ceil() as i64;
|
||||
let k = |x: f64| (x / self.cell).floor() as i64;
|
||||
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
|
||||
let mut best = cap;
|
||||
for r in 0..=rmax {
|
||||
for x in cx - r..=cx + r {
|
||||
for y in cy - r..=cy + r {
|
||||
for z in cz - r..=cz + r {
|
||||
// only the shell at Chebyshev distance r
|
||||
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
|
||||
for &i in list {
|
||||
let Some(slot) = seen.get_mut(i as usize) else { continue };
|
||||
if *slot == stamp {
|
||||
continue;
|
||||
}
|
||||
*slot = stamp;
|
||||
if let Some(t) = self.tris.get(i as usize) {
|
||||
best = best.min(point_tri(p, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Everything not yet searched is at least r·cell away from p.
|
||||
if best <= r as f64 * self.cell {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many sample points per side (larger meshes are sampled with an even stride).
|
||||
const MAX_SAMPLES: usize = 250_000;
|
||||
|
||||
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
|
||||
/// very large meshes).
|
||||
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
|
||||
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
|
||||
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
|
||||
for t in tris.iter().step_by(stride) {
|
||||
out.extend_from_slice(t);
|
||||
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
|
||||
/// capped at `cap`.
|
||||
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
|
||||
let (lo, hi) = bounds(to);
|
||||
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
|
||||
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
|
||||
// finer than diag/400 (so big flat triangles don't fill too many cells).
|
||||
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
|
||||
let typical = (area / to.len().max(1) as f64).sqrt();
|
||||
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
|
||||
let mut seen = vec![0u32; to.len()];
|
||||
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
|
||||
for (i, p) in samples(from).into_iter().enumerate() {
|
||||
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
|
||||
sum += d;
|
||||
n += 1;
|
||||
if d > max {
|
||||
max = d;
|
||||
worst = p;
|
||||
}
|
||||
}
|
||||
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
|
||||
}
|
||||
|
||||
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
|
||||
pub struct Hausdorff {
|
||||
pub a_to_b: f64,
|
||||
pub b_to_a: f64,
|
||||
pub mean: f64,
|
||||
/// Where the larger one-sided distance was found (mm, world).
|
||||
pub worst_at: [f64; 3],
|
||||
}
|
||||
|
||||
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
|
||||
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
|
||||
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
|
||||
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
|
||||
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
|
||||
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
|
||||
let mut t = Vec::new();
|
||||
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
|
||||
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
|
||||
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
|
||||
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
|
||||
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
|
||||
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_moments_and_distance() {
|
||||
let c = cube(10.0, [5.0, -3.0, 2.0]);
|
||||
let (v, com, pm) = mass_properties(&c);
|
||||
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
|
||||
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
|
||||
for m in pm {
|
||||
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
|
||||
}
|
||||
let moved = cube(10.0, [5.0, -3.0, 2.3]);
|
||||
let h = hausdorff(&c, &moved, 1.0);
|
||||
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
|
||||
let same = hausdorff(&c, &c, 1.0);
|
||||
assert!(same.a_to_b < 1e-12);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
//! The bracket example must cut two through holes, rather than successfully cutting empty
|
||||
//! space beside the body. Check the shipped script and rebuild its saved design from scratch.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::{Session, io, kernel};
|
||||
|
||||
fn assert_two_through_holes(session: &Session, stage: &str) {
|
||||
let errors: Vec<_> = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect();
|
||||
assert!(errors.is_empty(), "{stage}: every example feature evaluates: {errors:?}");
|
||||
|
||||
let cut = session.doc.features.last().expect("the example ends with the hole cut");
|
||||
let before = session.model.state_before(cut.id);
|
||||
let after = session.model.state();
|
||||
assert_eq!(before.bodies.len(), 1, "{stage}: one bracket before cutting the holes");
|
||||
assert_eq!(after.bodies.len(), 1, "{stage}: the holes leave one bracket");
|
||||
let uncut = &before.body("Bracket").expect("the uncut bracket").body;
|
||||
let bracket = &after.body("Bracket").expect("the finished bracket").body;
|
||||
|
||||
// Measure the actual state before the final cut so the existing edge fillet cancels out.
|
||||
// Both holes have diameter 8 mm and pass through the 6 mm base. Kernel measurements are
|
||||
// tessellated, so the relative tolerance applies to the removed material, not the body.
|
||||
let removed = kernel::measure(uncut).unwrap().volume - kernel::measure(bracket).unwrap().volume;
|
||||
let expected = 2.0 * PI * 4.0_f64.powi(2) * 6.0;
|
||||
assert!((removed - expected).abs() < expected * 1e-3, "{stage}: the two holes remove {removed} mm³, expected {expected} mm³");
|
||||
|
||||
let before_mesh = uncut.tessellate(0.01).unwrap();
|
||||
let after_mesh = bracket.tessellate(0.01).unwrap();
|
||||
let down = Vec3::new(0.0, 0.0, -1.0);
|
||||
for y in [-15.0, -45.0] {
|
||||
let center = Vec3::new(25.0, y, 1.0);
|
||||
assert_eq!(before_mesh.ray_crossings(center, down), 2, "{stage}: hole at y={y} starts over the solid base");
|
||||
// Rays through each opening must miss the entire bracket, including its top and
|
||||
// bottom caps. Points away from the centre guard against a narrow slit or shifted cut.
|
||||
for (dx, dy) in [(0.0, 0.0), (3.0, 0.0), (-3.0, 0.0), (0.0, 3.0), (0.0, -3.0)] {
|
||||
let ray = center + Vec3::new(dx, dy, 0.0);
|
||||
assert_eq!(after_mesh.ray_crossings(ray, down), 0, "{stage}: the hole at y={y} is open through both caps at ({dx}, {dy})");
|
||||
}
|
||||
// Material just outside each radius remains, so neither opening is an edge notch.
|
||||
for (dx, dy) in [(5.0, 0.0), (-5.0, 0.0), (0.0, 5.0), (0.0, -5.0)] {
|
||||
let ray = center + Vec3::new(dx, dy, 0.0);
|
||||
assert_eq!(after_mesh.ray_crossings(ray, down), 2, "{stage}: material remains around the hole at y={y}, offset ({dx}, {dy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_example_cuts_two_through_holes_and_reopens() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/bracket.json")).unwrap();
|
||||
let mut session = Session::default();
|
||||
session.run_script(&script).unwrap();
|
||||
assert_two_through_holes(&session, "script");
|
||||
|
||||
// No geometry cache is supplied: reopening the container re-evaluates the saved timeline.
|
||||
let saved = io::write_design_container(&session.doc).unwrap();
|
||||
let document = io::read_design(&saved).unwrap();
|
||||
assert_eq!(document.features, session.doc.features, "saving preserves the example's feature parameters");
|
||||
let reopened = Session::new(document);
|
||||
assert_two_through_holes(&reopened, "reopened");
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! The registry remains valid JSON and a closed pipeline reader must not cause a panic.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let commands = registry.as_array().unwrap();
|
||||
assert!(commands.iter().any(|command| command["id"] == "solid.box"));
|
||||
assert!(commands.iter().any(|command| command["id"] == "engine.commands"));
|
||||
assert!(output.stdout.ends_with(b"\n"));
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// Close our read end before waiting. No sleeps or timing threshold are needed:
|
||||
// the registry is larger than the pipe buffer, so it cannot all be written first.
|
||||
drop(child.stdout.take());
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# Design file corpus
|
||||
|
||||
Designs saved by older SolidCraft builds. `tests/design_corpus.rs` opens each one with the
|
||||
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
|
||||
it is `solidcraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
|
||||
world bounding boxes), that it is upgraded to the current file format, and that damaged and
|
||||
hostile variants of it never panic.
|
||||
|
||||
| File | Saved by | Era |
|
||||
|---|---|---|
|
||||
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
|
||||
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
|
||||
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
|
||||
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
|
||||
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
|
||||
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
|
||||
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
|
||||
|
||||
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
|
||||
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
|
||||
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
|
||||
|
||||
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
|
||||
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 25751.999999999945,
|
||||
"bbox": {
|
||||
"max": [
|
||||
35.0,
|
||||
42.0,
|
||||
48.00000000000001
|
||||
],
|
||||
"min": [
|
||||
-35.0,
|
||||
-42.000000000000014,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
5.524909928104648e-14,
|
||||
-1.0945382096961234e-12,
|
||||
22.366336633663238
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 24,
|
||||
"face_types": {
|
||||
"plane": 10
|
||||
},
|
||||
"faces": 10,
|
||||
"kernel": {
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"shells": 1,
|
||||
"vertices": 16
|
||||
},
|
||||
"mass_g": 328.33079999999734,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm",
|
||||
"value": 70.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 6,
|
||||
"dof": 0,
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 10,
|
||||
"dof": 1,
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"profiles": 3,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.131943,
|
||||
"name": "Sketch1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 1.0812709999999999,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.450002,
|
||||
"name": "Sketch2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 5.191514000000001,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 25751.999999999945,
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"mass_g": 328.33079999999734,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"plane": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"p": 5,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 5,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 1,
|
||||
"param": "d1",
|
||||
"type": "length",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 0,
|
||||
"param": "d2",
|
||||
"type": "length",
|
||||
"value": 70.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 6,
|
||||
"p": 5
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 1,
|
||||
"b": 3,
|
||||
"construction": true,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": true,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "30"
|
||||
},
|
||||
"id": 2,
|
||||
"name": "Extrude1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 1,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"plane": {
|
||||
"anchor": {
|
||||
"author_path": [],
|
||||
"component": 0,
|
||||
"coordinates": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"path": [],
|
||||
"target": {
|
||||
"body": "Body1",
|
||||
"certificate": {
|
||||
"high": true,
|
||||
"holes": [],
|
||||
"kind": "extrude_cap",
|
||||
"outer": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
],
|
||||
"sketch": 1
|
||||
},
|
||||
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"origin_feature": 2,
|
||||
"point": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
]
|
||||
},
|
||||
"x_axis": [
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"at": [
|
||||
2.012802880253332,
|
||||
7.4635742926698185,
|
||||
30.000000000000114
|
||||
],
|
||||
"name": "F2:top",
|
||||
"plane": {
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"type": "face"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 8,
|
||||
"p": 9,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 9,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"p": 7,
|
||||
"param": "d4",
|
||||
"q": 6,
|
||||
"text": [
|
||||
-0.2,
|
||||
9.200000000000003
|
||||
],
|
||||
"type": "distance",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"j": 1,
|
||||
"k": 7,
|
||||
"l": 10,
|
||||
"p": 9
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 7,
|
||||
"construction": true,
|
||||
"id": "l9",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 8,
|
||||
"construction": true,
|
||||
"id": "l10",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"auto_face": true,
|
||||
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"id": "j1",
|
||||
"kind": "project",
|
||||
"source": {
|
||||
"at": [
|
||||
11.666666666666666,
|
||||
-13.999999999999995,
|
||||
30.0
|
||||
],
|
||||
"body": "Body1",
|
||||
"type": "face"
|
||||
}
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p1",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p2",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p3",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p4",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-31.000000000002185
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
35.0,
|
||||
-31.00000000000349
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
35.0,
|
||||
30.99999999999603
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
-35.0,
|
||||
30.999999999995318
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p9",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "18"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l5",
|
||||
"l4",
|
||||
"l7"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/14",
|
||||
"name": "Untitled",
|
||||
"next_id": 6,
|
||||
"params": [
|
||||
{
|
||||
"expr": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 8430.29544246263,
|
||||
"bbox": {
|
||||
"max": [
|
||||
176.8000000000002,
|
||||
89.30000000000001,
|
||||
14.100000000000001
|
||||
],
|
||||
"min": [
|
||||
99.99999999999991,
|
||||
-1.820765760385257e-14,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
138.3999999716193,
|
||||
51.04392591016203,
|
||||
2.0990634391586322
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 156,
|
||||
"face_types": {
|
||||
"cylinder": 4,
|
||||
"plane": 50
|
||||
},
|
||||
"faces": 54,
|
||||
"kernel": {
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"shells": 1,
|
||||
"vertices": 104
|
||||
},
|
||||
"mass_g": 9.101726874183207,
|
||||
"material": "Default",
|
||||
"name": "Lid",
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 8,
|
||||
"dof": 8,
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"profiles": 2,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 1.178957,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 83.24342399999999,
|
||||
"name": "Fillet1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.150889,
|
||||
"name": "Skirt",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 94.465481,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 50.426736,
|
||||
"name": "Box2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 40.383005,
|
||||
"name": "Box3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 59.041461000000005,
|
||||
"name": "Box4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [
|
||||
{
|
||||
"face_shift_mm": 0.0,
|
||||
"rotation_rad": 0.0,
|
||||
"strategy": "planar_fallback",
|
||||
"tolerance_mm": 0.0,
|
||||
"translation_mm": 0.0
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 8,
|
||||
"ms": 562.5477970000001,
|
||||
"name": "Rib1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": "Boolean retry: exact planar fallback (no displacement)"
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 9,
|
||||
"ms": 89.56076,
|
||||
"name": "Rib2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 10,
|
||||
"ms": 65.330634,
|
||||
"name": "Rib3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 11,
|
||||
"ms": 97.229403,
|
||||
"name": "Rib4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 8430.29544246263,
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"mass_g": 9.101726874183207,
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"body_names": [
|
||||
"Lid"
|
||||
],
|
||||
"corner": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"height": "2.4",
|
||||
"id": 1,
|
||||
"length": "76.8",
|
||||
"name": "Box1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "89.3"
|
||||
},
|
||||
{
|
||||
"edge_names": [
|
||||
"F1:-x|F1:-y",
|
||||
"F1:+x|F1:-y",
|
||||
"F1:+y|F1:-x",
|
||||
"F1:+x|F1:+y"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
100.0,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
100.0,
|
||||
89.3,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
89.3,
|
||||
1.2
|
||||
]
|
||||
],
|
||||
"id": 2,
|
||||
"name": "Fillet1",
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"radius": "3",
|
||||
"suppressed": false,
|
||||
"type": "fillet"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"plane": {
|
||||
"base": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"distance": "2.4",
|
||||
"type": "offset"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 8,
|
||||
"p": 8
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
102.65,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
174.15,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
174.15,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
102.65,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
104.15,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
172.65,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
172.65,
|
||||
85.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
104.15,
|
||||
85.15
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "3"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"targets": [
|
||||
"Lid"
|
||||
],
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
104.4,
|
||||
5.4,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 5,
|
||||
"length": "68",
|
||||
"name": "Box2",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d7",
|
||||
"d8",
|
||||
"d9"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "61"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
116.4,
|
||||
72.0,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 6,
|
||||
"length": "44",
|
||||
"name": "Box3",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d10",
|
||||
"d11",
|
||||
"d12"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "10"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
127.4,
|
||||
84.0,
|
||||
2.4
|
||||
],
|
||||
"height": "4",
|
||||
"id": 7,
|
||||
"length": "22",
|
||||
"name": "Box4",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d13",
|
||||
"d14",
|
||||
"d15"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 8,
|
||||
"length": "1.6",
|
||||
"name": "Rib1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d16",
|
||||
"d17",
|
||||
"d18"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 9,
|
||||
"length": "1.6",
|
||||
"name": "Rib2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d19",
|
||||
"d20",
|
||||
"d21"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 10,
|
||||
"length": "1.6",
|
||||
"name": "Rib3",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d22",
|
||||
"d23",
|
||||
"d24"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 11,
|
||||
"length": "1.6",
|
||||
"name": "Rib4",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d25",
|
||||
"d26",
|
||||
"d27"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/15",
|
||||
"name": "issue-616-lid-ribs",
|
||||
"next_id": 12,
|
||||
"params": [],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 2400.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
50.0,
|
||||
20.0,
|
||||
20.0
|
||||
],
|
||||
"min": [
|
||||
30.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
40.0,
|
||||
10.0,
|
||||
10.0
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 10.32,
|
||||
"material": "Default",
|
||||
"name": "Block",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 8000.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 600.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
3.552713678800501e-15,
|
||||
90.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-9.999999999999998,
|
||||
80.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
-4.999999999999999,
|
||||
85.0,
|
||||
5.000000000000001
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 1.29,
|
||||
"material": "Default",
|
||||
"name": "Peg",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 1000.0
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "Peg",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.204585,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 2.318662,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.109196,
|
||||
"name": "Peg",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 0.338859,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 0.312259,
|
||||
"name": "Move1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 0.23144699999999999,
|
||||
"name": "Move2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3000.0,
|
||||
"edges": 24,
|
||||
"faces": 12,
|
||||
"mass_g": 11.61,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 9000.0
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 4078.7680390666596,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.000000000000014,
|
||||
12.000000000000002
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
19.25890231218174,
|
||||
15.000000010054242,
|
||||
5.04117761026949
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 30,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 11
|
||||
},
|
||||
"faces": 12,
|
||||
"kernel": {
|
||||
"edges": 30,
|
||||
"faces": 12,
|
||||
"shells": 1,
|
||||
"vertices": 20
|
||||
},
|
||||
"mass_g": 14.435720327850033,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11190.480874302351
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.09895799999999999,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 20.768292000000002,
|
||||
"name": "Block",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.057,
|
||||
"name": "SlotSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 136.28875,
|
||||
"name": "Slot",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 153.58625,
|
||||
"name": "Round",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 169.174375,
|
||||
"name": "Bevel",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 47.772166,
|
||||
"name": "Raise",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "OffsetFacesFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 4078.7680390666596,
|
||||
"edges": 30,
|
||||
"faces": 12,
|
||||
"mass_g": 14.435720327850033,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11190.480874302351
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
{
|
||||
"format": "solidcraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
40.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
40.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"type": "coincident",
|
||||
"p": 0,
|
||||
"q": 1
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 5,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Block",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
15.0,
|
||||
-5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
25.0,
|
||||
-5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
25.0,
|
||||
35.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
15.0,
|
||||
35.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 4,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Slot",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive",
|
||||
"start_offset": "5"
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Round",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
-1.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l2|F2:top#2"
|
||||
],
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
40.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "2"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Bevel",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.000000000000007,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l4|F2:top#1"
|
||||
],
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
0.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1.5"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Raise",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"face_names": [
|
||||
"F2:top#1"
|
||||
],
|
||||
"type": "offset_face",
|
||||
"faces": [
|
||||
[
|
||||
7.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "2"
|
||||
}
|
||||
],
|
||||
"next_id": 8
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{"commands": [
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
|
||||
{"command": "component.create", "params": {"name": "Arm"}},
|
||||
{"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}},
|
||||
{"command": "component.activate", "params": {"component": 0}},
|
||||
{"command": "occurrence.move", "params": {"occurrence": 3, "translate": [0, 50, 0]}},
|
||||
{"command": "occurrence.copy", "params": {"component": "Arm", "translate": [0, 20, 0]}},
|
||||
{"command": "component.paste_new", "params": {"component": "Arm", "translate": [0, -30, 0]}},
|
||||
{"command": "occurrence.ground", "params": {"occurrence": 3}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "S1"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [5, 5], "radius": 2}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}}
|
||||
]}
|
||||
@@ -1,14 +0,0 @@
|
||||
{"commands": [
|
||||
{"command": "parameters.change", "params": {"name": "open_angle", "expression": "90 deg"}},
|
||||
{"command": "component.create", "params": {"name": "LeafA"}},
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
|
||||
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
|
||||
{"command": "component.activate", "params": {"component": "root"}},
|
||||
{"command": "component.create", "params": {"name": "LeafB"}},
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
|
||||
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
|
||||
{"command": "component.activate", "params": {"component": "root"}},
|
||||
{"command": "occurrence.ground", "params": {"occurrence": 2, "grounded": true}},
|
||||
{"command": "joint.create", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
|
||||
{"command": "joint.drive", "params": {"joint": "Hinge", "value": "open_angle"}}
|
||||
]}
|
||||
@@ -1,12 +0,0 @@
|
||||
{"commands": [
|
||||
{"command": "parameters.change", "params": {"name": "width", "expression": "40 mm"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "width / 2"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}}
|
||||
]}
|
||||
@@ -1,11 +0,0 @@
|
||||
{"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XZ"}},
|
||||
{"command": "sketch.line", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.revolve", "params": {"axis": "y"}},
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
|
||||
{"command": "solid.combine", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
|
||||
{"command": "solid.chamfer", "params": {"edges": [[30, 5, 10]], "distance": 1}},
|
||||
{"command": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}}
|
||||
]}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"commands": [
|
||||
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
|
||||
{"command": "solid.box", "params": {"length": 80, "width": 50, "height": 30}},
|
||||
{"command": "solid.shell", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
|
||||
{"command": "plastic.lip", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
|
||||
{"command": "plastic.boss", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
|
||||
{"command": "plastic.rest", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
|
||||
{"command": "plastic.snap_fit", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
|
||||
{"command": "plastic.manage_rules", "params": {"name": "ABS (1.5mm)", "active": true}}
|
||||
]}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
|
||||
]
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": 10,
|
||||
"name": "Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "SlotSketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
15,
|
||||
-5
|
||||
],
|
||||
"p1": [
|
||||
25,
|
||||
35
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"sketch": "SlotSketch",
|
||||
"distance": 10,
|
||||
"start_offset": 5,
|
||||
"operation": "cut",
|
||||
"name": "Slot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
40,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"radius": 2,
|
||||
"name": "Round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.chamfer",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
0,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 1.5,
|
||||
"name": "Bevel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.offset_face",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
7,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 2,
|
||||
"name": "Raise"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": 10,
|
||||
"name": "Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "SlotSketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
-5,
|
||||
12
|
||||
],
|
||||
"p1": [
|
||||
45,
|
||||
18
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"sketch": "SlotSketch",
|
||||
"distance": 10,
|
||||
"operation": "cut",
|
||||
"name": "Slot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
20,
|
||||
0,
|
||||
10
|
||||
]
|
||||
],
|
||||
"radius": 2,
|
||||
"body": "Body1 (1)",
|
||||
"name": "NearRound"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.chamfer",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
20,
|
||||
30,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 1.5,
|
||||
"body": "Body1",
|
||||
"name": "FarBevel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "appearance.assign",
|
||||
"params": {
|
||||
"bodies": [
|
||||
"Body1 (1)"
|
||||
],
|
||||
"color": "#cc3333"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 1962.6028137423857,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
18.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.0,
|
||||
23.94794952681388,
|
||||
4.957413249211356
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 15,
|
||||
"face_types": {
|
||||
"plane": 7
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 15,
|
||||
"faces": 7,
|
||||
"shells": 1,
|
||||
"vertices": 10
|
||||
},
|
||||
"mass_g": 6.13395,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 4755.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 1963.9401350108699,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.00000000000002,
|
||||
12.000000000000002,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-8.881784197001252e-15,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.00000000000079,
|
||||
6.040035701025577,
|
||||
4.967173993940324
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 15,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 15,
|
||||
"faces": 7,
|
||||
"shells": 1,
|
||||
"vertices": 10
|
||||
},
|
||||
"mass_g": 6.147677129601969,
|
||||
"material": "Default",
|
||||
"name": "Body1 (1)",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 4765.64118573796
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.07066700000000001,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 0.7462909999999999,
|
||||
"name": "Block",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.035125,
|
||||
"name": "SlotSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 9.647208000000001,
|
||||
"name": "Slot",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 3.443708,
|
||||
"name": "NearRound",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 3.947834,
|
||||
"name": "FarBevel",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3926.5429487532556,
|
||||
"edges": 30,
|
||||
"faces": 14,
|
||||
"mass_g": 12.281627129601969,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 9520.64118573796
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
{
|
||||
"format": "solidcraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
40.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
40.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"type": "coincident",
|
||||
"p": 0,
|
||||
"q": 1
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 5,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Block",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-5.0,
|
||||
12.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
45.0,
|
||||
12.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
45.0,
|
||||
18.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-5.0,
|
||||
18.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 4,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Slot",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d2"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "NearRound",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
-1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
12.000000000000002,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l1|F2:top"
|
||||
],
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
20.0,
|
||||
0.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "2",
|
||||
"body": "Body1 (1)"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "FarBevel",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
18.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l3|F2:top"
|
||||
],
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
20.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1.5",
|
||||
"body": "Body1"
|
||||
}
|
||||
],
|
||||
"next_id": 7,
|
||||
"appearances": {
|
||||
"bodies": {
|
||||
"Body1 (1)": {
|
||||
"name": "Custom",
|
||||
"color": [
|
||||
204,
|
||||
51,
|
||||
51
|
||||
],
|
||||
"opacity": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
//! Designs saved by older SolidCraft builds (tests/corpus, see its README) open and evaluate as
|
||||
//! they did when they were saved; damaged and hostile design files never panic.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
||||
v
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// Differences that are deliberate behaviour changes made before the file format had versions
|
||||
/// (no migration can tell those files apart): (file, check, why).
|
||||
const KNOWN: [(&str, &str, &str); 2] = [
|
||||
(
|
||||
"m0-revolve-primitives",
|
||||
"Body1 bbox",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
|
||||
),
|
||||
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
||||
];
|
||||
|
||||
fn known(file: &str, check: &str) -> bool {
|
||||
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
|
||||
}
|
||||
|
||||
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
|
||||
/// become exact since): the same part within this relative tolerance (the oracle's).
|
||||
const REL: f64 = 1e-3;
|
||||
|
||||
fn close(a: f64, b: f64, rel: f64) -> bool {
|
||||
(a - b).abs() <= rel * b.abs().max(1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_designs_open_and_evaluate_as_saved() {
|
||||
let mut problems = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
|
||||
let mut s = open(&p);
|
||||
for r in &s.model.results {
|
||||
if let Some(e) = &r.error {
|
||||
problems.push(format!("{name}: feature {}: {e}", r.id));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc.format, solidcraft_engine::doc::format::format_string(solidcraft_engine::doc::format::FORMAT), "{name}: upgraded");
|
||||
let got = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
|
||||
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
|
||||
}
|
||||
for w in want["bodies"].as_array().into_iter().flatten() {
|
||||
let bname = w["name"].as_str().unwrap_or_default();
|
||||
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
|
||||
problems.push(format!("{name}: body {bname} missing"));
|
||||
continue;
|
||||
};
|
||||
for k in ["volume_mm3", "area_mm2"] {
|
||||
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
|
||||
if !close(a, b, REL) {
|
||||
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
|
||||
}
|
||||
}
|
||||
if known(&name, &format!("{bname} bbox")) {
|
||||
continue;
|
||||
}
|
||||
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
|
||||
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
|
||||
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
|
||||
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Saved again, it reads back the same.
|
||||
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(again, *s.doc, "{name}: round trip");
|
||||
let package = solidcraft_engine::io::write_design_container(&s.doc).unwrap();
|
||||
let packaged = solidcraft_engine::io::read_design(&package).unwrap();
|
||||
assert_eq!(packaged, *s.doc, "{name}: container round trip after migration");
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
|
||||
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
|
||||
/// they are found as the build that saved them found them, without new notes.
|
||||
#[test]
|
||||
fn names_saved_before_exact_history_find_the_faces_they_named() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solidcraft");
|
||||
let s = open(&path);
|
||||
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
|
||||
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
|
||||
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
// The raised piece is the left one, as saved (the stored point is on it).
|
||||
let state = s.model.state();
|
||||
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
|
||||
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
|
||||
}
|
||||
|
||||
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
|
||||
/// The features after the split that refer to a piece by its body name still find that piece:
|
||||
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
|
||||
#[test]
|
||||
fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solidcraft");
|
||||
let s = open(&path);
|
||||
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
let state = s.model.state();
|
||||
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
|
||||
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
|
||||
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
||||
}
|
||||
|
||||
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
|
||||
/// planes as outward normal and distance from the origin, or every flat face of the body,
|
||||
/// issue). Face indices are not used: they can change between builds. Each issue has an
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 1] = [("issue-616-lid-ribs", "Lid", None, 628)];
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
|
||||
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
|
||||
if *d != design || *b != body {
|
||||
return None;
|
||||
}
|
||||
match planes {
|
||||
None => Some((*issue, false)),
|
||||
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn undo_to(s: &mut Session, features: usize) {
|
||||
for _ in 0..4 {
|
||||
if s.doc.features.len() <= features {
|
||||
return;
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
|
||||
}
|
||||
|
||||
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
|
||||
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
|
||||
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
|
||||
/// (design, body, face, issue it is known under, what went wrong or None).
|
||||
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
|
||||
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
|
||||
let mut out = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut s = open(&p);
|
||||
let state = s.model.state();
|
||||
let placed = s.placed_document();
|
||||
let mut picks = Vec::new();
|
||||
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
|
||||
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
|
||||
continue;
|
||||
};
|
||||
let mesh = body.body.tessellate(0.01).unwrap();
|
||||
for index in 0..body.body.face_count() {
|
||||
if body.body.planar_face(index).is_none() {
|
||||
continue;
|
||||
}
|
||||
// The middle of the face's largest triangle: well inside the face.
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
|
||||
let Some(point) = point else { continue };
|
||||
let point = apply_point(&frame, point);
|
||||
let normal =
|
||||
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
|
||||
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
|
||||
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
|
||||
}
|
||||
}
|
||||
let before = s.doc.features.len();
|
||||
for (body, index, (normal, distance), plane) in picks {
|
||||
let mut problem = None;
|
||||
match s.execute("sketch.create", &json!({"plane": plane})) {
|
||||
Ok(created) => {
|
||||
let id = created["sketch"].as_u64().unwrap_or(0);
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
|
||||
other => problem = Some(format!("sketch attachment {other:?}")),
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
undo_to(&mut s, before);
|
||||
}
|
||||
Err(e) => problem = Some(format!("sketch.create: {e}")),
|
||||
}
|
||||
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
|
||||
Ok(_) => undo_to(&mut s, before),
|
||||
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
|
||||
}
|
||||
let known = known_without_attachment(&name, &body, normal, distance);
|
||||
out.push((name.clone(), body, index, known, problem));
|
||||
}
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
|
||||
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
|
||||
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
|
||||
#[test]
|
||||
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
|
||||
let all = flat_face_attachments();
|
||||
assert!(all.len() > 50, "only {} faces checked", all.len());
|
||||
let problems: Vec<String> = all
|
||||
.iter()
|
||||
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
|
||||
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
|
||||
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
|
||||
}
|
||||
|
||||
fn known_faces_attach(issue: u32) {
|
||||
let problems: Vec<String> = flat_face_attachments()
|
||||
.into_iter()
|
||||
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
|
||||
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #628 is fixed"]
|
||||
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
|
||||
known_faces_attach(628);
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE: [&str; 14] =
|
||||
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
|
||||
|
||||
/// Every JSON value in a document, as a path of keys and indices.
|
||||
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
|
||||
out.push(at.clone());
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
at.push(json!(k));
|
||||
paths(x, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
at.push(json!(i));
|
||||
paths(x, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
|
||||
let mut cur = v;
|
||||
for k in path {
|
||||
cur = match k {
|
||||
Value::String(s) => cur.get_mut(s.as_str())?,
|
||||
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
|
||||
fn try_bytes(bytes: &[u8]) {
|
||||
if let Ok(doc) = solidcraft_engine::io::read_design(bytes) {
|
||||
let mut s = Session::new(doc);
|
||||
let _ = s.execute("inspect.measure", &json!({}));
|
||||
let _ = s.execute("document.inspect", &json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
|
||||
/// cycle, which resolve through the model and back into the document.
|
||||
fn generated() -> Vec<(String, Vec<u8>)> {
|
||||
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
|
||||
let plane = |id: u64, name: &str, side: Value| {
|
||||
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
|
||||
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
|
||||
};
|
||||
let design = |name: &str, features: Value| {
|
||||
json!({"format": "solidcraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
||||
};
|
||||
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
|
||||
// by the point count and abort on the allocation.
|
||||
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
|
||||
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
|
||||
"plane": {"type": "origin", "name": "XY"},
|
||||
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
|
||||
vec![
|
||||
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
|
||||
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
|
||||
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_design_files_never_panic() {
|
||||
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
||||
// The cases come from one seeded sequence; trying them is independent, so threads share it.
|
||||
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
let doc: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let mut all = Vec::new();
|
||||
paths(&doc, &mut Vec::new(), &mut all);
|
||||
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// Truncated and bit-flipped files.
|
||||
for _ in 0..8 {
|
||||
let cut = rng.below(bytes.len());
|
||||
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
|
||||
let mut b = bytes.clone();
|
||||
let i = rng.below(b.len());
|
||||
b[i] ^= 1 << rng.below(8);
|
||||
cases.push((format!("bit flip at {i}"), b));
|
||||
}
|
||||
// Values replaced by hostile ones, and keys removed.
|
||||
for _ in 0..25 {
|
||||
let path = &all[rng.below(all.len())];
|
||||
let mut v = doc.clone();
|
||||
let h = HOSTILE[rng.below(HOSTILE.len())];
|
||||
if let Some(slot) = at_mut(&mut v, path) {
|
||||
*slot = serde_json::from_str(h).unwrap();
|
||||
}
|
||||
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
|
||||
let mut v = doc.clone();
|
||||
if let Some((last, parent)) = path.split_last()
|
||||
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
|
||||
{
|
||||
m.remove(k);
|
||||
}
|
||||
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
|
||||
}
|
||||
// Features repeated many times; deep nesting; not JSON at all.
|
||||
let mut v = doc.clone();
|
||||
if let Some(Value::Array(fs)) = v.get_mut("features") {
|
||||
let copy = fs.clone();
|
||||
for _ in 0..2 {
|
||||
fs.extend(copy.iter().cloned());
|
||||
}
|
||||
}
|
||||
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
||||
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
||||
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
||||
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
||||
}
|
||||
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let failures: Vec<String> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut failures = Vec::new();
|
||||
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
failures
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! Seed ranges must not crash or wrap, including at the last representable seed.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_seed_runs_once_without_wrapping() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,"));
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//! An explicit help request prints usage to stdout and succeeds; an unknown command still fails.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_succeeds() {
|
||||
for arg in ["--help", "-h", "help"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg(arg).output().unwrap();
|
||||
assert!(output.status.success(), "{arg}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.starts_with("usage:"), "{arg}: {stdout}");
|
||||
assert!(stdout.contains("solidcraft-cli run"), "{arg}: {stdout}");
|
||||
assert!(output.stderr.is_empty(), "{arg}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_still_fails_with_usage() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--not-a-command").output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).starts_with("error: usage:"));
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes
|
||||
//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a
|
||||
//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A box, and a parameter whose comment has a character outside ASCII.
|
||||
fn script(&self) -> PathBuf {
|
||||
let path = self.0.join("box.json");
|
||||
let script = json!({"commands": [
|
||||
{"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}},
|
||||
{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
|
||||
]});
|
||||
std::fs::write(&path, script.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// A filleted box whose fillet a later parameter change makes impossible: every command
|
||||
/// succeeds, the timeline ends with a failing feature.
|
||||
fn broken_script(&self) -> PathBuf {
|
||||
let path = self.0.join("broken.json");
|
||||
let script = json!({"commands": [
|
||||
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
|
||||
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
|
||||
]});
|
||||
std::fs::write(&path, script.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn stdout_of(output: Output) -> Vec<u8> {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
output.stdout
|
||||
}
|
||||
|
||||
/// Timings differ from one run to the next.
|
||||
fn without_timings(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
map.remove("ms");
|
||||
map.values_mut().for_each(without_timings);
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(without_timings),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file holds what stdout carried in the same command without `--json-out`: the same JSON,
|
||||
/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict
|
||||
/// reader accepts it.
|
||||
fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value {
|
||||
let bytes = std::fs::read(file).unwrap();
|
||||
assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark");
|
||||
assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline");
|
||||
let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count();
|
||||
assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout");
|
||||
let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
|
||||
let mut printed: Value = serde_json::from_slice(stdout).unwrap();
|
||||
without_timings(&mut value);
|
||||
without_timings(&mut printed);
|
||||
assert_eq!(value, printed, "the file is what stdout would have carried");
|
||||
value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_results_json_out_holds_what_stdout_would() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let file = dir.0.join("results.json");
|
||||
let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap());
|
||||
let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout");
|
||||
let results = assert_plain_utf8_json(&file, &on_stdout);
|
||||
assert_eq!(results.as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_out_without_results_holds_the_measurement_even_when_quiet() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap());
|
||||
for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] {
|
||||
let file = dir.0.join(format!("measure-{name}.json"));
|
||||
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
|
||||
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "{name}");
|
||||
let measure = assert_plain_utf8_json(&file, &on_stdout);
|
||||
assert_eq!(measure["body_count"], 1, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_json_out_holds_what_stdout_would() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap());
|
||||
// The option may come before the file to evaluate, and `inspect` is the same command.
|
||||
for name in ["eval", "inspect"] {
|
||||
let file = dir.0.join(format!("{name}.json"));
|
||||
let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap();
|
||||
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "{name}");
|
||||
let measured = assert_plain_utf8_json(&file, &on_stdout);
|
||||
assert_eq!(measured["body_count"], 1, "{name}");
|
||||
// A character outside ASCII is written as it is, in UTF-8.
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
for cmd in ["run", "eval"] {
|
||||
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{cmd}");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
|
||||
assert!(output.stdout.is_empty(), "{cmd}");
|
||||
|
||||
let missing = dir.0.join("missing").join("out.json");
|
||||
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{cmd}");
|
||||
assert!(!output.stderr.is_empty(), "{cmd}");
|
||||
assert!(output.stdout.is_empty(), "{cmd}");
|
||||
assert!(!missing.exists(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_out_is_not_written_when_a_feature_fails() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.broken_script();
|
||||
for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] {
|
||||
let file = dir.0.join(format!("failed-{name}.json"));
|
||||
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{name}");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}");
|
||||
assert!(!file.exists(), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_forwards_with_a_deprecation_note() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_passes_the_exit_status_through() {
|
||||
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
|
||||
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
|
||||
assert_eq!(alias.status.code(), real.status.code());
|
||||
assert_eq!(alias.stdout, real.stdout);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//! `mcp --connect` to a port where no app listens yet (#270): the server still starts, its tools
|
||||
//! say how to start the app, and once the app runs they reach it with that launch's token.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
|
||||
// This test needs the port closed until its second phase. A port obtained
|
||||
// with bind(:0) and released can immediately be reused by another parallel
|
||||
// test's mock server. Stay below the usual ephemeral ranges,
|
||||
// with a process-specific starting point, and skip any occupied ports.
|
||||
let port = (0..512u32)
|
||||
.find_map(|offset| {
|
||||
let port = 16000 + (std::process::id() + offset) % 16000;
|
||||
TcpListener::bind(("127.0.0.1", port as u16)).ok().map(|listener| listener.local_addr().unwrap().port())
|
||||
})
|
||||
.expect("an unused non-ephemeral test port");
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
|
||||
std::fs::create_dir_all(&control_dir).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &addr, "--allow-any-path"])
|
||||
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
|
||||
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut ask = |id: u64, method: &str, params: Value| -> Value {
|
||||
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).unwrap();
|
||||
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"))
|
||||
};
|
||||
let measure = json!({"name": "measure", "arguments": {}});
|
||||
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||||
|
||||
// The server answers without the app, and its instructions say the app isn't running.
|
||||
let init = ask(1, "initialize", json!({"protocolVersion": "2025-06-18"}));
|
||||
let instructions = init["result"]["instructions"].as_str().unwrap_or_default();
|
||||
assert!(instructions.contains("not running") && instructions.contains(&format!("--control {port}")), "{instructions}");
|
||||
let tools = ask(2, "tools/list", json!({}));
|
||||
assert!(tools["result"]["tools"].as_array().is_some_and(|t| !t.is_empty()), "{tools}");
|
||||
|
||||
// A tool call says the app is not running and how to start it.
|
||||
let r = ask(3, "tools/call", measure.clone());
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert!(text(&r).contains("not running") && text(&r).contains(&format!("solidcraft --control {port}")), "{r}");
|
||||
|
||||
// The app starts: a token file for this launch, and its control port.
|
||||
let token = "0123456789abcdef".repeat(4);
|
||||
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
|
||||
let listener = TcpListener::bind(&addr).unwrap();
|
||||
let app = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let reply = if req["token"] == json!(token) {
|
||||
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
|
||||
} else {
|
||||
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
|
||||
};
|
||||
writeln!(&stream, "{reply}").unwrap();
|
||||
});
|
||||
let r = ask(4, "tools/call", measure);
|
||||
assert_ne!(r["result"]["isError"], true, "{r}");
|
||||
assert!(text(&r).contains("engine.execute"), "{r}");
|
||||
app.join().unwrap();
|
||||
|
||||
drop(stdin);
|
||||
assert!(child.wait().unwrap().success());
|
||||
std::fs::remove_dir_all(&control_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_a_port_is_refused_at_start() {
|
||||
for addr in ["localhost", "127.0.0.1:0"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--connect", addr]).stdin(Stdio::null()).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{addr}: {stderr}");
|
||||
assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//! `mcp --in script --param-check <level>`: the level applies to the script the session starts
|
||||
//! from too, so a misspelled key in it is refused (strict) or reported on stderr (warn) instead of
|
||||
//! silently ignored.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn param_check_applies_to_the_starting_script() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-param-check-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("typo.json");
|
||||
let box_ = r#"{"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}}"#;
|
||||
std::fs::write(&script, format!(r#"{{"commands": [{box_}]}}"#)).unwrap();
|
||||
let run = |level: &str| {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--in", script.to_str().unwrap(), "--param-check", level])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let strict = run("strict");
|
||||
let stderr = String::from_utf8_lossy(&strict.stderr);
|
||||
assert!(!strict.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("unknown key `operaton`; did you mean `operation`?"), "{stderr}");
|
||||
// Warn: the script runs, and its warnings go to stderr.
|
||||
let warn = run("warn");
|
||||
let stderr = String::from_utf8_lossy(&warn.stderr);
|
||||
assert!(warn.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{stderr}");
|
||||
// A warned step's warning is printed even when a later step fails.
|
||||
let failing = dir.join("typo_then_failure.json");
|
||||
std::fs::write(&failing, format!(r#"{{"commands": [{box_}, {{"command": "no.such_command"}}]}}"#)).unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--in", failing.to_str().unwrap(), "--param-check", "warn"])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("step 2"), "{stderr}");
|
||||
// Off: the script runs as before, and the server ends with its input.
|
||||
let off = run("off");
|
||||
assert!(off.status.success(), "{}", String::from_utf8_lossy(&off.stderr));
|
||||
assert!(!String::from_utf8_lossy(&off.stderr).contains("operaton"));
|
||||
// A bare flag is an error, not off.
|
||||
let bare = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--param-check"]).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!bare.status.success());
|
||||
assert!(String::from_utf8_lossy(&bare.stderr).contains("--param-check wants off, warn or strict"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
//! A design opened from the model its file keeps is the design evaluated again: the same
|
||||
//! results, the same bodies (to the bit), the same face names and volumes after every feature,
|
||||
//! and the same after an edit made to both.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::{ModelBody, ModelState};
|
||||
|
||||
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();
|
||||
v
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("solidcraft-model-cache-{}-{name}.solidcraft", std::process::id()))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn body(name: &str, b: &ModelBody) -> Value {
|
||||
let measured = solidcraft_engine::kernel::measure(&b.body).ok();
|
||||
json!({
|
||||
"name": b.name, "feature": b.feature, "component": b.component, "source": b.source_name, "transferred": b.transferred,
|
||||
"brep": solidcraft_engine::kernel::encode_native_body(&b.body).map(|bytes| format!("{:x}", sha(&bytes))).unwrap_or_else(|e| format!("{name}: {e}")),
|
||||
"faces": *solidcraft_engine::doc::naming::face_names(b),
|
||||
"volume": measured.as_ref().map(|m| m.volume.to_bits()), "area": measured.as_ref().map(|m| m.area.to_bits()),
|
||||
"look": b.imported.as_ref().map(|l| format!("{l:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn sha(bytes: &[u8]) -> u128 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
bytes.hash(&mut h);
|
||||
let a = h.finish();
|
||||
bytes.len().hash(&mut h);
|
||||
(u128::from(a) << 64) | u128::from(h.finish())
|
||||
}
|
||||
|
||||
fn state(name: &str, st: &Arc<ModelState>) -> Value {
|
||||
json!({
|
||||
"bodies": st.bodies.iter().map(|b| body(name, b)).collect::<Vec<_>>(),
|
||||
"sketches": st.sketches.iter().map(|s| json!([s.feature, s.name, s.plane, s.sketch, s.report, s.profiles.len()])).collect::<Vec<_>>(),
|
||||
"counter": st.body_counter, "sheets": st.sheets, "threads": st.threads, "construct": format!("{:?}", st.construct),
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything a model holds that the rest of the app reads.
|
||||
fn model(name: &str, s: &Session) -> Value {
|
||||
let results: Vec<Value> = s
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "name": r.name, "error": r.error, "warning": r.warning, "skipped": r.skipped, "state": state(name, &r.state)}))
|
||||
.collect();
|
||||
json!({"results": results, "world": state(name, &s.world_state())})
|
||||
}
|
||||
|
||||
/// The same edit on both: a parameter's value when the design has one, else a new body.
|
||||
fn edit(s: &mut Session) {
|
||||
let param = s.doc.params.first().map(|p| (p.name.clone(), p.expr.clone()));
|
||||
match param {
|
||||
Some((name, expr)) => {
|
||||
s.execute("parameters.change", &json!({"name": name, "expression": format!("({expr}) * 1.05")})).unwrap();
|
||||
}
|
||||
None => {
|
||||
s.execute("solid.box", &json!({"length": 7, "width": 8, "height": 9, "operation": "new"})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(name: &str, mut built: Session) {
|
||||
let path = temp(name);
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the save keeps the model");
|
||||
let mut cached = open(&path);
|
||||
assert_eq!(cached.model.last_recomputed, 0, "{name}: opened without evaluating");
|
||||
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
|
||||
assert!(rebuilt.model.last_recomputed > 0);
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
|
||||
edit(&mut cached);
|
||||
edit(&mut rebuilt);
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after the same edit");
|
||||
cached.execute("edit.undo", &json!({})).unwrap();
|
||||
rebuilt.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after undoing it");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_open_from_their_model_as_evaluated() {
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = open(&p);
|
||||
// Names saved before format 14 are found again through names worked out from geometry on
|
||||
// demand, which a saved model does not keep.
|
||||
let legacy = s.doc.to_json().contains(&format!("\"{}", solidcraft_engine::doc::naming::LEGACY_MARK));
|
||||
if s.model.results.iter().any(|r| r.error.is_some()) || legacy {
|
||||
// Not kept: a model with errors, or names saved before format 14.
|
||||
let path = temp(&name);
|
||||
let mut s = s;
|
||||
s.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&std::fs::read(&path).unwrap()).is_none(), "{name}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
continue;
|
||||
}
|
||||
check(&name, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_designs_open_from_their_model_as_evaluated() {
|
||||
for sample in solidcraft_engine::sample::samples() {
|
||||
let mut s = Session::default();
|
||||
for c in (sample.script)()["commands"].as_array().unwrap() {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap_or_else(|e| panic!("{}: {e}", sample.name));
|
||||
}
|
||||
check(sample.name, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_assemblies_open_from_their_model_as_evaluated() {
|
||||
// A STEP assembly of the hinge sample: parts placed several times.
|
||||
let mut source = Session::default();
|
||||
let hinge = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Hinge Assembly").unwrap();
|
||||
for c in (hinge.script)()["commands"].as_array().unwrap() {
|
||||
source.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let step = std::env::temp_dir().join(format!("solidcraft-model-cache-{}-hinge.step", std::process::id()));
|
||||
source.execute("file.export", &json!({"path": step.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("file.insert_step", &json!({"path": step.to_string_lossy()})).unwrap();
|
||||
s.execute("file.insert_step", &json!({"path": step.to_string_lossy(), "matrix": [1, 0, 0, 200, 0, 1, 0, 0, 0, 0, 1, 0]})).unwrap();
|
||||
let _ = std::fs::remove_file(&step);
|
||||
check("imported hinge", s);
|
||||
}
|
||||
|
||||
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
|
||||
/// saved states before it are kept as they were read (the same states, not evaluated again).
|
||||
#[test]
|
||||
fn an_edit_after_opening_evaluates_from_the_feature_it_changes() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let path = temp("incremental");
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let n = built.doc.features.len();
|
||||
assert!(n >= 4);
|
||||
let mut s = open(&path);
|
||||
assert_eq!(s.model.last_recomputed, 0);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
// How many of the first features still have the states read from the file.
|
||||
let kept = |s: &Session| s.model.results.iter().zip(&read).take_while(|(r, st)| Arc::ptr_eq(&r.state, st)).count();
|
||||
// The marker moved back and forth: the features before it are as read.
|
||||
s.execute("timeline.rollback", &json!({"position": n - 2})).unwrap();
|
||||
assert_eq!(kept(&s), n - 2, "rolled back");
|
||||
s.execute("timeline.rollback", &json!({})).unwrap();
|
||||
assert_eq!(kept(&s), n, "rolled forward: the features after the marker take their saved results again");
|
||||
// Suppressing the second-to-last feature keeps every state before it.
|
||||
let k = s.doc.features[n - 2].id;
|
||||
s.execute("timeline.suppress", &json!({"feature": k, "suppressed": true})).unwrap();
|
||||
assert_eq!(kept(&s), n - 2, "evaluated from the suppressed feature on");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A new feature at the end starts from the final state read from the file.
|
||||
let mut s = open(&path);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
|
||||
assert_eq!(s.model.results.len(), n + 1);
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "only the new feature evaluated");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
/// An edit made after opening and undone finds the saved results again: undo evaluates nothing.
|
||||
#[test]
|
||||
fn undoing_an_edit_after_opening_reuses_the_saved_results() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let path = temp("undo");
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut s = open(&path);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
let first = s.doc.features[1].id;
|
||||
s.execute("timeline.suppress", &json!({"feature": first, "suppressed": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.last_recomputed, 0, "taken from the saved results");
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "the very states read from the file");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! The reference tests (tests/oracle, docs/oracle.md) as a `cargo test`, so tools that drive
|
||||
//! `cargo test` (cargo-mutants) can use them: every case with expected values and no
|
||||
//! `known_failure` mark must pass, as `cargo xtask oracle` requires.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by cargo-mutants (.cargo/mutants.toml); CI replays the oracle through cargo xtask oracle"]
|
||||
fn oracle_suite() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let dir = root.join("tests/oracle");
|
||||
let mut cases: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.join("recipe.json").is_file())
|
||||
.map(|p| format!("tests/oracle/{}", p.file_name().unwrap().to_string_lossy()))
|
||||
.collect();
|
||||
cases.sort();
|
||||
assert!(cases.len() >= 50, "oracle cases in {}", dir.display());
|
||||
// One process per core, cases dealt round-robin so the slow ones spread out.
|
||||
let n = std::thread::available_parallelism().map_or(4, |n| n.get()).min(cases.len());
|
||||
let children: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let chunk: Vec<&String> = cases.iter().skip(i).step_by(n).collect();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.current_dir(&root)
|
||||
.arg("oracle")
|
||||
.arg("--json")
|
||||
.args(chunk)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
for child in children {
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let part: Vec<Value> =
|
||||
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
results.extend(part);
|
||||
}
|
||||
assert_eq!(results.len(), cases.len());
|
||||
let mut failed = Vec::new();
|
||||
for r in &results {
|
||||
let case = r["case"].as_str().unwrap_or("");
|
||||
let want: Value =
|
||||
std::fs::read_to_string(dir.join(case).join("expected.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||||
if want.is_null() || want["known_failure"].is_string() || r["pass"].as_bool().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let checks: 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();
|
||||
failed.push(format!("{case}: {} {}", r["error"].as_str().unwrap_or(""), checks.join("; ")));
|
||||
}
|
||||
assert!(failed.is_empty(), "oracle cases failed:\n{}", failed.join("\n"));
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
//! A parameter change gives the same topology as building at the new value from scratch, and
|
||||
//! changing it back gives the original topology: no stale splits, no faces added to regions
|
||||
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
|
||||
//! the README demo's Raspberry Pi case pinned step by step.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// A folder of its own for test `name` (tests run side by side).
|
||||
fn scratch_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Topology {
|
||||
faces: usize,
|
||||
edges: usize,
|
||||
planes: BTreeMap<[i64; 4], usize>,
|
||||
}
|
||||
|
||||
/// Every solid body's topology, by name.
|
||||
fn topology(s: &Session) -> BTreeMap<String, Topology> {
|
||||
let st = s.world_state();
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.map(|b| {
|
||||
let mut planes = BTreeMap::new();
|
||||
for i in 0..b.body.face_count() {
|
||||
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
|
||||
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
|
||||
*planes.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
|
||||
/// opened again.
|
||||
fn rebuilt(s: &Session, path: &Path) -> Session {
|
||||
let mut t = s.scratch();
|
||||
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
|
||||
open(path)
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<u64> {
|
||||
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
|
||||
}
|
||||
|
||||
/// The corpus's designs, as sessions.
|
||||
fn designs() -> Vec<(String, Session)> {
|
||||
let root = root();
|
||||
let mut out = Vec::new();
|
||||
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
|
||||
open(&p)
|
||||
} else {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
|
||||
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
if s.active_sketch.is_some() {
|
||||
let _ = s.execute("sketch.finish", &json!({}));
|
||||
}
|
||||
s
|
||||
};
|
||||
out.push((name, s));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
|
||||
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
|
||||
/// original topology.
|
||||
#[test]
|
||||
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
|
||||
let dir = scratch_dir("corpus");
|
||||
let mut problems = Vec::new();
|
||||
let mut checked = 0;
|
||||
for (name, s) in designs() {
|
||||
let original = topology(&s);
|
||||
let before = errors(&s);
|
||||
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
|
||||
params.sort_by_key(|p| p.model);
|
||||
let mut tried = 0;
|
||||
for p in params {
|
||||
if tried == 1 {
|
||||
break;
|
||||
}
|
||||
let mut t = s.scratch();
|
||||
let expr = format!("({}) * 1.1", p.expr);
|
||||
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
checked += 1;
|
||||
let edited = topology(&t);
|
||||
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
|
||||
if edited != fresh {
|
||||
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
|
||||
}
|
||||
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
|
||||
let back = topology(&t);
|
||||
if back != original {
|
||||
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(checked >= 8, "only {checked} parameter edits checked");
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
|
||||
/// face count after each feature, and each outer side (the four walls and the bottom) one face
|
||||
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
|
||||
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
|
||||
/// wall at 24) into strips along the openings' tops and bottoms.
|
||||
#[test]
|
||||
fn pi_case_faces_per_step_at_both_heights() {
|
||||
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
|
||||
const FACES: [(usize, usize); 13] =
|
||||
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
|
||||
let dir = scratch_dir("pi");
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
|
||||
let at = |height: &str, file: &str| {
|
||||
let mut d = design.clone();
|
||||
for p in d["params"].as_array_mut().unwrap() {
|
||||
if p["name"] == "height" {
|
||||
p["expr"] = json!(height);
|
||||
}
|
||||
}
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
open(&path)
|
||||
};
|
||||
let a = at("24 mm", "pi-24.solidcraft");
|
||||
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
|
||||
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
|
||||
let c = at("32 mm", "pi-32.solidcraft");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut per_case = Vec::new();
|
||||
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
|
||||
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
|
||||
let mut steps = Vec::new();
|
||||
for (k, want) in FACES {
|
||||
let mut t = s.scratch();
|
||||
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
|
||||
let st = t.world_state();
|
||||
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
|
||||
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
|
||||
// Each outer side: the faces in its plane.
|
||||
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
|
||||
let on = (0..body.body.face_count())
|
||||
.filter(|&i| match face_surface(&body.body, i) {
|
||||
Some(FaceSurface::Plane { normal, point }) => {
|
||||
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count();
|
||||
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
|
||||
}
|
||||
let bbox_top = body.mesh().bounds().max.z;
|
||||
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
|
||||
steps.push(topology(&t));
|
||||
}
|
||||
per_case.push((case, steps));
|
||||
}
|
||||
// (b) and (c): the same topology after every feature.
|
||||
let (b, c) = (&per_case[1].1, &per_case[2].1);
|
||||
for (k, (x, y)) in b.iter().zip(c).enumerate() {
|
||||
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
//! Result reporting must not skip requested files or execute a script twice.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
fn script(&self) -> PathBuf {
|
||||
let path = self.0.join("box.json");
|
||||
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn body_count(path: &Path) -> Value {
|
||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_loads_both_design_encodings_by_contents() {
|
||||
let dir = Scratch::new();
|
||||
for extension in ["solidcraft", "solvecraft", "json"] {
|
||||
let saved = dir.0.join(format!("box.{extension}"));
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
assert_eq!(solidcraft_engine::io::is_design_container(&bytes), extension != "json");
|
||||
let renamed = dir.0.join(format!("{extension}.unusual"));
|
||||
std::fs::write(&renamed, bytes).unwrap();
|
||||
let measured = json_output(cli().arg("eval").arg(&renamed).output().unwrap());
|
||||
assert_eq!(measured["body_count"], 1);
|
||||
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_still_save_and_export_the_script_once() {
|
||||
let dir = Scratch::new();
|
||||
// JSON is explicit here because the fixture edits its saved format tag.
|
||||
let saved = dir.0.join("box.json");
|
||||
let stl = dir.0.join("box.stl");
|
||||
let obj = dir.0.join("box.obj");
|
||||
let result = json_output(
|
||||
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
|
||||
);
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
for exported in [&stl, &obj] {
|
||||
assert!(std::fs::metadata(exported).unwrap().len() > 0);
|
||||
assert_eq!(body_count(exported), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
let saved = dir.0.join("combined.solidcraft");
|
||||
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
|
||||
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 2);
|
||||
assert_eq!(body_count(&base), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_propagate_output_errors() {
|
||||
let dir = Scratch::new();
|
||||
for flag in ["--save", "--out"] {
|
||||
let path = dir.0.join("missing").join(if flag == "--save" { "box.solidcraft" } else { "box.stl" });
|
||||
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!output.stderr.is_empty());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_without_script_still_fail() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
|
||||
}
|
||||
|
||||
/// Compatibility shim (docs/compatibility.md): a design saved before the rename, with the
|
||||
/// legacy extension and format tag, still opens; re-saving writes the current tag.
|
||||
#[test]
|
||||
fn legacy_extension_and_format_tag_still_open() {
|
||||
let dir = Scratch::new();
|
||||
// JSON is explicit here because the fixture edits its saved format tag.
|
||||
let saved = dir.0.join("box.json");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||
let text = std::fs::read_to_string(&saved).unwrap();
|
||||
assert!(text.contains("\"solidcraft/"), "new designs carry the current tag");
|
||||
let legacy = dir.0.join("old.solvecraft");
|
||||
std::fs::write(&legacy, text.replace("\"solidcraft/", "\"solvecraft/")).unwrap();
|
||||
assert_eq!(body_count(&legacy), 1);
|
||||
let resaved = dir.0.join("resaved.solidcraft");
|
||||
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
|
||||
assert_eq!(body_count(&resaved), 2);
|
||||
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
|
||||
assert!(reopened.to_json().contains("\"solidcraft/"));
|
||||
}
|
||||
|
||||
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
|
||||
/// option it doesn't take rather than ignoring it.
|
||||
#[test]
|
||||
fn snapshot_runs_its_script_on_the_in_design() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let shifted = dir.0.join("shifted.json");
|
||||
std::fs::write(
|
||||
&shifted,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let shot = |name: &str, args: &[&std::ffi::OsStr]| {
|
||||
let png = dir.0.join(name);
|
||||
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
|
||||
std::fs::read(&png).unwrap()
|
||||
};
|
||||
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
|
||||
match solidcraft_gpu::capture::shared() {
|
||||
Ok(_) => {
|
||||
let alone = shot("alone.png", &[shifted.as_os_str()]);
|
||||
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
|
||||
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
|
||||
assert_ne!(alone, on_base, "the script ran on the --in design");
|
||||
assert_ne!(base_only, on_base);
|
||||
}
|
||||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("pictures left out: {e}")
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
|
||||
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains(says), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown command's error suggests the id that was meant.
|
||||
#[test]
|
||||
fn unknown_command_suggests_the_closest_id() {
|
||||
let output = cli().args(["exec", "file.open"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
|
||||
/// the kernel's share, `--results` puts the results beside them, and files are still written.
|
||||
#[test]
|
||||
fn timings_and_profile_report_each_command() {
|
||||
let dir = Scratch::new();
|
||||
let saved = dir.0.join("timed.solidcraft");
|
||||
let t = json_output(cli().arg("run").arg(dir.script()).arg("--timings").arg("--save").arg(&saved).output().unwrap());
|
||||
let rows = t["timings"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!((rows[0]["index"].as_u64(), rows[0]["command"].as_str()), (Some(0), Some("solid.box")));
|
||||
assert!(rows[0]["ms"].as_f64().is_some() && t["total_ms"].as_f64().is_some(), "{t}");
|
||||
assert!(rows[0].get("profile").is_none() && t.get("results").is_none(), "{t}");
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
|
||||
// --profile alone implies --timings; a flag before the script doesn't eat it.
|
||||
let t = json_output(cli().arg("run").arg("--profile").arg(dir.script()).arg("--results").output().unwrap());
|
||||
let p = &t["timings"][0]["profile"];
|
||||
for k in ["boolean_ms", "tessellate_ms", "other_ms"] {
|
||||
assert!(p[k].as_f64().is_some_and(|ms| ms >= 0.0), "{k}: {t}");
|
||||
}
|
||||
assert_eq!(t["results"].as_array().map(Vec::len), Some(1), "{t}");
|
||||
|
||||
// --results alone stays a plain array.
|
||||
assert!(json_output(cli().arg("run").arg(dir.script()).arg("--results").output().unwrap()).is_array());
|
||||
|
||||
let output = cli().arg("run").arg("--in").arg(&saved).arg("--timings").output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! A timeline feature that fails fails `run`, and nothing is written: the bodies lack what the
|
||||
//! feature should have made, and handing them out with exit 0 hides the failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-run-strict-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A filleted box whose fillet a later parameter change makes impossible: every command
|
||||
/// succeeds, the timeline ends with a failing feature.
|
||||
fn broken_script(&self) -> PathBuf {
|
||||
let path = self.0.join("broken.json");
|
||||
let script = json!({"commands": [
|
||||
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
|
||||
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
|
||||
]});
|
||||
std::fs::write(&path, script.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_feature_fails_the_run_and_writes_nothing() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.broken_script();
|
||||
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
|
||||
for extra in [&[][..], &["--results"][..], &["--quiet"][..]] {
|
||||
let output = cli().arg("run").arg(&script).args(extra).arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Fillet1") && stderr.contains("nothing was written"), "{stderr}");
|
||||
assert!(output.stdout.is_empty(), "{extra:?}");
|
||||
assert!(!step.exists() && !saved.exists(), "{extra:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_errors_writes_anyway_and_a_saved_design_keeps_failing() {
|
||||
let dir = Scratch::new();
|
||||
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
|
||||
let output = cli().arg("run").arg(dir.broken_script()).arg("--allow-errors").arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let measured: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(measured["total"]["volume_mm3"], json!(4000.0), "the box without its fillet");
|
||||
assert!(std::fs::metadata(&step).unwrap().len() > 0);
|
||||
// The saved design still holds the failing feature: running it fails too, `eval` reports it.
|
||||
let again = dir.0.join("again.step");
|
||||
let output = cli().arg("run").arg(&saved).arg("--out").arg(&again).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!again.exists());
|
||||
let output = cli().arg("eval").arg(&saved).output().unwrap();
|
||||
assert!(output.status.success());
|
||||
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert!(report["timeline"].as_array().unwrap().iter().any(|f| f["error"].is_string()), "{}", report["timeline"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_timeline_still_writes() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.0.join("box.json");
|
||||
std::fs::write(&script, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
let step = dir.0.join("box.step");
|
||||
let output = cli().arg("run").arg(&script).arg("--out").arg(&step).arg("--quiet").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(std::fs::metadata(&step).unwrap().len() > 0);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
//! A design's command script (`timeline.export_script`, #886) replays into the same design:
|
||||
//! every corpus design that the script supports comes back with the same timeline, parameters,
|
||||
//! settings and bodies.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
||||
v
|
||||
}
|
||||
|
||||
/// Designs with components or joints, which scripts do not support yet.
|
||||
const ASSEMBLIES: [&str; 2] = ["components-arm", "joints-hinge"];
|
||||
|
||||
fn bodies(s: &mut Session) -> Vec<(String, f64, f64, Value)> {
|
||||
let m = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
let mut v: Vec<_> = m["bodies"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| {
|
||||
(
|
||||
b["name"].as_str().unwrap_or_default().to_string(),
|
||||
b["volume_mm3"].as_f64().unwrap(),
|
||||
b["area_mm2"].as_f64().unwrap(),
|
||||
b["faces"].clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<(u64, Option<String>)> {
|
||||
s.model.results.iter().map(|r| (r.id, r.error.clone())).collect()
|
||||
}
|
||||
|
||||
fn params(s: &Session) -> Vec<solidcraft_engine::doc::Param> {
|
||||
let mut v = s.doc.params.clone();
|
||||
v.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_replay_from_their_scripts() {
|
||||
let mut replayed = 0;
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let r = match s.execute("timeline.export_script", &json!({})) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
assert!(ASSEMBLIES.contains(&name.as_str()), "{name}: {e}");
|
||||
assert!(e.to_string().contains("not supported yet"), "{name}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
assert!(!ASSEMBLIES.contains(&name.as_str()), "{name} has components: the script should refuse it");
|
||||
// Through text, as a script file would carry it.
|
||||
let script: Value = serde_json::from_str(&r["script"].to_string()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(t.doc.features, s.doc.features, "{name}: timeline");
|
||||
assert_eq!(params(&t), params(&s), "{name}: parameters");
|
||||
assert_eq!(t.doc.marker, s.doc.marker, "{name}: marker");
|
||||
assert_eq!(t.doc.units, s.doc.units, "{name}: units");
|
||||
assert_eq!(t.doc.sheet, s.doc.sheet, "{name}: sheet metal rules");
|
||||
assert_eq!(t.doc.plastic, s.doc.plastic, "{name}: plastic rules");
|
||||
assert_eq!(t.doc.materials, s.doc.materials, "{name}: materials");
|
||||
assert_eq!(t.doc.user_materials, s.doc.user_materials, "{name}: user materials");
|
||||
assert_eq!(t.doc.favorites, s.doc.favorites, "{name}: favourites");
|
||||
assert_eq!(t.doc.param_comments, s.doc.param_comments, "{name}: comments");
|
||||
assert_eq!(errors(&t), errors(&s), "{name}: feature results");
|
||||
assert_eq!(bodies(&mut t), bodies(&mut s), "{name}: bodies");
|
||||
replayed += 1;
|
||||
}
|
||||
assert!(replayed >= 7, "replayed {replayed} corpus designs");
|
||||
}
|
||||
|
||||
/// #1990: a sketch and its extrude may share a name, and the design's script still replays,
|
||||
/// keeping the names: the replayed design's own script has the same steps.
|
||||
#[test]
|
||||
fn a_design_naming_a_sketch_and_its_extrude_alike_replays_from_its_script() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-shared-names-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cli = |args: &[&std::ffi::OsStr]| {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}: {}",
|
||||
args.iter().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" "),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
let script = dir.join("mini.json");
|
||||
let saved = dir.join("mini.solidcraft");
|
||||
let exported = dir.join("rt.json");
|
||||
let replayed = dir.join("rt.solidcraft");
|
||||
let again = dir.join("rt2.json");
|
||||
let commands = json!({"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Block"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 10]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "5", "name": "Block"}},
|
||||
]});
|
||||
std::fs::write(&script, commands.to_string()).unwrap();
|
||||
cli(&["run".as_ref(), script.as_ref(), "--save".as_ref(), saved.as_ref()]);
|
||||
cli(&["script".as_ref(), saved.as_ref(), "--out".as_ref(), exported.as_ref()]);
|
||||
cli(&["run".as_ref(), exported.as_ref(), "--save".as_ref(), replayed.as_ref()]);
|
||||
cli(&["script".as_ref(), replayed.as_ref(), "--out".as_ref(), again.as_ref()]);
|
||||
let first: Value = serde_json::from_str(&std::fs::read_to_string(&exported).unwrap()).unwrap();
|
||||
let second: Value = serde_json::from_str(&std::fs::read_to_string(&again).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let names: Vec<&str> = first["commands"].as_array().unwrap().iter().filter_map(|c| c["params"]["feature"]["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Block", "Block"], "{first:#}");
|
||||
assert_eq!(second["commands"], first["commands"], "the replayed design's script has the same steps");
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
//! `snapshot --body`, `--tool` and `--section`: a picture of a tool body inside the material it
|
||||
//! meets, which is the one picture that says whether a cut reaches through.
|
||||
//!
|
||||
//! Each option is a shortcut for the command the app itself runs (`appearance.assign`,
|
||||
//! `inspect.section`), so every test here asserts that the option's picture is the picture of a
|
||||
//! script that runs those commands — not that the pixels match a stored image. Pictures are
|
||||
//! compared with a tolerance, and the tests run on whatever renderer the machine has (a GPU, a
|
||||
//! software adapter, or the CPU rasterizer), because they only ever compare two pictures this
|
||||
//! same CLI drew.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
const SIZE: (u32, u32) = (200, 140);
|
||||
|
||||
/// A PNG's size and RGBA pixels.
|
||||
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
|
||||
(img.width(), img.height(), img.into_raw())
|
||||
}
|
||||
|
||||
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
|
||||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||||
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
|
||||
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
|
||||
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
|
||||
}
|
||||
|
||||
/// The same picture: a renderer's output may differ by a few levels or a stray edge pixel.
|
||||
#[track_caller]
|
||||
fn assert_same(a: &[u8], b: &[u8], what: &str) {
|
||||
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
|
||||
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
|
||||
}
|
||||
|
||||
/// Different pictures: more than stray pixels differ.
|
||||
#[track_caller]
|
||||
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
|
||||
let n = differing(a, b);
|
||||
assert!(n >= 100, "{what}: only {n} pixels differ");
|
||||
}
|
||||
|
||||
/// How much of a picture is the tool colour (`preview::TOOL_COLOR`, #e8542c): a red neither
|
||||
/// the default body grey nor the background has, still recognisable once a see-through body in
|
||||
/// front of it has tinted it (measured on this machine: (200, 108, 85) through a target at
|
||||
/// opacity 0.3, against (164, 173, 184) for the plain grey).
|
||||
fn tool_pixels(png: &[u8]) -> usize {
|
||||
let (_, _, px) = decode(png);
|
||||
px.as_chunks::<4>().0.iter().filter(|p| p[0] > 140 && p[0] > p[1].saturating_add(50) && p[0] > p[2].saturating_add(50)).count()
|
||||
}
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("solidcraft-snapshot-appearance-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A plate with a pin standing in it: the tool body a cut would remove, reaching 6 mm short
|
||||
/// of the far face, which is exactly what a picture is supposed to show and a volume is not.
|
||||
/// `more` runs on top.
|
||||
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
|
||||
let mut steps = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 50, "width": 34, "height": 20, "center": [0, 0, 10], "body_name": "Target"}}),
|
||||
json!({"command": "solid.cylinder", "params": {"diameter": 12, "height": 14, "base": [0, 0, 0], "operation": "new_body", "body_name": "Tool"}}),
|
||||
];
|
||||
steps.extend(more.iter().cloned());
|
||||
let path = self.0.join(name);
|
||||
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
|
||||
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(self.0.join(out))
|
||||
.args(["--width", &w, "--height", &h, "--no-grid", "--no-axes"])
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The PNG `snapshot` writes.
|
||||
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
|
||||
let output = self.run(script, out, extra);
|
||||
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let png = std::fs::read(self.0.join(out)).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
|
||||
png
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `--body NAME:opacity=…` draws that body see-through, and the picture is the one a script
|
||||
/// gets by assigning the same appearance itself: the option is the command, not a second way
|
||||
/// of drawing.
|
||||
#[test]
|
||||
fn a_body_drawn_see_through_is_the_picture_of_the_same_appearance_assigned() {
|
||||
let dir = Scratch::new();
|
||||
let plain = dir.script("plain.json", &[]);
|
||||
let assigned =
|
||||
dir.script("assigned.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}})]);
|
||||
let solid = dir.snapshot(&plain, "solid.png", &[]);
|
||||
let see_through = dir.snapshot(&plain, "see-through.png", &["--body", "Target:opacity=0.3"]);
|
||||
assert_differs(&see_through, &solid, "--body opacity was ignored");
|
||||
assert_same(&see_through, &dir.snapshot(&assigned, "by-script.png", &[]), "the same appearance assigned by a script");
|
||||
|
||||
// The option's colour is the design's too. It names the target, not the tool: the tool is
|
||||
// inside the target, so a solid target hides whatever colour the tool is given.
|
||||
let painted =
|
||||
dir.script("painted.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#00aa33", "opacity": 1.0}})]);
|
||||
let by_option = dir.snapshot(&plain, "painted.png", &["--body", "Target:color=#00aa33"]);
|
||||
assert_differs(&by_option, &solid, "--body color was ignored");
|
||||
assert_same(&by_option, &dir.snapshot(&painted, "painted-script.png", &[]), "the same colour assigned by a script");
|
||||
}
|
||||
|
||||
/// `--tool NAME` is the whole point: the tool solid in the tool colour, every other body
|
||||
/// see-through, so the tool shows inside the material it meets. The tool's colour covers more
|
||||
/// of the picture than it does without the option, because the target no longer hides it.
|
||||
#[test]
|
||||
fn a_tool_shows_through_the_bodies_around_it() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let plain = dir.snapshot(&script, "plain.png", &["--view", "front"]);
|
||||
let preview = dir.snapshot(&script, "preview.png", &["--view", "front", "--tool", "Tool"]);
|
||||
assert_differs(&preview, &plain, "--tool was ignored");
|
||||
// Seen from the front the plate hides the pin completely; the preview shows all of it.
|
||||
assert_eq!(tool_pixels(&plain), 0, "nothing is the tool colour before the option");
|
||||
assert!(tool_pixels(&preview) > 200, "the tool shows through the target: {} pixels", tool_pixels(&preview));
|
||||
|
||||
// It is the two commands it stands for, run in that order.
|
||||
let by_script = dir.script(
|
||||
"by-script.json",
|
||||
&[
|
||||
json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}}),
|
||||
json!({"command": "appearance.assign", "params": {"bodies": ["Tool"], "color": "#e8542c", "opacity": 1.0}}),
|
||||
],
|
||||
);
|
||||
assert_same(&preview, &dir.snapshot(&by_script, "script.png", &["--view", "front"]), "the appearances --tool assigns");
|
||||
|
||||
// A later --body overrides what --tool said.
|
||||
let overridden = dir.snapshot(&script, "override.png", &["--view", "front", "--tool", "Tool", "--body", "Tool:color=#2255cc"]);
|
||||
assert!(tool_pixels(&overridden) < 50, "--body after --tool decides the tool's colour");
|
||||
}
|
||||
|
||||
/// `--section` cuts the model where `inspect.section` would, and the same spec written as the
|
||||
/// command's own JSON, or as the command in a script, gives the same picture.
|
||||
#[test]
|
||||
fn a_section_cuts_the_model_as_inspect_section_does() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let whole = dir.snapshot(&script, "whole.png", &[]);
|
||||
let cut = dir.snapshot(&script, "cut.png", &["--section", "z=12"]);
|
||||
assert_differs(&cut, &whole, "--section was ignored");
|
||||
assert_same(&cut, &dir.snapshot(&script, "cut-json.png", &["--section", r#"{"plane": "XY", "at": 12}"#]), "the command's own JSON");
|
||||
let by_script = dir.script("sectioned.json", &[json!({"command": "inspect.section", "params": {"plane": "XY", "at": 12}})]);
|
||||
assert_same(&cut, &dir.snapshot(&by_script, "by-script.png", &[]), "inspect.section in a script");
|
||||
|
||||
// The other side of the same plane is a different picture.
|
||||
let flipped = dir.snapshot(&script, "flipped.png", &["--section", "z=12,flip"]);
|
||||
assert_differs(&flipped, &cut, "flip keeps the other side");
|
||||
|
||||
// A section through a see-through target still shows the tool inside it.
|
||||
let both = dir.snapshot(&script, "both.png", &["--section", "z=12", "--tool", "Tool"]);
|
||||
assert_differs(&both, &cut, "--tool and --section together");
|
||||
assert!(tool_pixels(&both) > 100, "the tool is in the cut picture");
|
||||
}
|
||||
|
||||
/// Every way of getting the options wrong is an error that says what it takes, and writes no
|
||||
/// picture: a snapshot that quietly ignored an option would be a lie about the design.
|
||||
#[test]
|
||||
fn a_body_tool_or_section_that_cannot_be_used_is_an_error_and_writes_nothing() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let cases: [(&[&str], &str); 8] = [
|
||||
(&["--body", "Nowhere:opacity=0.3"], "no body `Nowhere`"),
|
||||
(&["--body", "Target"], "NAME:key=value"),
|
||||
(&["--body", "Target:opacity=3"], "0 to 1"),
|
||||
(&["--body", "Target:alpha=0.3"], "unknown key `alpha`"),
|
||||
(&["--body", "Target:color=red"], "#rrggbb"),
|
||||
(&["--tool", "Nowhere"], "no body `Nowhere`"),
|
||||
(&["--section", "q=1"], "unknown axis `q`"),
|
||||
(&["--section", "nonsense"], "z=27"),
|
||||
];
|
||||
for (extra, expect) in cases {
|
||||
let output = dir.run(&script, "never.png", extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
|
||||
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
|
||||
}
|
||||
// An error naming a body says which bodies the design has.
|
||||
let stderr = String::from_utf8_lossy(&dir.run(&script, "never.png", &["--tool", "Nowhere"]).stderr).to_string();
|
||||
assert!(stderr.contains("Target") && stderr.contains("Tool"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The usage text says what the options do.
|
||||
#[test]
|
||||
fn the_usage_names_the_body_tool_and_section_options() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for word in ["--body", "--tool", "--section", "appearance.assign", "inspect.section", "see-through"] {
|
||||
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
|
||||
//! design or one the script applies itself, and without the ground grid and the origin's axes
|
||||
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). Pictures are drawn
|
||||
//! on the GPU and compared with a tolerance; without a GPU adapter the tests that draw are skipped
|
||||
//! with the reason printed (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_mcp::{Headless, Server};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP
|
||||
/// `screenshot` in this process draws as `solidcraft-cli mcp` does.
|
||||
fn gpu() -> bool {
|
||||
match solidcraft_gpu::capture::shared() {
|
||||
Ok(c) => {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
|
||||
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
|
||||
}));
|
||||
true
|
||||
}
|
||||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("skipping: {e}");
|
||||
false
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's size and RGBA pixels.
|
||||
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
|
||||
(img.width(), img.height(), img.into_raw())
|
||||
}
|
||||
|
||||
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
|
||||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||||
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
|
||||
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
|
||||
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
|
||||
}
|
||||
|
||||
/// The same picture: GPU output may differ by a few levels or a stray edge pixel between drivers.
|
||||
#[track_caller]
|
||||
fn assert_same(a: &[u8], b: &[u8], what: &str) {
|
||||
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
|
||||
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
|
||||
}
|
||||
|
||||
/// Different pictures: more than stray pixels differ.
|
||||
#[track_caller]
|
||||
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
|
||||
let n = differing(a, b);
|
||||
assert!(n >= 20, "{what}: only {n} pixels differ");
|
||||
}
|
||||
|
||||
const SIZE: (u32, u32) = (160, 100);
|
||||
|
||||
fn camera() -> Value {
|
||||
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
|
||||
}
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
|
||||
/// saved in the design, plus `more` steps.
|
||||
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
|
||||
let mut steps = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
|
||||
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
|
||||
];
|
||||
steps.extend(more.iter().cloned());
|
||||
let path = self.0.join(name);
|
||||
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
|
||||
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(self.0.join(out))
|
||||
.args(["--width", &w, "--height", &h])
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The PNG `snapshot` writes.
|
||||
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
|
||||
let output = self.run(script, out, extra);
|
||||
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let png = std::fs::read(self.0.join(out)).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
|
||||
png
|
||||
}
|
||||
|
||||
/// The PNG the MCP `screenshot` tool writes for the same design.
|
||||
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
|
||||
let mut session = Session::default();
|
||||
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::new(session)));
|
||||
let mut arguments = arguments;
|
||||
arguments["path"] = json!(self.0.join(out).to_string_lossy());
|
||||
arguments["width"] = json!(SIZE.0);
|
||||
arguments["height"] = json!(SIZE.1);
|
||||
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
|
||||
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
|
||||
std::fs::read(self.0.join(out)).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
|
||||
/// not the home view the option used to be ignored for.
|
||||
#[test]
|
||||
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let home = dir.snapshot(&script, "home.png", &[]);
|
||||
let json = camera().to_string();
|
||||
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
assert_differs(&shot, &home, "--camera was ignored");
|
||||
assert_same(&shot, &dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})), "the MCP screenshot");
|
||||
// A perspective camera too, and a different eye gives a different picture.
|
||||
let mut near = camera();
|
||||
near["fov"] = json!(0.6);
|
||||
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
|
||||
assert_differs(&perspective, &shot, "perspective");
|
||||
assert_same(&perspective, &dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})), "the MCP perspective screenshot");
|
||||
}
|
||||
|
||||
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
|
||||
/// standard view still looks as it did.
|
||||
#[test]
|
||||
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
|
||||
assert_same(&saved, &by_camera, "the saved view");
|
||||
assert_same(&saved, &dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})), "the MCP saved view");
|
||||
// A standard view frames the model from its direction, as before: the home camera turned.
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
let mut s = Session::default();
|
||||
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top");
|
||||
assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view");
|
||||
assert_differs(&top, &saved, "top and the saved view");
|
||||
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
|
||||
assert_same(&iso, &dir.snapshot(&script, "home.png", &[]), "iso is home");
|
||||
assert_differs(&iso, &top, "iso and top");
|
||||
}
|
||||
|
||||
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
|
||||
/// of its snapshot; `--view` and `--camera` still decide when given.
|
||||
#[test]
|
||||
fn a_script_can_carry_its_camera() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
|
||||
let plain = dir.script("plain.json", &[]);
|
||||
let carried = dir.snapshot(&script, "carried.png", &[]);
|
||||
assert_same(&carried, &dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]), "the carried camera");
|
||||
assert_differs(&carried, &dir.snapshot(&plain, "home.png", &[]), "the carried camera and home");
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
assert_same(&top, &dir.snapshot(&plain, "plain-top.png", &["--view", "top"]), "--view decides");
|
||||
}
|
||||
|
||||
/// The grid and the axes each go when asked, and a render without both from any side shows the
|
||||
/// model alone: an empty design is then the same blank picture from every camera.
|
||||
#[test]
|
||||
fn the_grid_and_the_axes_can_be_left_out() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
|
||||
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
|
||||
let (all, no_grid, no_axes, bare) = (
|
||||
with(&[], "all.png"),
|
||||
with(&["--no-grid"], "no-grid.png"),
|
||||
with(&["--no-axes"], "no-axes.png"),
|
||||
with(&["--no-grid", "--no-axes"], "bare.png"),
|
||||
);
|
||||
let different = [&all, &no_grid, &no_axes, &bare];
|
||||
for (i, a) in different.iter().enumerate() {
|
||||
for b in &different[i + 1..] {
|
||||
assert_differs(a, b, &format!("{view:?}: two of the pictures"));
|
||||
}
|
||||
}
|
||||
// The same as the MCP's `grid` and `axes`.
|
||||
let mcp = |extra: Value, out: &str| {
|
||||
let mut a = match view[0] {
|
||||
"--view" => json!({"view": "front"}),
|
||||
_ => json!({"camera": camera()}),
|
||||
};
|
||||
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
dir.mcp(&script, out, a)
|
||||
};
|
||||
assert_same(&no_grid, &mcp(json!({"grid": false}), "mcp-no-grid.png"), "the MCP's grid: false");
|
||||
assert_same(&no_axes, &mcp(json!({"axes": false}), "mcp-no-axes.png"), "the MCP's axes: false");
|
||||
assert_same(&bare, &mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"), "the MCP's bare picture");
|
||||
}
|
||||
let empty = dir.0.join("empty.json");
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
|
||||
assert_same(&blank("top", "blank-top.png"), &blank("front", "blank-front.png"), "blank from every side");
|
||||
assert_differs(&blank("top", "blank-top.png"), &dir.snapshot(&empty, "top.png", &["--view", "top"]), "blank and the grid");
|
||||
}
|
||||
|
||||
/// A snapshot of a box shows its silhouette where the camera puts it: the pixels that differ
|
||||
/// from an empty design's picture (same camera, no grid or axes) span the box's corners projected
|
||||
/// to pixels, within the edge and silhouette lines' width.
|
||||
#[test]
|
||||
fn a_snapshot_shows_the_part_where_the_camera_puts_it() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let part = dir.0.join("box.json");
|
||||
let empty = dir.0.join("empty.json");
|
||||
let corner = [10.0, -20.0, -15.0];
|
||||
let size = [60.0, 40.0, 30.0];
|
||||
std::fs::write(
|
||||
&part,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": size[0], "width": size[1], "height": size[2], "corner": corner}}]})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
for cam in [
|
||||
camera(),
|
||||
json!({"target": [40, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 160}),
|
||||
json!({"target": [40, 0, 0], "yaw": -0.4, "pitch": 0.5, "distance": 150, "fov": 0.6}),
|
||||
] {
|
||||
let args = ["--camera", &cam.to_string(), "--no-grid", "--no-axes"];
|
||||
let (_, _, with) = decode(&dir.snapshot(&part, "part.png", &args));
|
||||
let (_, _, without) = decode(&dir.snapshot(&empty, "empty.png", &args));
|
||||
// The part's pixels: where the picture differs from the empty one.
|
||||
let (mut lo, mut hi) = ([u32::MAX; 2], [0u32; 2]);
|
||||
for (i, (p, q)) in with.as_chunks::<4>().0.iter().zip(without.as_chunks::<4>().0).enumerate() {
|
||||
if p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 24) {
|
||||
let (x, y) = (i as u32 % SIZE.0, i as u32 / SIZE.0);
|
||||
lo = [lo[0].min(x), lo[1].min(y)];
|
||||
hi = [hi[0].max(x), hi[1].max(y)];
|
||||
}
|
||||
}
|
||||
assert!(lo[0] <= hi[0], "{cam}: the part is not in the picture");
|
||||
// Where the camera puts the corners.
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("view.apply", &json!({"camera": cam})).unwrap();
|
||||
let c = s.camera.unwrap();
|
||||
let vp = c.view_proj(f64::from(SIZE.0) / f64::from(SIZE.1), 1000.0);
|
||||
let (mut elo, mut ehi) = ([f64::MAX; 2], [f64::MIN; 2]);
|
||||
for k in 0..8 {
|
||||
let p = solidcraft_engine::geom::Vec3::new(
|
||||
corner[0] + size[0] * f64::from(k & 1),
|
||||
corner[1] + size[1] * f64::from((k >> 1) & 1),
|
||||
corner[2] + size[2] * f64::from((k >> 2) & 1),
|
||||
);
|
||||
let q = vp.apply(p);
|
||||
let (x, y) = ((q[0] / q[3] + 1.0) * 0.5 * f64::from(SIZE.0), (1.0 - q[1] / q[3]) * 0.5 * f64::from(SIZE.1));
|
||||
elo = [elo[0].min(x), elo[1].min(y)];
|
||||
ehi = [ehi[0].max(x), ehi[1].max(y)];
|
||||
}
|
||||
let near = |got: u32, want: f64| (f64::from(got) - want).abs() <= 2.5;
|
||||
assert!(
|
||||
near(lo[0], elo[0]) && near(lo[1], elo[1]) && near(hi[0] + 1, ehi[0]) && near(hi[1] + 1, ehi[1]),
|
||||
"{cam}: the part spans pixels {lo:?}..={hi:?}, its corners project to {elo:?}..{ehi:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `snapshot` under a `SOLIDCRAFT_GPU` policy: its reply and stderr.
|
||||
fn snapshot_under(dir: &Scratch, script: &Path, out: &str, policy: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.env("SOLIDCRAFT_GPU", policy)
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(dir.0.join(out))
|
||||
.args(["--width", &SIZE.0.to_string(), "--height", &SIZE.1.to_string(), "--view", "top"])
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The last tier: `SOLIDCRAFT_GPU=cpu` draws with the CPU rasterizer whatever adapters there are,
|
||||
/// says so (`renderer: cpu` and a warning that overlays are not drawn), and the picture is the
|
||||
/// rasterizer's own.
|
||||
#[test]
|
||||
fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let output = snapshot_under(&dir, &script, "cpu.png", "cpu");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{stderr}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], "cpu", "{reply}");
|
||||
assert!(reply["warning"].as_str().is_some_and(|w| w.contains("not drawn")), "{reply}");
|
||||
assert!(stderr.contains("CPU rasterizer"), "{stderr}");
|
||||
let mut s = Session::default();
|
||||
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
|
||||
assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu);
|
||||
}
|
||||
|
||||
/// `hardware` (and `software`) never fall to the CPU rasterizer: without such an adapter the
|
||||
/// snapshot fails, naming the policy, and writes nothing; with one it says it was drawn on the GPU.
|
||||
/// `auto` lands where `solidcraft-cli gpu` says it does.
|
||||
#[test]
|
||||
fn a_strict_policy_never_falls_to_the_cpu() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let survey = |policy: &str| {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["gpu", "--policy", policy]).output().unwrap();
|
||||
(o.status.success(), serde_json::from_slice::<Value>(&o.stdout).unwrap_or(Value::Null))
|
||||
};
|
||||
for policy in ["hardware", "software"] {
|
||||
let (found, report) = survey(policy);
|
||||
let output = snapshot_under(&dir, &script, &format!("{policy}.png"), policy);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if found {
|
||||
assert!(output.status.success(), "{policy}: {stderr}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], report["renderer"], "{policy}: {reply}");
|
||||
assert_ne!(reply["renderer"], "cpu");
|
||||
} else {
|
||||
assert_eq!(report["lands_on"], Value::Null, "{policy}: {report}");
|
||||
assert_eq!(output.status.code(), Some(1), "{policy}: {stderr}");
|
||||
assert!(stderr.contains(&format!("`{policy}` policy")) && stderr.contains("solidcraft-cli gpu"), "{policy}: {stderr}");
|
||||
assert!(!dir.0.join(format!("{policy}.png")).exists());
|
||||
}
|
||||
}
|
||||
let (found, report) = survey("auto");
|
||||
assert!(found, "auto always lands somewhere: {report}");
|
||||
let output = snapshot_under(&dir, &script, "auto.png", "auto");
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], report["renderer"], "{reply} vs {report}");
|
||||
let (_, cpu) = survey("cpu");
|
||||
assert_eq!((cpu["lands_on"].clone(), cpu["renderer"].clone()), (json!("cpu"), json!("cpu")), "{cpu}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let cases: [(&[&str], &str); 7] = [
|
||||
(&["--view", "Nowhere"], "Nowhere"),
|
||||
(&["--view", "iso", "--camera", "{}"], "not both"),
|
||||
(&["--camera", "not json"], "--camera"),
|
||||
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
|
||||
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
|
||||
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
|
||||
(&["--camera", "[1, 2, 3]"], "camera"),
|
||||
];
|
||||
for (extra, expect) in cases {
|
||||
let output = dir.run(&script, "never.png", extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
|
||||
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The usage text says what the new options do.
|
||||
#[test]
|
||||
fn the_usage_names_the_camera_and_the_guides() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
|
||||
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! Straight edges stay one edge after many cuts (#1049). The owner's design
|
||||
//! (`tests/action_matrix/designs/crazy-geom-cuts.solidcraft`, saved by 0.10.0): five extrudes,
|
||||
//! joins and cuts, over four sketches, three of them on faces of the body with the faces'
|
||||
//! edges projected. Cuts used to leave the straight edges of the walls they sliced split at
|
||||
//! vertices nothing else used, so an edge that is one straight line was drawn (and lit on hover)
|
||||
//! as many pieces, and a sketch on such a face projected every piece.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
fn design() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.solidcraft")
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// The design built from scratch: saved without cached geometry and opened again.
|
||||
fn rebuilt() -> Session {
|
||||
let s = open(&design());
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-straight-edges-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("crazy-geom-cuts.json");
|
||||
let mut t = s.scratch();
|
||||
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
|
||||
let out = open(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
out
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<(u64, String)> {
|
||||
s.model.results.iter().filter_map(|r| r.error.clone().map(|e| (r.id, e))).collect()
|
||||
}
|
||||
|
||||
fn key(v: Vec3) -> [i64; 3] {
|
||||
[v.x, v.y, v.z].map(|c| (c * 1e5).round() as i64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_edges_are_one_edge_after_many_cuts() {
|
||||
for (what, s) in [("as saved", open(&design())), ("built from scratch", rebuilt())] {
|
||||
assert_eq!(errors(&s), vec![], "{what}: every feature evaluates");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<_> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
|
||||
assert_eq!(bodies.len(), 1, "{what}: one body");
|
||||
let body = &bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
// Every edge is drawn as one polyline; a straight one as one segment.
|
||||
let mut at: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
let (Some(a), Some(z)) = (e.first(), e.last()) else { panic!("{what}: edge {i} is empty") };
|
||||
at.entry(key(*a)).or_default().push(i);
|
||||
at.entry(key(*z)).or_default().push(i);
|
||||
}
|
||||
let straight = |i: usize| mesh.edges.get(i).filter(|e| e.len() == 2).and_then(|e| (e[1] - e[0]).normalized());
|
||||
let split: Vec<_> = at
|
||||
.iter()
|
||||
.filter(|(_, es)| match es.as_slice() {
|
||||
[a, b] => straight(*a).zip(straight(*b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9),
|
||||
_ => false,
|
||||
})
|
||||
.map(|(k, _)| k.map(|c| c as f64 / 1e5))
|
||||
.collect();
|
||||
assert_eq!(split, Vec::<[f64; 3]>::new(), "{what}: straight edges split where nothing else meets them");
|
||||
// No two faces side by side in one plane.
|
||||
let plane = |f: usize| match face_surface(body, f) {
|
||||
Some(FaceSurface::Plane { normal, point }) => Some((normal, normal.dot(point))),
|
||||
_ => None,
|
||||
};
|
||||
for (i, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
if let [a, b] = faces.as_slice()
|
||||
&& a != b
|
||||
&& let (Some((na, da)), Some((nb, db))) = (plane(*a as usize), plane(*b as usize))
|
||||
{
|
||||
assert!(na.dot(nb) < 1. - 1e-9 || (da - db).abs() > 1e-6, "{what}: faces {a} and {b} meet at edge {i} in one plane");
|
||||
}
|
||||
}
|
||||
assert_eq!((body.face_count(), body.edge_count(), body.vertex_count()), (35, 107, 71), "{what}: faces, edges and vertices");
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
[package]
|
||||
name = "solidcraft-web"
|
||||
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solidcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance",
|
||||
] }
|
||||
@@ -1,16 +0,0 @@
|
||||
# Build with: trunk build --release (from apps/solidcraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8781
|
||||
|
||||
# Offline use and updates: release builds get sw.js listing this build's files (xtask/src/web_offline.rs).
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "cargo"
|
||||
command_arguments = ["xtask", "web-offline"]
|
||||
@@ -1,51 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="SolidCraft">
|
||||
<meta property="og:title" content="SolidCraft: try it in your browser">
|
||||
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://solidcraft.ai/">
|
||||
<meta property="og:image" content="https://docs.solidcraft.io/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://docs.solidcraft.io/og.png">
|
||||
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
|
||||
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
|
||||
<meta name="theme-color" content="#343840">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
|
||||
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
|
||||
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
<div id="solidcraft_loading" role="status"><img src="loading-logo.svg" alt=""><span class="sr-only">Loading SolidCraft…</span></div>
|
||||
<script>
|
||||
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
|
||||
if ("serviceWorker" in navigator) {
|
||||
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
|
||||
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
|
||||
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
|
||||
addEventListener("appinstalled", () => { window.solidcraftInstall = null; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "SolidCraft",
|
||||
"short_name": "SolidCraft",
|
||||
"description": "Open-source parametric CAD built in Rust.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#e8ebf0",
|
||||
"theme_color": "#343840",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" },
|
||||
{ "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Promise-based access to the running app's existing control channel.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
|
||||
const MAX_PENDING: usize = 256;
|
||||
const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
resolve: js_sys::Function,
|
||||
reject: js_sys::Function,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Api {
|
||||
sender: Sender<ControlRequest>,
|
||||
pending: Rc<RefCell<Vec<Pending>>>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
fn params(value: &JsValue) -> Result<Value, String> {
|
||||
if value.is_undefined() || value.is_null() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
let text = js_sys::JSON::stringify(value).ok().and_then(|v| v.as_string()).ok_or("params must be JSON serializable")?;
|
||||
if text.len() > MAX_JSON_BYTES {
|
||||
return Err("params exceed 16 MiB".into());
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
fn error(message: &str) -> JsValue {
|
||||
js_sys::Error::new(message).into()
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn install(sender: Sender<ControlRequest>, ctx: egui::Context) -> Result<Self, JsValue> {
|
||||
let api = Self { sender, pending: Rc::default(), ctx };
|
||||
let object = js_sys::Object::new();
|
||||
let request = api.clone();
|
||||
let execute = api.clone();
|
||||
let commands = api.clone();
|
||||
let inspect = api.clone();
|
||||
for (name, function) in [
|
||||
(
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |method: String, value: JsValue| {
|
||||
request.request(&method, params(&value))
|
||||
})
|
||||
.into_js_value(),
|
||||
),
|
||||
(
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |command: String, value: JsValue| {
|
||||
execute.request("engine.execute", params(&value).map(|p| json!({"command": command, "params": p})))
|
||||
})
|
||||
.into_js_value(),
|
||||
),
|
||||
("commands", Closure::<dyn Fn() -> js_sys::Promise>::new(move || commands.request("engine.commands", Ok(json!({})))).into_js_value()),
|
||||
(
|
||||
"inspect",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(move |value: JsValue| inspect.request("document.inspect", params(&value)))
|
||||
.into_js_value(),
|
||||
),
|
||||
] {
|
||||
js_sys::Reflect::set(&object, &name.into(), &function)?;
|
||||
}
|
||||
let window = web_sys::window().ok_or_else(|| error("no window"))?;
|
||||
js_sys::Reflect::set(&window, &"solidcraft".into(), &object)?;
|
||||
Ok(api)
|
||||
}
|
||||
|
||||
fn request(&self, method: &str, params: Result<Value, String>) -> js_sys::Promise {
|
||||
let params = match params {
|
||||
Ok(params) => params,
|
||||
Err(message) => return js_sys::Promise::reject(&error(&message)),
|
||||
};
|
||||
if self.pending.borrow().len() >= MAX_PENDING {
|
||||
return js_sys::Promise::reject(&error("too many pending SolidCraft requests (maximum 256)"));
|
||||
}
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (request, reply) = ControlRequest::new(method, params.clone());
|
||||
if self.sender.send(request).is_err() {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &error("SolidCraft is not running"));
|
||||
return;
|
||||
}
|
||||
self.pending.borrow_mut().push(Pending { reply, resolve, reject });
|
||||
self.ctx.request_repaint();
|
||||
})
|
||||
}
|
||||
|
||||
pub fn poll(&self) {
|
||||
let mut completed = Vec::new();
|
||||
self.pending.borrow_mut().retain_mut(|pending| {
|
||||
let result = match pending.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
js_sys::JSON::parse(&reply.get("result").unwrap_or(&Value::Null).to_string())
|
||||
} else {
|
||||
Err(error(reply.get("error").and_then(Value::as_str).unwrap_or("SolidCraft request failed")))
|
||||
}
|
||||
}
|
||||
Err(TryRecvError::Empty) => return true,
|
||||
Err(TryRecvError::Disconnected) => Err(error("SolidCraft request dropped")),
|
||||
};
|
||||
completed.push((pending.resolve.clone(), pending.reject.clone(), result));
|
||||
false
|
||||
});
|
||||
// Release the queue before calling JavaScript, which can submit another request.
|
||||
for (resolve, reject, result) in completed {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &value);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = reject.call1(&JsValue::UNDEFINED, &error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pending.borrow().is_empty() {
|
||||
self.ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
//! Designs on the computer's own disk, through the File System Access API (Chrome, Edge, Opera):
|
||||
//! Save As and Open show the browser's file dialogs, and the chosen file's handle is kept (in
|
||||
//! memory, and in IndexedDB so Save still reaches the file after a reload) so Save writes back to
|
||||
//! it. To the engine such a design is the file `/disk/<id>/<name>`: it saves there like anywhere,
|
||||
//! and [`flush`](crate::files::flush) forwards what it wrote to the handle. Browsers without the
|
||||
//! API (Firefox, Safari) keep designs in the origin private file system (`files.rs`).
|
||||
//!
|
||||
//! The path logic is plain Rust and tested natively; the browser calls are wasm-only.
|
||||
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
|
||||
/// Where a design on the computer's disk appears to the engine.
|
||||
pub const PREFIX: &str = "/disk/";
|
||||
|
||||
/// Where designs kept in the browser's private storage (OPFS) appear to the engine.
|
||||
pub const OPFS_PREFIX: &str = "/opfs/";
|
||||
/// Paths written here are handed to the user as downloads.
|
||||
pub const DOWNLOADS_PREFIX: &str = "/downloads/";
|
||||
|
||||
/// Where what a command wrote to an engine path goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WriteTo {
|
||||
/// The browser's private storage: the design (or recovery file) keeps its name there.
|
||||
Opfs,
|
||||
/// A file on the computer, through its handle.
|
||||
Disk,
|
||||
/// A download.
|
||||
Download,
|
||||
/// Kept only in memory (uploads).
|
||||
Memory,
|
||||
}
|
||||
|
||||
/// Route a write by its path. OPFS paths always go to OPFS, whatever the browser can do: a design
|
||||
/// opened from the browser's storage keeps saving there until the user picks Save As.
|
||||
pub fn route(path: &str) -> WriteTo {
|
||||
if path.starts_with(OPFS_PREFIX) {
|
||||
WriteTo::Opfs
|
||||
} else if parse(path).is_some() {
|
||||
WriteTo::Disk
|
||||
} else if path.starts_with(DOWNLOADS_PREFIX) {
|
||||
WriteTo::Download
|
||||
} else {
|
||||
WriteTo::Memory
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine path of the design `name` kept under handle `id`.
|
||||
pub fn path(id: &str, name: &str) -> String {
|
||||
format!("{PREFIX}{id}/{name}")
|
||||
}
|
||||
|
||||
/// The handle id and file name of an engine path made by [`path`].
|
||||
pub fn parse(path: &str) -> Option<(&str, &str)> {
|
||||
let (id, name) = path.strip_prefix(PREFIX)?.split_once('/')?;
|
||||
(!id.is_empty() && !name.is_empty() && !name.contains('/')).then_some((id, name))
|
||||
}
|
||||
|
||||
/// A handle id from the time and a random number.
|
||||
pub fn make_id(ms: u64, random: u64) -> String {
|
||||
format!("{ms:x}-{random:x}")
|
||||
}
|
||||
|
||||
/// How long a remembered file may go unused, and unreferenced by the recent designs and the
|
||||
/// autosave, before the browser's copy of its handle is dropped.
|
||||
pub const HANDLE_MAX_AGE_MS: f64 = 90.0 * 24.0 * 3600.0 * 1000.0;
|
||||
|
||||
/// What to do with the remembered handles.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct PrunePlan {
|
||||
/// Handles to forget.
|
||||
pub delete: Vec<String>,
|
||||
/// Handles seen unused for the first time: start their clock.
|
||||
pub stamp: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decide which of the remembered handles `ids` (id, when it was last stamped) to forget: those
|
||||
/// that no recent design or autosave (`keep`) names and that have gone unused for `max_age`. One
|
||||
/// never stamped starts its clock now, so a file another tab has just saved is never dropped.
|
||||
pub fn plan_prune(ids: &[(String, Option<f64>)], keep: &std::collections::HashSet<String>, now: f64, max_age: f64) -> PrunePlan {
|
||||
let mut plan = PrunePlan::default();
|
||||
for (id, stamped) in ids.iter().filter(|(id, _)| !keep.contains(id)) {
|
||||
match stamped {
|
||||
None => plan.stamp.push(id.clone()),
|
||||
Some(at) if now - at > max_age => plan.delete.push(id.clone()),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// Where a design saved with Save As goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SaveTarget {
|
||||
/// A file the user picks on their computer.
|
||||
Disk,
|
||||
/// The browser's private storage (OPFS): what browsers without the API have always done.
|
||||
Browser,
|
||||
/// Not a design (an export): handed over as a download.
|
||||
Download,
|
||||
}
|
||||
|
||||
/// Choose where a Save As goes: designs to the disk when the browser can pick a file, else into
|
||||
/// the browser's storage; anything else is a download.
|
||||
pub fn save_target(api: bool, design: bool) -> SaveTarget {
|
||||
match (design, api) {
|
||||
(false, _) => SaveTarget::Download,
|
||||
(true, true) => SaveTarget::Disk,
|
||||
(true, false) => SaveTarget::Browser,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a picker for `extensions` (what the command reads; `&[]`: anything) belong to a design?
|
||||
/// Only those go through the disk dialog; inserting a STEP or mesh keeps using the file window.
|
||||
pub fn opens_design(extensions: &[&str]) -> bool {
|
||||
extensions.is_empty() || extensions.iter().any(|e| solidcraft_engine::io::DESIGN_EXTS.contains(e))
|
||||
}
|
||||
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
|
||||
pub fn save_type(name: &str) -> (&'static str, &'static str, &'static str) {
|
||||
let json = std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
|
||||
if json { ("Plain JSON design", "application/json", ".json") } else { ("SolidCraft design", "application/octet-stream", ".solidcraft") }
|
||||
}
|
||||
|
||||
/// Is this a cancelled dialog (the browser's `AbortError`)? Not an error to show.
|
||||
pub fn is_abort(error_name: &str) -> bool {
|
||||
error_name == "AbortError"
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Promise, Reflect};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
|
||||
|
||||
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
|
||||
use crate::files::{Files, err, read_handle};
|
||||
|
||||
const DB: &str = "solidcraft-disk-handles";
|
||||
const STORE: &str = "handles";
|
||||
|
||||
thread_local! {
|
||||
/// Handles picked or looked up in this session, by id.
|
||||
static HANDLES: RefCell<HashMap<String, FileSystemFileHandle>> = RefCell::new(HashMap::new());
|
||||
/// Writes waiting for their turn (a file takes one writer at a time), latest per id.
|
||||
static QUEUE: RefCell<VecDeque<(String, Vec<u8>)>> = const { RefCell::new(VecDeque::new()) };
|
||||
static BUSY: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Why a dialog gave no file.
|
||||
enum Pick {
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
fn failed(e: JsValue) -> Pick {
|
||||
let name = Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).unwrap_or_default();
|
||||
if is_abort(&name) { Pick::Cancelled } else { Pick::Failed(err(e)) }
|
||||
}
|
||||
|
||||
/// Can this page show the browser's file dialogs and write to the files they pick?
|
||||
pub fn supported() -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let has = |name: &str| Reflect::get(&window, &name.into()).is_ok_and(|f| f.is_function());
|
||||
window.is_secure_context() && has("showSaveFilePicker") && has("showOpenFilePicker")
|
||||
}
|
||||
|
||||
fn types(description: &str, mime: &str, extension: &str) -> Array {
|
||||
let accept = Object::new();
|
||||
let _ = Reflect::set(&accept, &mime.into(), &Array::of1(&extension.into()));
|
||||
let entry = Object::new();
|
||||
let _ = Reflect::set(&entry, &"description".into(), &description.into());
|
||||
let _ = Reflect::set(&entry, &"accept".into(), &accept);
|
||||
Array::of1(&entry)
|
||||
}
|
||||
|
||||
/// Start a dialog. This must run in the click that asked for it (the browser wants a user
|
||||
/// gesture), so it is called before anything is awaited.
|
||||
fn start_picker(function: &str, options: &Object) -> Result<Promise, Pick> {
|
||||
let window = web_sys::window().ok_or_else(|| Pick::Failed("no window".into()))?;
|
||||
let f: Function = Reflect::get(&window, &function.into())
|
||||
.map_err(failed)?
|
||||
.dyn_into()
|
||||
.map_err(|_| Pick::Failed("this browser can't open file dialogs".into()))?;
|
||||
f.call1(&window, options).map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("no answer from the file dialog".into()))
|
||||
}
|
||||
|
||||
/// Remember a picked file: in memory now, in IndexedDB for the next session.
|
||||
fn register(handle: &FileSystemFileHandle) -> String {
|
||||
let id = make_id(js_sys::Date::now() as u64, (js_sys::Math::random() * 1e15) as u64);
|
||||
HANDLES.with(|h| h.borrow_mut().insert(id.clone(), handle.clone()));
|
||||
let (key, kept, name) = (id.clone(), handle.clone(), handle.name());
|
||||
spawn_local(async move {
|
||||
if let Err(e) = remember(&key, &kept).await {
|
||||
log::warn!("the file's handle isn't kept for the next session: {e}");
|
||||
}
|
||||
});
|
||||
path(&id, &name)
|
||||
}
|
||||
|
||||
fn save_options(name: &str) -> Object {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"suggestedName".into(), &name.into());
|
||||
let (description, mime, extension) = save_type(name);
|
||||
let _ = Reflect::set(&options, &"types".into(), &types(description, mime, extension));
|
||||
options
|
||||
}
|
||||
|
||||
/// "Save to computer…" for a design kept in the browser: write a copy of `bytes` to a file the
|
||||
/// user picks (call in the click), or download it where the browser can't pick one. The design
|
||||
/// itself stays where it is.
|
||||
pub fn save_copy(files: &Rc<Files>, name: &str, bytes: Vec<u8>) {
|
||||
if !supported() {
|
||||
if let Err(e) = crate::files::download(name, &bytes) {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let promise = start_picker("showSaveFilePicker", &save_options(name));
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
let h: FileSystemFileHandle =
|
||||
JsFuture::from(promise?).await.map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("not a file".into()))?;
|
||||
write_handle(&h, &bytes).await.map_err(Pick::Failed)?;
|
||||
Ok::<String, Pick>(h.name())
|
||||
};
|
||||
match r.await {
|
||||
Ok(saved) => files.say(format!("Saved a copy to {saved}"), false),
|
||||
Err(Pick::Cancelled) => {}
|
||||
Err(Pick::Failed(e)) => files.say(format!("save to computer: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Save As, step one: show the browser's save dialog (call in the click). The answer is the
|
||||
/// engine path the design is saved to, `None` when the dialog was closed.
|
||||
pub fn pick_save(files: &Rc<Files>, name: &str) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
let promise = start_picker("showSaveFilePicker", &save_options(name));
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let handle = match promise {
|
||||
Ok(p) => JsFuture::from(p)
|
||||
.await
|
||||
.map_err(failed)
|
||||
.and_then(|h| h.dyn_into::<FileSystemFileHandle>().map_err(|_| Pick::Failed("not a file".into()))),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match handle {
|
||||
Ok(h) => Some(register(&h)),
|
||||
Err(Pick::Cancelled) => None,
|
||||
Err(Pick::Failed(e)) => {
|
||||
files.say(format!("save: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open, step one: show the browser's open dialog for a design (call in the click). The
|
||||
/// answer is the engine path of the file, read into the engine's files; a design keeps its
|
||||
/// handle, so Save writes back to the file. `None`: closed, or it could not be read.
|
||||
pub fn pick_open(files: &Rc<Files>) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
let promise = start_picker("showOpenFilePicker", &open_options());
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let r = async {
|
||||
let p = promise?;
|
||||
let handles: Array = JsFuture::from(p).await.map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("no file".into()))?;
|
||||
let h: FileSystemFileHandle = handles.get(0).dyn_into().map_err(|_| Pick::Cancelled)?;
|
||||
let bytes = read_handle(&h).await.map_err(Pick::Failed)?;
|
||||
Ok((h, bytes))
|
||||
};
|
||||
match r.await {
|
||||
Ok((h, bytes)) => Some(arrive(&h, bytes)),
|
||||
Err(Pick::Cancelled) => None,
|
||||
Err(Pick::Failed(e)) => {
|
||||
files.say(format!("open: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of the open dialog: a single file, designs and CAD files listed first.
|
||||
pub fn open_options() -> Object {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
|
||||
let extensions = Array::new();
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf"] {
|
||||
extensions.push(&e.into());
|
||||
}
|
||||
let accept = Object::new();
|
||||
let _ = Reflect::set(&accept, &"application/octet-stream".into(), &extensions);
|
||||
let entry = Object::new();
|
||||
let _ = Reflect::set(&entry, &"description".into(), &"Designs and CAD files".into());
|
||||
let _ = Reflect::set(&entry, &"accept".into(), &accept);
|
||||
let _ = Reflect::set(&options, &"types".into(), &Array::of1(&entry));
|
||||
options
|
||||
}
|
||||
|
||||
/// A file read from a dialog's handle: a design lives at its disk path from now on (and
|
||||
/// keeps the handle); anything else is an upload to open or insert.
|
||||
pub fn arrive(handle: &FileSystemFileHandle, bytes: Vec<u8>) -> String {
|
||||
let name = handle.name();
|
||||
let p = if solidcraft_engine::io::is_design_path(&name) { register(handle) } else { format!("{}{name}", crate::files::UPLOAD) };
|
||||
vfs::insert(&p, bytes);
|
||||
p
|
||||
}
|
||||
|
||||
/// Does an Open for `extensions` use the browser's open dialog?
|
||||
pub fn handles_open(extensions: &[&str]) -> bool {
|
||||
supported() && opens_design(extensions)
|
||||
}
|
||||
|
||||
// ---- writing ------------------------------------------------------------------------
|
||||
|
||||
/// Queue what a command wrote to a disk path. Writes go one at a time; a newer write of the
|
||||
/// same file replaces one still waiting.
|
||||
pub fn enqueue(files: &Rc<Files>, path: &str, bytes: Vec<u8>) {
|
||||
QUEUE.with(|q| {
|
||||
let mut q = q.borrow_mut();
|
||||
q.retain(|(p, _)| p != path);
|
||||
q.push_back((path.to_string(), bytes));
|
||||
});
|
||||
if BUSY.with(|b| b.replace(true)) {
|
||||
return;
|
||||
}
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
while let Some((path, bytes)) = QUEUE.with(|q| q.borrow_mut().pop_front()) {
|
||||
if let Err(e) = write(&path, &bytes).await {
|
||||
files.failed_disk(&path, &e);
|
||||
}
|
||||
}
|
||||
BUSY.with(|b| b.set(false));
|
||||
});
|
||||
}
|
||||
|
||||
async fn write(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let (id, name) = parse(path).ok_or("not a file on the computer")?;
|
||||
let handle = handle_for(id).await.map_err(|e| format!("{name}: {e}"))?;
|
||||
write_handle(&handle, bytes).await.map_err(|e| format!("{name}: {e}"))
|
||||
}
|
||||
|
||||
async fn write_handle(handle: &FileSystemFileHandle, bytes: &[u8]) -> Result<(), String> {
|
||||
ensure_readwrite(handle).await?;
|
||||
let w: web_sys::FileSystemWritableFileStream =
|
||||
JsFuture::from(handle.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
|
||||
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
|
||||
JsFuture::from(w.close()).await.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_for(id: &str) -> Result<FileSystemFileHandle, String> {
|
||||
if let Some(h) = HANDLES.with(|h| h.borrow().get(id).cloned()) {
|
||||
return Ok(h);
|
||||
}
|
||||
let h: FileSystemFileHandle = recall(id)
|
||||
.await?
|
||||
.and_then(|v| v.dyn_into().ok())
|
||||
.ok_or("the browser no longer remembers where this design was saved: use Save As to choose the file again")?;
|
||||
HANDLES.with(|m| m.borrow_mut().insert(id.to_string(), h.clone()));
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// Ask for write access if the browser doesn't hold it any more (after a reload): one prompt,
|
||||
/// from the click that asked for the save.
|
||||
async fn ensure_readwrite(handle: &FileSystemFileHandle) -> Result<(), String> {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"mode".into(), &"readwrite".into());
|
||||
for method in ["queryPermission", "requestPermission"] {
|
||||
let Some(f) = Reflect::get(handle, &method.into()).ok().and_then(|f| f.dyn_into::<Function>().ok()) else {
|
||||
// A browser without the permission calls hands out writable handles as they are.
|
||||
return Ok(());
|
||||
};
|
||||
let answer = f.call1(handle, &options).map_err(err)?;
|
||||
let state = JsFuture::from(Promise::resolve(&answer)).await.map_err(err)?;
|
||||
if state.as_string().as_deref() == Some("granted") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("the browser wasn't given permission to write the file: use Save As to choose it again".into())
|
||||
}
|
||||
|
||||
// ---- IndexedDB: the handles, by id --------------------------------------------------
|
||||
|
||||
/// The result of an IndexedDB request.
|
||||
async fn result(req: &IdbRequest) -> Result<JsValue, String> {
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let r = req.clone();
|
||||
let ok = Closure::once_into_js(move || {
|
||||
let _ = resolve.call1(&JsValue::NULL, &r.result().unwrap_or(JsValue::UNDEFINED));
|
||||
});
|
||||
let bad = Closure::once_into_js(move || {
|
||||
let _ = reject.call1(&JsValue::NULL, &"IndexedDB failed".into());
|
||||
});
|
||||
req.set_onsuccess(Some(ok.unchecked_ref()));
|
||||
req.set_onerror(Some(bad.unchecked_ref()));
|
||||
});
|
||||
JsFuture::from(promise).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn open_db() -> Result<IdbDatabase, String> {
|
||||
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(err)?.ok_or("this browser has no IndexedDB")?;
|
||||
let request: IdbOpenDbRequest = factory.open_with_u32(DB, 1).map_err(err)?;
|
||||
let target = request.clone();
|
||||
let upgrade = Closure::once_into_js(move || {
|
||||
if let Ok(db) = target.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
|
||||
let _ = db.create_object_store(STORE);
|
||||
}
|
||||
});
|
||||
request.set_onupgradeneeded(Some(upgrade.unchecked_ref()));
|
||||
result(&request).await?.dyn_into().map_err(|_| "no database".to_string())
|
||||
}
|
||||
|
||||
fn store(db: &IdbDatabase, mode: IdbTransactionMode) -> Result<IdbObjectStore, String> {
|
||||
db.transaction_with_str_and_mode(STORE, mode).map_err(err)?.object_store(STORE).map_err(err)
|
||||
}
|
||||
|
||||
/// Key of the time a handle was last used (the handle's own key is its id).
|
||||
fn stamp_key(id: &str) -> String {
|
||||
format!("at:{id}")
|
||||
}
|
||||
|
||||
async fn remember(id: &str, handle: &FileSystemFileHandle) -> Result<(), String> {
|
||||
let db = open_db().await?;
|
||||
let store = store(&db, IdbTransactionMode::Readwrite)?;
|
||||
result(&store.put_with_key(handle, &id.into()).map_err(err)?).await?;
|
||||
result(&store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into()).map_err(err)?).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Note that the file was used now.
|
||||
async fn touch(id: &str) {
|
||||
let Ok(db) = open_db().await else { return };
|
||||
if let Ok(store) = store(&db, IdbTransactionMode::Readwrite)
|
||||
&& let Ok(req) = store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into())
|
||||
{
|
||||
let _ = result(&req).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of every remembered file.
|
||||
pub async fn known_ids() -> Result<Vec<String>, String> {
|
||||
let db = open_db().await?;
|
||||
let req = store(&db, IdbTransactionMode::Readonly)?.get_all_keys().map_err(err)?;
|
||||
let keys: Array = result(&req).await?.dyn_into().map_err(|_| "no keys".to_string())?;
|
||||
Ok(keys.iter().filter_map(|k| k.as_string()).filter(|k| !k.starts_with("at:")).collect())
|
||||
}
|
||||
|
||||
async fn forget(ids: &[String]) -> Result<(), String> {
|
||||
let db = open_db().await?;
|
||||
let store = store(&db, IdbTransactionMode::Readwrite)?;
|
||||
for id in ids {
|
||||
result(&store.delete(&id.as_str().into()).map_err(err)?).await?;
|
||||
result(&store.delete(&stamp_key(id).into()).map_err(err)?).await?;
|
||||
HANDLES.with(|h| h.borrow_mut().remove(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the remembered files nothing refers to any more (`keep`: the ids the recent designs
|
||||
/// and the autosave name), once they have gone unused for a long while.
|
||||
pub fn prune(keep: std::collections::HashSet<String>) {
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
// A transaction ends when the page awaits anything but its own requests, so each
|
||||
// step opens its own.
|
||||
let known = known_ids().await?;
|
||||
let db = open_db().await?;
|
||||
let reading = store(&db, IdbTransactionMode::Readonly)?;
|
||||
let mut ids = Vec::new();
|
||||
for id in known {
|
||||
let at = result(&reading.get(&stamp_key(&id).into()).map_err(err)?).await?.as_f64();
|
||||
ids.push((id, at));
|
||||
}
|
||||
let plan = plan_prune(&ids, &keep, js_sys::Date::now(), HANDLE_MAX_AGE_MS);
|
||||
for id in &plan.stamp {
|
||||
touch(id).await;
|
||||
}
|
||||
forget(&plan.delete).await
|
||||
};
|
||||
if let Err(e) = r.await {
|
||||
log::warn!("remembered files weren't tidied: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
|
||||
/// for access to the file (call in the click), read it into the engine's files and queue it
|
||||
/// to open. A file that is gone is forgotten and dropped from the recent designs.
|
||||
pub fn restore(files: &Rc<Files>, path: &str) {
|
||||
let Some((id, name)) = parse(path) else { return };
|
||||
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
let handle = handle_for(&id).await?;
|
||||
ensure_readwrite(&handle).await?;
|
||||
read_handle(&handle).await
|
||||
};
|
||||
match r.await {
|
||||
Ok(bytes) => {
|
||||
vfs::insert(&path, bytes);
|
||||
files.opened.borrow_mut().push(path);
|
||||
files.wake();
|
||||
touch(&id).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
|
||||
if gone {
|
||||
let _ = forget(std::slice::from_ref(&id)).await;
|
||||
files.disk_known.borrow_mut().remove(&id);
|
||||
files.gone.borrow_mut().push(path);
|
||||
files.say(format!("{name} isn't where it was saved any more"), true);
|
||||
} else {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn recall(id: &str) -> Result<Option<JsValue>, String> {
|
||||
let db = open_db().await?;
|
||||
let req = store(&db, IdbTransactionMode::Readonly)?.get(&id.into()).map_err(err)?;
|
||||
Ok(Some(result(&req).await?).filter(|v| !v.is_undefined()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_disk_path_round_trips_its_handle_id_and_name() {
|
||||
let p = path("18f-2a", "Bracket v2.solidcraft");
|
||||
assert_eq!(p, "/disk/18f-2a/Bracket v2.solidcraft");
|
||||
assert_eq!(parse(&p), Some(("18f-2a", "Bracket v2.solidcraft")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_disk_paths_parse() {
|
||||
for not in [
|
||||
"/opfs/a.solidcraft",
|
||||
"/downloads/a.step",
|
||||
"/upload/a.step",
|
||||
"/disk/",
|
||||
"/disk/id",
|
||||
"/disk/id/",
|
||||
"/disk//a",
|
||||
"/disk/id/x/y",
|
||||
"a.solidcraft",
|
||||
] {
|
||||
assert_eq!(parse(not), None, "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Designs already stored in OPFS (and the recovery files) keep saving to OPFS under their own
|
||||
/// names, whether or not the browser can write to the disk.
|
||||
#[test]
|
||||
fn writes_to_opfs_paths_still_go_to_opfs() {
|
||||
for p in
|
||||
["/opfs/Bracket.solidcraft", "/opfs/Bracket (2).solidcraft", "/opfs/recovery.solidcraft", "/opfs/recovery.json", "/opfs/old.solvecraft"]
|
||||
{
|
||||
assert_eq!(route(p), WriteTo::Opfs, "{p}");
|
||||
}
|
||||
// A design opened from OPFS saves to its own path; only a Save As can pick the disk.
|
||||
assert_eq!(save_target(false, true), SaveTarget::Browser);
|
||||
assert_eq!(route(&format!("{OPFS_PREFIX}Bracket.solidcraft")), WriteTo::Opfs);
|
||||
assert_eq!(route("/disk/18f-2a/Bracket.solidcraft"), WriteTo::Disk);
|
||||
assert_eq!(route("/downloads/part.step"), WriteTo::Download);
|
||||
assert_eq!(route("/upload/part.step"), WriteTo::Memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_unreferenced_handles_unused_for_long_are_forgotten() {
|
||||
let keep: std::collections::HashSet<String> = ["recent".to_string()].into();
|
||||
let day = 24.0 * 3600.0 * 1000.0;
|
||||
let now = 1000.0 * day;
|
||||
let ids = vec![
|
||||
("recent".to_string(), Some(now - 200.0 * day)),
|
||||
("old".to_string(), Some(now - 100.0 * day)),
|
||||
("fresh".to_string(), Some(now - 5.0 * day)),
|
||||
("unstamped".to_string(), None),
|
||||
];
|
||||
let plan = plan_prune(&ids, &keep, now, HANDLE_MAX_AGE_MS);
|
||||
assert_eq!(plan, PrunePlan { delete: vec!["old".to_string()], stamp: vec!["unstamped".to_string()] });
|
||||
assert_eq!(plan_prune(&[], &keep, now, HANDLE_MAX_AGE_MS), PrunePlan::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_ids_differ_by_time_and_by_chance() {
|
||||
assert_ne!(make_id(1, 5), make_id(2, 5));
|
||||
assert_ne!(make_id(1, 5), make_id(1, 6));
|
||||
assert!(!make_id(0, 0).contains('/'));
|
||||
}
|
||||
|
||||
/// Save As: a design goes to the disk where the browser can pick files and to the browser's
|
||||
/// storage where it cannot (today's behaviour); an export is a download either way.
|
||||
#[test]
|
||||
fn save_as_chooses_between_the_disk_the_browsers_storage_and_a_download() {
|
||||
assert_eq!(save_target(true, true), SaveTarget::Disk);
|
||||
assert_eq!(save_target(false, true), SaveTarget::Browser);
|
||||
assert_eq!(save_target(true, false), SaveTarget::Download);
|
||||
assert_eq!(save_target(false, false), SaveTarget::Download);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_design_opens_use_the_disk_dialog() {
|
||||
assert!(opens_design(&[]));
|
||||
assert!(opens_design(&["solidcraft"]));
|
||||
assert!(opens_design(&["solvecraft"]));
|
||||
assert!(!opens_design(&["step", "stp"]));
|
||||
assert!(!opens_design(&["json"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_dialog_offers_the_type_of_the_name() {
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
assert_eq!(save_type("Plate.JSON").2, ".json");
|
||||
assert_eq!(save_type("Plate").2, ".solidcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_dialog_is_not_an_error() {
|
||||
assert!(is_abort("AbortError"));
|
||||
assert!(!is_abort("NotAllowedError"));
|
||||
assert!(!is_abort("SecurityError"));
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
|
||||
//! 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"). Where the File System Access API
|
||||
//! exists a design also lives on the computer's disk, saved and opened through the browser's file
|
||||
//! dialogs (`disk.rs`). An autosave of unsaved work is kept in OPFS and offered again after a
|
||||
//! reload or a crash.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
|
||||
use crate::disk;
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
|
||||
|
||||
/// Where OPFS files appear to the engine.
|
||||
pub const OPFS: &str = disk::OPFS_PREFIX;
|
||||
/// Paths written here are handed to the user as downloads.
|
||||
pub const DOWNLOADS: &str = disk::DOWNLOADS_PREFIX;
|
||||
/// Files brought in from the computer.
|
||||
pub const UPLOAD: &str = "/upload/";
|
||||
/// 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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Intent {
|
||||
/// Open it (a design, or STEP/mesh as a new design).
|
||||
Open,
|
||||
/// Add its bodies to the open design (STEP, 3MF, STL).
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// A stored design, as the file panel lists it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
/// Last written (ms since 1970; 0 when unknown).
|
||||
pub modified: f64,
|
||||
}
|
||||
|
||||
/// State shared between the async browser calls and the app's frames.
|
||||
#[derive(Default)]
|
||||
pub struct Files {
|
||||
pub entries: RefCell<Vec<Entry>>,
|
||||
/// Files read from the computer: (name, bytes, what to do).
|
||||
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
|
||||
/// Designs opened from the computer's disk: their paths in the engine's files.
|
||||
pub opened: RefCell<Vec<String>>,
|
||||
/// The ids of the files remembered from earlier sessions (see `disk::restore`).
|
||||
pub disk_known: RefCell<std::collections::HashSet<String>>,
|
||||
/// Recent designs whose file is gone: to drop from the recent list.
|
||||
pub gone: RefCell<Vec<String>>,
|
||||
/// Disk designs whose last write failed: they are not saved.
|
||||
pub unsaved: RefCell<Vec<String>>,
|
||||
/// Messages for the status line (errors and confirmations).
|
||||
pub messages: RefCell<Vec<(String, bool)>>,
|
||||
/// The stored designs are loaded (the app may open them).
|
||||
pub ready: Cell<bool>,
|
||||
/// The file panel is open.
|
||||
pub panel: Cell<bool>,
|
||||
/// An autosave left by an earlier session: its design's name and file.
|
||||
pub recovery: RefCell<Option<(String, Option<String>)>>,
|
||||
/// The app's context, to wake it when a browser call finishes.
|
||||
pub ctx: RefCell<Option<egui::Context>>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
pub fn say(&self, text: impl Into<String>, error: bool) {
|
||||
self.messages.borrow_mut().push((text.into(), error));
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Writing the design at disk `path` failed: say so, and take its "saved" back.
|
||||
pub fn failed_disk(&self, path: &str, error: &str) {
|
||||
self.unsaved.borrow_mut().push(path.to_string());
|
||||
self.say(format!("Not saved to your computer: {error}"), true);
|
||||
}
|
||||
|
||||
/// A file arrived from the computer.
|
||||
fn arrive(&self, name: String, bytes: Vec<u8>, intent: Intent) {
|
||||
self.incoming.borrow_mut().push((name, bytes, intent));
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Run a frame (the app sleeps until something happens).
|
||||
pub fn wake(&self) {
|
||||
if let Some(c) = self.ctx.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The private file system isn't there (an insecure page: http on a network address, or an
|
||||
/// old browser): designs are kept in local storage instead.
|
||||
static LOCAL: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Local storage keys of stored files.
|
||||
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())
|
||||
}
|
||||
|
||||
/// Text as is, anything else as hex (local storage holds strings).
|
||||
fn encode(bytes: &[u8]) -> String {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(t) => format!("t{t}"),
|
||||
Err(_) => {
|
||||
let mut s = String::with_capacity(bytes.len() * 2 + 1);
|
||||
s.push('x');
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(s: &str) -> Vec<u8> {
|
||||
if let Some(t) = s.strip_prefix('t') {
|
||||
return t.as_bytes().to_vec();
|
||||
}
|
||||
let h = s.strip_prefix('x').unwrap_or("");
|
||||
(0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect()
|
||||
}
|
||||
|
||||
/// Is the browser's storage local storage (no OPFS here)?
|
||||
pub fn local_only() -> bool {
|
||||
LOCAL.with(Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// The browser's storage manager: missing on insecure pages (http on a network address) and in
|
||||
/// old browsers, where calling into it would throw.
|
||||
fn storage_manager() -> Option<web_sys::StorageManager> {
|
||||
let nav = web_sys::window()?.navigator();
|
||||
let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?;
|
||||
Some(sm.unchecked_into())
|
||||
}
|
||||
|
||||
/// Does the storage manager have `method`?
|
||||
fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
|
||||
Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function())
|
||||
}
|
||||
|
||||
async fn root() -> Result<FileSystemDirectoryHandle, String> {
|
||||
let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
|
||||
let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
|
||||
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
|
||||
let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
|
||||
blob_bytes(&file).await
|
||||
}
|
||||
|
||||
async fn blob_bytes(b: &web_sys::Blob) -> Result<Vec<u8>, String> {
|
||||
let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?;
|
||||
Ok(Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result<FileSystemFileHandle, String> {
|
||||
let opts = FileSystemGetFileOptions::new();
|
||||
opts.set_create(create);
|
||||
JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())
|
||||
}
|
||||
|
||||
async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if local_only() {
|
||||
return local()?
|
||||
.set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
|
||||
.map_err(|e| format!("local storage is full or blocked: {}", err(e)));
|
||||
}
|
||||
let dir = root().await?;
|
||||
let h = file_handle(&dir, name, true).await?;
|
||||
let w: FileSystemWritableFileStream =
|
||||
JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
|
||||
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
|
||||
JsFuture::from(w.close()).await.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_file(name: &str) -> Result<(), String> {
|
||||
if local_only() {
|
||||
let st = local()?;
|
||||
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
|
||||
return st.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
|
||||
}
|
||||
let dir = root().await?;
|
||||
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every stored file in local storage.
|
||||
fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
|
||||
let st = local()?;
|
||||
let mut out = Vec::new();
|
||||
for i in 0..st.length().unwrap_or(0).min(10_000) {
|
||||
let Some(k) = st.key(i).ok().flatten() else { continue };
|
||||
let Some(name) = k.strip_prefix(LOCAL_PREFIX).or_else(|| k.strip_prefix(LEGACY_LOCAL_PREFIX)) else { continue };
|
||||
if let Some(v) = st.get_item(&k).ok().flatten() {
|
||||
out.push((name.to_string(), decode(&v), 0.0));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on.
|
||||
async fn read_all() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
|
||||
let dir = match root().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::warn!("no OPFS ({e}): designs go to local storage");
|
||||
LOCAL.with(|l| l.set(true));
|
||||
return read_local();
|
||||
}
|
||||
};
|
||||
let iter: JsValue = dir.values().into();
|
||||
let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..10_000 {
|
||||
let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?;
|
||||
if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::<FileSystemFileHandle>() else { continue };
|
||||
let file =
|
||||
match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::<web_sys::File>().map_err(|_| "not a file".to_string())) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}", h.name());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match blob_bytes(&file).await {
|
||||
Ok(b) => out.push((h.name(), b, file.last_modified())),
|
||||
Err(e) => log::warn!("{}: {e}", h.name()),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ask the browser to keep the storage (not evict it under pressure), then load every stored
|
||||
/// file into the engine's files.
|
||||
pub fn start(files: &Rc<Files>) {
|
||||
if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist"))
|
||||
&& let Ok(p) = sm.persist()
|
||||
{
|
||||
spawn_local(async move {
|
||||
let _ = JsFuture::from(p).await;
|
||||
});
|
||||
}
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
match disk::known_ids().await {
|
||||
Ok(ids) => files.disk_known.borrow_mut().extend(ids),
|
||||
Err(e) => log::info!("no remembered files: {e}"),
|
||||
}
|
||||
match read_all().await {
|
||||
Ok(all) => {
|
||||
if local_only() {
|
||||
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();
|
||||
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
|
||||
continue;
|
||||
}
|
||||
if name != RECOVERY {
|
||||
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
|
||||
}
|
||||
vfs::insert(&format!("{OPFS}{name}"), bytes);
|
||||
}
|
||||
entries.sort_by_key(|a| a.name.to_lowercase());
|
||||
*files.entries.borrow_mut() = entries;
|
||||
// An autosave without its design file is nothing to recover.
|
||||
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
|
||||
*files.recovery.borrow_mut() = None;
|
||||
}
|
||||
}
|
||||
Err(e) => files.say(format!("browser storage: {e}"), true),
|
||||
}
|
||||
files.ready.set(true);
|
||||
files.wake();
|
||||
});
|
||||
}
|
||||
|
||||
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
|
||||
pub fn flush(files: &Rc<Files>) {
|
||||
for (path, bytes) in vfs::take_writes() {
|
||||
match disk::route(&path) {
|
||||
disk::WriteTo::Opfs => {
|
||||
let name = path.strip_prefix(OPFS).unwrap_or(&path).to_string();
|
||||
{
|
||||
let mut e = files.entries.borrow_mut();
|
||||
e.retain(|x| x.name != name);
|
||||
if name != RECOVERY && name != RECOVERY_META {
|
||||
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
|
||||
e.sort_by_key(|a| a.name.to_lowercase());
|
||||
}
|
||||
}
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
if let Err(e) = write_file(&name, &bytes).await {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
disk::WriteTo::Disk => disk::enqueue(files, &path, bytes),
|
||||
disk::WriteTo::Download => {
|
||||
let name = path.strip_prefix(DOWNLOADS).unwrap_or(&path);
|
||||
if let Err(e) = download(name, &bytes) {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
}
|
||||
disk::WriteTo::Memory => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `bytes` to the user as a download named `name`.
|
||||
pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let parts = Array::new();
|
||||
parts.push(&Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/octet-stream");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
// Let the browser start the download before the URL goes.
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a stored design.
|
||||
pub fn delete(files: &Rc<Files>, name: &str) {
|
||||
let _ = vfs::remove(&format!("{OPFS}{name}"));
|
||||
files.entries.borrow_mut().retain(|e| e.name != name);
|
||||
let (files, name) = (files.clone(), name.to_string());
|
||||
spawn_local(async move {
|
||||
if let Err(e) = remove_file(&name).await {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rename a stored design (a copy under the new name, then the old one goes: not every browser
|
||||
/// can move OPFS files).
|
||||
pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
let to = to.trim();
|
||||
if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 {
|
||||
return Err("give a file name without slashes".into());
|
||||
}
|
||||
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}"), 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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep (or drop) the autosave of the open design.
|
||||
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 = 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 {
|
||||
write_file(RECOVERY, &bytes).await?;
|
||||
write_file(RECOVERY_META, meta.as_bytes()).await
|
||||
};
|
||||
if let Err(e) = r.await {
|
||||
files.say(format!("autosave: {e}"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
None => spawn_local(async move {
|
||||
let _ = remove_file(RECOVERY).await;
|
||||
let _ = remove_file(LEGACY_RECOVERY).await;
|
||||
let _ = remove_file(RECOVERY_META).await;
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`…
|
||||
pub fn free_name(name: &str) -> String {
|
||||
let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new()));
|
||||
for i in 1..1000 {
|
||||
let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") };
|
||||
let p = format!("{OPFS}{n}");
|
||||
if !vfs::exists(&p) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64)
|
||||
}
|
||||
|
||||
/// The autosaved design's path (in the engine's files) for opening it.
|
||||
pub fn recovery_path() -> String {
|
||||
format!("{OPFS}{RECOVERY}")
|
||||
}
|
||||
|
||||
/// Pick files on the computer: the File System Access picker where there is one, else an
|
||||
/// `<input type=file>`. Read files arrive in `files.incoming`.
|
||||
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::<Function>().ok());
|
||||
match picker {
|
||||
Some(f) => {
|
||||
// A design from the computer's disk keeps its file, so Save writes back to it.
|
||||
let on_disk = intent == Intent::Open && disk::supported();
|
||||
let opts = if on_disk {
|
||||
disk::open_options()
|
||||
} else {
|
||||
let opts = Object::new();
|
||||
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
|
||||
opts
|
||||
};
|
||||
let promise = f.call1(&window, &opts);
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
|
||||
let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?;
|
||||
for h in handles.iter() {
|
||||
let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?;
|
||||
let bytes = read_handle(&h).await?;
|
||||
if on_disk && solidcraft_engine::io::is_design_path(&h.name()) {
|
||||
let path = disk::arrive(&h, bytes);
|
||||
files.opened.borrow_mut().push(path);
|
||||
files.wake();
|
||||
} else {
|
||||
files.arrive(h.name(), bytes, intent);
|
||||
}
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
};
|
||||
match r.await {
|
||||
Ok(()) => {}
|
||||
// Closing the picker is no error.
|
||||
Err(e) if e.contains("abort") || e.contains("Abort") => {}
|
||||
Err(e) => files.say(format!("open: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
None => input_picker(files, intent),
|
||||
}
|
||||
}
|
||||
|
||||
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(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
|
||||
// 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() {
|
||||
let _ = body.append_child(&input);
|
||||
}
|
||||
let (files, el) = (files.clone(), input.clone());
|
||||
let on_change = Closure::once_into_js(move || {
|
||||
el.remove();
|
||||
let Some(list) = el.files() else { return };
|
||||
for i in 0..list.length() {
|
||||
let Some(f) = list.get(i) else { continue };
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
match blob_bytes(&f).await {
|
||||
Ok(b) => files.arrive(f.name(), b, intent),
|
||||
Err(e) => files.say(format!("{}: {e}", f.name()), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
input.set_onchange(Some(on_change.unchecked_ref()));
|
||||
input.click();
|
||||
}
|
||||
|
||||
/// Read files dropped on the page.
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(b) => files.arrive(name, b, Intent::Insert),
|
||||
Err(e) => files.say(format!("{name}: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a
|
||||
//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event
|
||||
//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for
|
||||
//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it
|
||||
//! gets its steps, unless the app is already running installed.
|
||||
|
||||
use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps};
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
|
||||
/// Where index.html keeps the browser's offer.
|
||||
const OFFER: &str = "solidcraftInstall";
|
||||
|
||||
/// The browser's offer, kept by index.html (None: none, or it was used).
|
||||
fn offer() -> Option<js_sys::Object> {
|
||||
let window = web_sys::window()?;
|
||||
js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok()
|
||||
}
|
||||
|
||||
/// Show the browser's install prompt (from the File menu click, so the page may).
|
||||
fn prompt() {
|
||||
let Some(offer) = offer() else { return };
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
|
||||
}
|
||||
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
|
||||
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
|
||||
.and_then(|f| f.call0(&offer))
|
||||
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
|
||||
match shown {
|
||||
// Accepted or dismissed, the browser handles it; a refusal is only logged.
|
||||
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("install prompt: {e:?}");
|
||||
}
|
||||
}),
|
||||
Err(e) => log::warn!("install prompt: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page runs as the installed app (its own window, or from the Home Screen on iOS).
|
||||
fn running_installed(window: &web_sys::Window) -> bool {
|
||||
let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches());
|
||||
let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
standalone || home_screen
|
||||
}
|
||||
|
||||
/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser
|
||||
/// makes or withdraws its offer (even with nothing else happening).
|
||||
pub fn service(ctx: &egui::Context) -> InstallApp {
|
||||
// Safari's steps, decided once: the browser and the way the page runs don't change.
|
||||
let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| {
|
||||
let nav = w.navigator();
|
||||
let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points())
|
||||
});
|
||||
if let Some(window) = web_sys::window() {
|
||||
for event in ["beforeinstallprompt", "appinstalled"] {
|
||||
let ctx = ctx.clone();
|
||||
let redraw = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
redraw.forget();
|
||||
}
|
||||
}
|
||||
let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) };
|
||||
InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) }
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
//! SolidCraft in the browser.
|
||||
//!
|
||||
//! 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 (remembered until
|
||||
//! `?webgpu`), `?sample` opens the sample part.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod disk;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::disk::{self, SaveTarget};
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
/// A 32-bit float depth buffer (no stencil): WebGPU and WebGL2 both have it, and the viewport
|
||||
/// draws it with reversed depth, whose precision follows the distance from the eye (#1434).
|
||||
const DEPTH_BITS: u8 = 32;
|
||||
|
||||
/// The loading animation (assets/styles/web/loading-logo.svg) settles into the finished mark
|
||||
/// about 3.2 s after the page starts; this adds a short hold. The app doesn't land before it.
|
||||
const LOADING_MIN_MS: f64 = 3600.0;
|
||||
/// The fade from the loading screen into the app.
|
||||
const LOADING_FADE_MS: i32 = 300;
|
||||
|
||||
/// Take the loading screen away once the animation has played to the finished mark: wait out
|
||||
/// what is left of `LOADING_MIN_MS`, fade it, then remove it.
|
||||
fn dismiss_loading(el: web_sys::Element) {
|
||||
let Some(window) = web_sys::window() else {
|
||||
el.remove();
|
||||
return;
|
||||
};
|
||||
let now = window.performance().map_or(LOADING_MIN_MS, |p| p.now());
|
||||
let wait = (LOADING_MIN_MS - now).max(0.0) as i32;
|
||||
let fade = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = el.set_attribute("class", "done");
|
||||
let gone = wasm_bindgen::closure::Closure::once_into_js(move || el.remove());
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(gone.unchecked_ref(), LOADING_FADE_MS);
|
||||
}
|
||||
});
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(fade.unchecked_ref(), wait);
|
||||
}
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
|
||||
thread_local! {
|
||||
/// An open design has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
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,
|
||||
api: Api,
|
||||
files: Rc<Files>,
|
||||
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
||||
autosaved_at: f64,
|
||||
autosaved: Option<u64>,
|
||||
/// A stored design being renamed: (its name, the new name).
|
||||
renaming: Option<(String, String)>,
|
||||
/// The rename field takes the keyboard when it appears.
|
||||
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,
|
||||
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
|
||||
cjk_bytes: Option<Vec<u8>>,
|
||||
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files from the computer, writes to store, messages and the autosave.
|
||||
fn files_tick(&mut self, ctx: &egui::Context) {
|
||||
if self.files.ctx.borrow().is_none() {
|
||||
*self.files.ctx.borrow_mut() = Some(ctx.clone());
|
||||
}
|
||||
// A ready picker answer is consumed in app.logic later in this frame. Keep
|
||||
// incoming files queued until then so they cannot replace its target design.
|
||||
if self.app.file_picker_busy() {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
files::flush(&self.files);
|
||||
// A write to the computer's disk that failed: the design is not saved after all.
|
||||
for path in self.files.unsaved.borrow_mut().drain(..) {
|
||||
if self.app.session.path.as_deref() == Some(path.as_str()) {
|
||||
self.app.session.mark_unsaved();
|
||||
}
|
||||
}
|
||||
// Designs opened from the computer's disk (the files window): open them.
|
||||
let opened: Vec<_> = self.files.opened.borrow_mut().drain(..).collect();
|
||||
for path in opened {
|
||||
self.app.open_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
// Recent designs whose file is gone are dropped from the list.
|
||||
for path in self.files.gone.borrow_mut().drain(..) {
|
||||
self.app.home.recent.retain(|r| *r != path);
|
||||
self.app.home.opened_at.remove(&path);
|
||||
}
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
// A glTF file's buffers picked with it are kept next to it, not opened.
|
||||
incoming.retain_mut(|(name, bytes, _)| {
|
||||
let companion =
|
||||
std::path::Path::new(name.as_str()).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("bin"));
|
||||
if companion {
|
||||
vfs::insert(&format!("{}{name}", files::UPLOAD), std::mem::take(bytes));
|
||||
}
|
||||
!companion
|
||||
});
|
||||
for (name, bytes, intent) in incoming {
|
||||
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);
|
||||
if let Err(e) = vfs::write(&path, &bytes) {
|
||||
self.app.set_status(e.to_string(), true);
|
||||
}
|
||||
files::flush(&self.files);
|
||||
path
|
||||
} else {
|
||||
let path = format!("{}{name}", files::UPLOAD);
|
||||
vfs::insert(&path, bytes);
|
||||
path
|
||||
};
|
||||
match intent {
|
||||
Intent::Open => self.app.open_path(&path),
|
||||
Intent::Insert => self.app.drop_path(&path),
|
||||
}
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
if self.files.ready.get() && !self.recents_synced {
|
||||
self.recents_synced = true;
|
||||
self.sync_recents();
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
|
||||
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
|
||||
let s = &self.app.session;
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.design(), s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
// Come back for it even when nothing else happens.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
} else if !s.is_dirty() && self.autosaved.is_some() {
|
||||
files::autosave(&self.files, None);
|
||||
self.autosaved = None;
|
||||
}
|
||||
}
|
||||
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// The start page's recent designs are the stored ones: the remembered order first (those
|
||||
/// still stored), then the others, newest first.
|
||||
fn sync_recents(&mut self) {
|
||||
let mut entries = self.files.entries.borrow().clone();
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
let known = self.files.disk_known.borrow().clone();
|
||||
// A design on the computer's disk stays listed while its file is remembered.
|
||||
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
|
||||
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(solidcraft_ui_egui::home::MAX_RECENT);
|
||||
// Remembered files that no recent design or autosave names are forgotten in time.
|
||||
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
|
||||
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
|
||||
disk::prune(keep);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
fn file_panel(&mut self, ctx: &egui::Context) {
|
||||
if !self.files.panel.get() {
|
||||
return;
|
||||
}
|
||||
let mut act: Option<(&'static str, String)> = None;
|
||||
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
|
||||
.cards(3.0)
|
||||
.show_body(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, "Open from computer…", ui_kit::Button::Normal).clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.on_hover_text("STEP, 3MF or STL into the open design")
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
}
|
||||
if ui_kit::button(ui, "Download open design", ui_kit::Button::Normal).clicked() {
|
||||
act = Some(("download_open", String::new()));
|
||||
}
|
||||
});
|
||||
if !disk::supported() {
|
||||
ui.weak("This browser can't save to a folder: designs stay in this browser. Download design saves a file.");
|
||||
}
|
||||
ui.separator();
|
||||
if !self.files.ready.get() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading the browser's storage…");
|
||||
});
|
||||
return;
|
||||
}
|
||||
let entries = self.files.entries.borrow().clone();
|
||||
if entries.is_empty() {
|
||||
ui.label("Designs you save appear here. They stay in this browser.");
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for e in &entries {
|
||||
match &mut self.renaming {
|
||||
Some((from, to)) if *from == e.name => {
|
||||
let r = ui_kit::cell_editor(ui, ui.make_persistent_id(("stored_file_name", from.as_str())), to, "", false);
|
||||
if std::mem::take(&mut self.rename_focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
if r.lost_focus() {
|
||||
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
("rename", to.clone())
|
||||
} else {
|
||||
("rename_cancel", String::new())
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(&e.name);
|
||||
}
|
||||
}
|
||||
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, "Open", ui_kit::Button::Small).clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.on_hover_text("Into the open design (STEP, 3MF, STL)")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
if ui_kit::button(ui, "Rename", ui_kit::Button::Small).clicked() {
|
||||
self.renaming = Some((e.name.clone(), e.name.clone()));
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
|
||||
.on_hover_text("A copy in a file you choose; the stored design stays here")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("save_copy", e.name.clone()));
|
||||
}
|
||||
if ui_kit::button(ui, "Download", ui_kit::Button::Small).clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
if ui_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
|
||||
act = Some(("delete", e.name.clone()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if closed {
|
||||
self.files.panel.set(false);
|
||||
self.renaming = None;
|
||||
}
|
||||
let Some((what, name)) = act else { return };
|
||||
let path = format!("{}{name}", files::OPFS);
|
||||
match what {
|
||||
"open" => {
|
||||
self.app.open_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"insert" => {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"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);
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e.to_string(), true),
|
||||
},
|
||||
"save_copy" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => disk::save_copy(&self.files, &name, b),
|
||||
Err(e) => self.app.set_status(e.to_string(), true),
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let result = self
|
||||
.app
|
||||
.session
|
||||
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
files::delete(&self.files, &name);
|
||||
self.app.home.recent.retain(|r| *r != path);
|
||||
}
|
||||
"rename_cancel" => self.renaming = None,
|
||||
"rename" => {
|
||||
if let Some((from, _)) = self.renaming.take() {
|
||||
match files::rename(&self.files, &from, &name) {
|
||||
Ok(()) => {
|
||||
// Recent designs follow the new name.
|
||||
let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
|
||||
for r in &mut self.app.home.recent {
|
||||
if *r == old {
|
||||
*r = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer the autosave an earlier session left.
|
||||
fn recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
|
||||
let mut answer = None;
|
||||
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_recovery"), "restore", "Recover unsaved work?")
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, "Recover", ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, "Discard", ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
let Some(recover) = answer else { return };
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
if recover {
|
||||
self.app.open_path(&files::recovery_path());
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
}
|
||||
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) => {
|
||||
self.cjk_bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
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 if solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
self.apply_fonts(ctx);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
self.fetch_font(ctx);
|
||||
}
|
||||
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
|
||||
// to Ukrainian (or English) must put the Latin face back in front.
|
||||
if self.app.preferences.language != self.font_language {
|
||||
self.apply_fonts(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
|
||||
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
match self.cjk_bytes.clone() {
|
||||
Some(bytes) => {
|
||||
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
|
||||
}
|
||||
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
|
||||
}
|
||||
self.font_language.clone_from(&self.app.preferences.language);
|
||||
}
|
||||
|
||||
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
|
||||
/// fetch the font, so the text does not stay tofu (#720).
|
||||
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
self.fetch_font(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_font(&self, ctx: &egui::Context) {
|
||||
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.api.poll();
|
||||
self.space_mouse.logic(&mut self.app, ctx);
|
||||
self.files_tick(ctx);
|
||||
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);
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
let ctx = ui.ctx().clone();
|
||||
self.file_panel(&ctx);
|
||||
self.recovery_prompt(&ctx);
|
||||
self.watch_text_for_font(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
|
||||
/// has no query, so the choice has to outlive the page it was made on.
|
||||
const WEBGL_KEY: &str = "solidcraft.webgl";
|
||||
|
||||
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
|
||||
fn webgl() -> bool {
|
||||
let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
|
||||
let q = query();
|
||||
if q.contains("webgl") {
|
||||
if let Some(s) = &storage {
|
||||
let _ = s.set_item(WEBGL_KEY, "1");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if q.contains("webgpu") {
|
||||
if let Some(s) = &storage {
|
||||
let _ = s.remove_item(WEBGL_KEY);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if webgl()
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
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, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
|
||||
let (restorable, restorer) = (files.clone(), files.clone());
|
||||
let services = Services {
|
||||
// Open shows the browser's open dialog for a design where it has one
|
||||
// (the file keeps its handle, so Save writes back to it); otherwise,
|
||||
// and for inserting STEP or meshes, the browser's files window.
|
||||
pick_open: Some(Box::new(move |exts: &[&str]| {
|
||||
if disk::handles_open(exts) {
|
||||
return Box::pin(disk::pick_open(&opener));
|
||||
}
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
})),
|
||||
// A design is saved to a file the user picks, or kept in the browser
|
||||
// where the browser can't pick one; everything else is downloaded.
|
||||
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
match disk::save_target(disk::supported(), design) {
|
||||
SaveTarget::Disk => Box::pin(disk::pick_save(&saver, name)),
|
||||
SaveTarget::Browser => {
|
||||
saver.say("This browser can't save to a folder: the design is kept in this browser. File > Download design saves a file.", false);
|
||||
Box::pin(std::future::ready(Some(format!("{}{name}", files::OPFS))))
|
||||
}
|
||||
SaveTarget::Download => Box::pin(std::future::ready(Some(format!("{}{name}", files::DOWNLOADS)))),
|
||||
}
|
||||
})),
|
||||
// A recent design on the disk, from an earlier session, opens by asking
|
||||
// the browser for its remembered file.
|
||||
can_restore: Some(Box::new(move |path: &str| {
|
||||
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
|
||||
})),
|
||||
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
|
||||
download_design: Some(Box::new(files::download)),
|
||||
browser_files: Some(Box::new(move || shown.panel.set(true))),
|
||||
install_app: Some(crate::install::service(&cc.egui_ctx)),
|
||||
..Default::default()
|
||||
};
|
||||
// Thumbnails of saves and start-page cards.
|
||||
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
|
||||
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 {
|
||||
// eframe's web painter has no stencil buffer.
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("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();
|
||||
}
|
||||
if !query().contains("sample") {
|
||||
// The empty design takes Units of new designs (#1205). The sample
|
||||
// part is built in millimetres and is left that way.
|
||||
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
|
||||
let font_language = app.preferences.language.clone();
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
api,
|
||||
files,
|
||||
autosaved_at: f64::NEG_INFINITY,
|
||||
autosaved: None,
|
||||
renaming: None,
|
||||
rename_focus: false,
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
cjk_bytes: None,
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => dismiss_loading(el),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
//! The 3D mouse in the browser (#1336), through the Gamepad API.
|
||||
//!
|
||||
//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the
|
||||
//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis
|
||||
//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile
|
||||
//! for Firefox sends them there. Both come in the device's own order and signs, so the shared
|
||||
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
|
||||
//! profile.
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, Calibration, FULL_SCALE, calibrated};
|
||||
|
||||
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
|
||||
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
|
||||
const LOGITECH: u16 = 0x046d;
|
||||
const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b];
|
||||
/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the
|
||||
/// mouse side of its receivers, are not gamepads.
|
||||
const THREEDCONNEXION: u16 = 0x256f;
|
||||
/// 3DxWare's virtual joystick.
|
||||
const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d);
|
||||
|
||||
/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the
|
||||
/// joystick: in a Chromium browser both are listed, and the joystick stays at rest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Source {
|
||||
Device,
|
||||
VirtualJoystick,
|
||||
}
|
||||
|
||||
/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium
|
||||
/// browsers, `046d-c62b-Name` in Firefox.
|
||||
fn vendor_product(id: &str) -> Option<(u16, u16)> {
|
||||
fn hex(digits: &str) -> Option<u16> {
|
||||
if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None }
|
||||
}
|
||||
if let Some((_, ids)) = id.rsplit_once("Vendor: ") {
|
||||
let (vendor, rest) = ids.split_once(" Product: ")?;
|
||||
let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?;
|
||||
return Some((hex(vendor)?, hex(product)?));
|
||||
}
|
||||
let mut parts = id.splitn(3, '-');
|
||||
let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
Some((hex(vendor)?, hex(product)?))
|
||||
}
|
||||
|
||||
/// Whether a gamepad is a 3D mouse, from the id the browser gives it.
|
||||
pub fn source(id: &str) -> Option<Source> {
|
||||
match vendor_product(id)? {
|
||||
VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick),
|
||||
(THREEDCONNEXION, _) => Some(Source::Device),
|
||||
(LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a
|
||||
/// 3D mouse. A value that isn't a number reads as rest.
|
||||
pub fn axes(values: &[f64]) -> Option<Axes> {
|
||||
let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 });
|
||||
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
|
||||
}
|
||||
|
||||
/// What a calibration of the gamepad with this id is saved under: `web` and its vendor and
|
||||
/// product ids.
|
||||
pub fn device_key(id: &str) -> Option<String> {
|
||||
vendor_product(id).map(|(vendor, product)| format!("web {vendor:04x}:{product:04x}"))
|
||||
}
|
||||
|
||||
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
|
||||
pub fn choose<T>(found: impl IntoIterator<Item = (Source, T)>) -> Option<T> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, reading)| reading)
|
||||
}
|
||||
|
||||
/// Whether the cap is moving as the next frame will see it: through the device's calibration
|
||||
/// (as of the last frame) when it has one. The idle page wakes for a short-range device's push,
|
||||
/// and not for a device resting off zero; the wheel filter goes by the same.
|
||||
pub fn moving(axes: Axes, calibration: Option<Calibration>) -> bool {
|
||||
calibrated(axes, calibration).moving()
|
||||
}
|
||||
|
||||
/// The calibration as of the last frame, and the device it was for.
|
||||
pub type Cached = (Option<String>, Option<Calibration>);
|
||||
|
||||
/// Whether the cap of `device` is moving, judged with the calibration kept from the last frame:
|
||||
/// `None` when that was for another device, so another's calibration never judges this one. The
|
||||
/// waker then lets a frame look, which brings the cache up to date.
|
||||
pub fn moving_as_cached(device: &Option<String>, axes: Axes, cached: &Cached) -> Option<bool> {
|
||||
(*device == cached.0).then(|| moving(axes, cached.1))
|
||||
}
|
||||
|
||||
/// Whether the idle page's timer redraws: when the cap is moving as the last frame's calibration
|
||||
/// judges it, when the device read isn't the one that calibration was for (a frame then judges
|
||||
/// it with its own), and once when the device the last frame read is gone, so the page shows
|
||||
/// that. With no device, and none last frame, it stays quiet.
|
||||
pub fn wakes(reading: Option<(Option<String>, Axes)>, cached: &Cached) -> bool {
|
||||
match reading {
|
||||
Some((device, axes)) => moving_as_cached(&device, axes, cached).unwrap_or(true),
|
||||
None => cached.0.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::SpaceMouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, calibration, navigate_frame};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::WheelGuard;
|
||||
|
||||
use super::{Cached, axes, choose, device_key, moving_as_cached, source, wakes};
|
||||
|
||||
/// How often the page looks for the cap starting to move while nothing else redraws.
|
||||
const WAKE_MS: i32 = 50;
|
||||
|
||||
/// The 3D mouse the browser lists, if any: its calibration key and its axes.
|
||||
fn read() -> Option<(Option<String>, Axes)> {
|
||||
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
|
||||
choose(pads.iter().filter_map(|pad| {
|
||||
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
|
||||
let source = source(&pad.id())?;
|
||||
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
|
||||
Some((source, (device_key(&pad.id()), axes(&values)?)))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse once per frame and moves the view with it.
|
||||
#[derive(Default)]
|
||||
pub struct SpaceMouse {
|
||||
axes: Axes,
|
||||
/// The 3D mouse being read, as `SolveApp::space_mouse` keeps it.
|
||||
device: Option<String>,
|
||||
/// The previous frame's time and the step since it (seconds).
|
||||
last: Option<f64>,
|
||||
seconds: f64,
|
||||
wheel: WheelGuard,
|
||||
/// Listening for gamepads (from the first frame with navigation on).
|
||||
listening: bool,
|
||||
/// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse
|
||||
/// is first listed).
|
||||
waking: bool,
|
||||
/// Navigation is on (the waker reads it).
|
||||
enabled: Rc<Cell<bool>>,
|
||||
/// The calibration as of the last frame and its device (the waker and the wheel filter read it).
|
||||
cached: Rc<RefCell<Cached>>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
/// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the
|
||||
/// frame's input, before the app sees it.
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) {
|
||||
let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0);
|
||||
self.seconds = self.last.map_or(0.0, |last| now - last);
|
||||
self.last = Some(now);
|
||||
self.enabled.set(enabled);
|
||||
self.axes = Axes::default();
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
if !self.listening {
|
||||
self.listening = true;
|
||||
listen(ctx);
|
||||
}
|
||||
let reading = read();
|
||||
if reading.is_some() && !self.waking {
|
||||
self.waking = wake(ctx, &self.enabled, &self.cached);
|
||||
}
|
||||
// As read: calibration needs the readings at rest too, and `navigate_frame` leaves a
|
||||
// cap resting inside the dead zone alone.
|
||||
(self.device, self.axes) = reading.unwrap_or_default();
|
||||
let moving = moving_as_cached(&self.device, self.axes, &self.cached.borrow()).unwrap_or_else(|| self.axes.moving());
|
||||
self.wheel.filter(moving, now, &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.space_mouse.device.clone_from(&self.device);
|
||||
*self.cached.borrow_mut() = (self.device.clone(), calibration(app));
|
||||
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame
|
||||
/// finds it without waiting for the pointer.
|
||||
fn listen(ctx: &egui::Context) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let ctx = ctx.clone();
|
||||
let on_connect = Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_connect.forget();
|
||||
}
|
||||
|
||||
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
|
||||
/// redraws when the cap starts moving; the frames that follow keep going on their own.
|
||||
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>, cached: &Rc<RefCell<Cached>>) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let (ctx, enabled, cached) = (ctx.clone(), Rc::clone(enabled), Rc::clone(cached));
|
||||
let tick = Closure::<dyn FnMut()>::new(move || {
|
||||
if enabled.get() && wakes(read(), &cached.borrow()) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok();
|
||||
// The timer lives as long as the page.
|
||||
tick.forget();
|
||||
started
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_3d_mice_in_each_browsers_ids() {
|
||||
// As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10).
|
||||
assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device));
|
||||
assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick));
|
||||
assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick));
|
||||
// 3Dconnexion's own vendor id, any product; the older devices under Logitech's.
|
||||
assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device));
|
||||
assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device));
|
||||
assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device));
|
||||
assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device));
|
||||
for product in LOGITECH_3D_MICE {
|
||||
assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device));
|
||||
assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibration_is_saved_under_the_ids_whichever_browser_lists_it() {
|
||||
assert_eq!(device_key("SpaceMouse Pro (Vendor: 046d Product: c62b)").as_deref(), Some("web 046d:c62b"));
|
||||
assert_eq!(device_key("46d-c62b-SpaceMouse Pro").as_deref(), Some("web 046d:c62b"));
|
||||
assert_eq!(device_key("Xbox Wireless Controller"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_page_wakes_for_motion_as_calibrated_not_as_read() {
|
||||
let axis =
|
||||
|source, rest, full| solidcraft_ui_egui::space_mouse::AxisCalibration { source, reversed: false, rest, dead_zone: 0, full: [full; 2] };
|
||||
// A short-range device: 15 of its 50 is under the raw dead zone, but 105 of 350 calibrated.
|
||||
let short = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 0, 50)) };
|
||||
let push = Axes { translation: [15, 0, 0], rotation: [0; 3] };
|
||||
assert!(!push.moving() && moving(push, Some(short)));
|
||||
// A device resting at 100 on every axis: past the raw dead zone, but at rest calibrated.
|
||||
let offset = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 100, 350)) };
|
||||
let resting = Axes { translation: [100; 3], rotation: [100; 3] };
|
||||
assert!(resting.moving() && !moving(resting, Some(offset)));
|
||||
// Without a calibration, as read.
|
||||
assert!(!moving(push, None) && moving(resting, None));
|
||||
// Kept from the last frame: only for the device it was for.
|
||||
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
|
||||
assert_eq!(moving_as_cached(&b, push, &(b.clone(), Some(short))), Some(true));
|
||||
assert_eq!(moving_as_cached(&b, push, &(a.clone(), Some(offset))), None, "A's calibration doesn't judge B");
|
||||
assert_eq!(moving_as_cached(&b, push, &(None, None)), None);
|
||||
assert_eq!(moving_as_cached(&a, resting, &(a.clone(), None)), Some(true), "A was read without a calibration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_page_wakes_once_for_a_device_change_and_stays_quiet_without_one() {
|
||||
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
|
||||
let still = Axes::default();
|
||||
let last_frame_read_a: Cached = (a.clone(), None);
|
||||
assert!(!wakes(Some((a.clone(), still)), &last_frame_read_a), "A, at rest");
|
||||
assert!(wakes(Some((b.clone(), still)), &last_frame_read_a), "A to B: a frame looks at B");
|
||||
assert!(wakes(None, &last_frame_read_a), "A to none: a frame shows it's gone");
|
||||
assert!(!wakes(None, &(None, None)), "none to none: quiet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
|
||||
for id in [
|
||||
"Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)",
|
||||
"046d-c21d-Logitech Gamepad F310",
|
||||
"G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)",
|
||||
"046d-c215-Logitech Extreme 3D",
|
||||
"NuLOOQ navigator (Vendor: 046d Product: c640)",
|
||||
"Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"45e-28e-Xbox 360 Wired Controller",
|
||||
"beef-046e-Unknown Gamepad",
|
||||
"beee-046d-Unknown Gamepad",
|
||||
"",
|
||||
"-",
|
||||
"--",
|
||||
"256f-",
|
||||
"256f-c635",
|
||||
"Vendor: 256f",
|
||||
"Vendor: 256f Product: ",
|
||||
"x (Vendor: 256fff Product: c635)",
|
||||
"x (Vendor: zz6f Product: c635)",
|
||||
"12345-c635-x",
|
||||
"256f--x",
|
||||
"SpaceMouse Pro",
|
||||
] {
|
||||
assert_eq!(source(id), None, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_never_panic() {
|
||||
// Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short.
|
||||
for id in [
|
||||
"Vendor: é Product: ü",
|
||||
"Vendor: Vendor: 256f Product: c635)",
|
||||
"é-ü-ö",
|
||||
"Ⅰ (Vendor: 256f Product: c6Ⅰ)",
|
||||
"\u{0}-\u{0}-\u{0}",
|
||||
"Vendor: 256f Product: c635 Vendor: ",
|
||||
] {
|
||||
let _ = source(id);
|
||||
}
|
||||
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
|
||||
let alphabet: Vec<char> = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect();
|
||||
for _ in 0..20_000 {
|
||||
let len = (seed % 40) as usize;
|
||||
let id: String = (0..len)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
alphabet[(seed % alphabet.len() as u64) as usize]
|
||||
})
|
||||
.collect();
|
||||
if let Some(Source::Device) = source(&id) {
|
||||
let (vendor, product) = vendor_product(&id).expect("recognised ids have both");
|
||||
assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_axes_are_the_devices_raw_units() {
|
||||
// Full deflection is the desktop's 350, and the browser's scale keeps the device's steps.
|
||||
let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes");
|
||||
assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] });
|
||||
for raw in -350_i16..=350 {
|
||||
let value = f64::from(raw) / 350.0;
|
||||
assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw));
|
||||
}
|
||||
// Past full deflection is full deflection; anything that isn't a number is rest.
|
||||
let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes");
|
||||
assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] });
|
||||
// A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored.
|
||||
assert_eq!(super::axes(&[1.0; 5]), None);
|
||||
assert_eq!(super::axes(&[]), None);
|
||||
assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_is_read_before_the_virtual_joystick() {
|
||||
let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] };
|
||||
let other = Axes { translation: [0, 200, 0], rotation: [0; 3] };
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving));
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving));
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
|
||||
// Two devices: the first listed.
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
|
||||
assert_eq!(choose::<Axes>([]), None);
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// SolidCraft's service worker: the web app keeps working offline and updates itself.
|
||||
//
|
||||
// `cargo xtask web-offline` (Trunk's post_build hook) writes the build into the line below: the
|
||||
// version (workspace version + a hash of every built file) and the files to keep, each with its
|
||||
// SHA-256, and the same as `build.json` next to it. Files the app only fetches when it needs
|
||||
// them (the CJK font) aren't kept: they come from the network. Every download is checked
|
||||
// against its SHA-256: a deployment that lands mid-download (another build's `index.html`) fails
|
||||
// the download instead of mixing into the kept build. A new build changes this file, so the
|
||||
// browser installs the new worker, which keeps the whole new build before taking over.
|
||||
//
|
||||
// A launch only ever starts a build whose every file is kept, so a half-downloaded update can't
|
||||
// break it. It asks for `build.json` (waiting at most NAVIGATE_TIMEOUT_MS): offline, slow or
|
||||
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
|
||||
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
|
||||
// started. When the site has no `build.json` (the app was removed), launches go to the network,
|
||||
// the kept build answering when that fails. So they do when `build.json` is redirected or
|
||||
// refused (a sign-in in front of the app): the browser then shows the sign-in. Each page then
|
||||
// gets its files from the build it started (remembered in PAGES), never from another build
|
||||
// with a file of the same name.
|
||||
//
|
||||
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
|
||||
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
|
||||
// an open page uses the build it started. Then unfinished downloads are dropped, and so are
|
||||
// builds replaced by a newer worker (RETIRED). The origin is shared with other apps (github.io)
|
||||
// and other copies of this one (the release zip works from any path): only caches named after
|
||||
// this worker's path (CACHE_PREFIX…, PAGES) are ever touched.
|
||||
//
|
||||
// Unbuilt (VERSION "dev", as debug builds get it), the worker retires: it drops what a release
|
||||
// build kept and unregisters, so `trunk serve` after `trunk serve --release --no-autoreload`
|
||||
// shows the development build. Deploying this file unbuilt switches the worker off for everyone.
|
||||
/* @build */ const VERSION = "dev", FILES = [];
|
||||
const RETIRE = VERSION === "dev";
|
||||
|
||||
const PATH = new URL(self.registration.scope).pathname;
|
||||
const CACHE_PREFIX = `solidcraft-app ${PATH} `;
|
||||
const CACHE = CACHE_PREFIX + VERSION;
|
||||
/** Which build each open page started: page id → cache name (or NETWORK). */
|
||||
const PAGES = `solidcraft-app-pages ${PATH}`;
|
||||
/** A page that came from the network gets its files from there too. */
|
||||
const NETWORK = "network";
|
||||
const NAVIGATE_TIMEOUT_MS = 4000;
|
||||
const UPDATE_TIMEOUT_MS = 20000;
|
||||
/** The entry that marks a cache as holding its whole build. */
|
||||
const COMPLETE = "build-complete";
|
||||
/** The entry that marks a build replaced by a newer worker. */
|
||||
const RETIRED = "build-retired";
|
||||
/** A page remembered this recently may still be loading (not yet listed among the clients). */
|
||||
const PAGE_LOADING_MS = 30 * 1000;
|
||||
|
||||
const url = (file) => new URL(file, self.registration.scope).href;
|
||||
const has = (name, entry) => caches.match(url(entry), { cacheName: name }).then(Boolean);
|
||||
|
||||
/** Run `fn` while using the cache `name`: it isn't deleted meanwhile. */
|
||||
const using = (name, fn) => navigator.locks.request(name, { mode: "shared" }, fn);
|
||||
|
||||
/** Delete the cache `name` if nothing is using it and `unwanted()` (asked once no one can start to) agrees. */
|
||||
const drop = (name, unwanted) =>
|
||||
navigator.locks.request(name, { ifAvailable: true }, async (free) => free && (await unwanted()) && caches.delete(name));
|
||||
|
||||
const hex = (buffer) => Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
/**
|
||||
* Keep every file of a build (an earlier partial download is resumed); resolves to its cache's
|
||||
* name. The first file that fails cancels the others.
|
||||
*/
|
||||
function keep(version, files) {
|
||||
const name = CACHE_PREFIX + version;
|
||||
return using(name, async () => {
|
||||
const cache = await caches.open(name);
|
||||
await cache.delete(url(RETIRED)); // Back in use (a rollback).
|
||||
if (await has(name, COMPLETE)) return name;
|
||||
const cancel = new AbortController();
|
||||
const results = await Promise.allSettled(
|
||||
files.map(async ({ file, sha256 }) => {
|
||||
try {
|
||||
if (await cache.match(url(file))) return;
|
||||
// `no-cache`: revalidate with the server, so a stale HTTP-cached page never mixes builds.
|
||||
const response = await fetch(new Request(url(file), { cache: "no-cache" }), { signal: cancel.signal });
|
||||
if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`);
|
||||
const body = await response.arrayBuffer();
|
||||
if (hex(await crypto.subtle.digest("SHA-256", body)) !== sha256) throw new Error(`${file}: not this build's`);
|
||||
await cache.put(url(file), new Response(body, { headers: response.headers }));
|
||||
} catch (error) {
|
||||
cancel.abort();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const failed = results.find((r) => r.status === "rejected");
|
||||
if (failed) throw failed.reason;
|
||||
await cache.put(url(COMPLETE), new Response(version));
|
||||
return name;
|
||||
});
|
||||
}
|
||||
|
||||
const pageEntry = (id) => url(`~page/${encodeURIComponent(id)}`);
|
||||
|
||||
/** Remember which build a page started. */
|
||||
async function started(id, build) {
|
||||
if (!id) return;
|
||||
const record = JSON.stringify({ build, at: Date.now() });
|
||||
await (await caches.open(PAGES)).put(pageEntry(id), new Response(record));
|
||||
}
|
||||
|
||||
/** The build a page started; a page started before it was remembered gets this worker's. */
|
||||
async function startedBy(id) {
|
||||
const response = id ? await caches.match(pageEntry(id), { cacheName: PAGES }) : null;
|
||||
return response ? (await response.json()).build : CACHE;
|
||||
}
|
||||
|
||||
/** The builds open pages use (pages closed since are forgotten). */
|
||||
async function inUse() {
|
||||
const open = new Set((await self.clients.matchAll({ includeUncontrolled: true })).map((c) => c.id));
|
||||
const pages = await caches.open(PAGES);
|
||||
const builds = new Set();
|
||||
for (const request of await pages.keys()) {
|
||||
const id = decodeURIComponent(request.url.split("~page/")[1] ?? "");
|
||||
const record = await pages.match(request).then((r) => r?.json()).catch(() => null);
|
||||
if (record && (open.has(id) || Date.now() - record.at < PAGE_LOADING_MS)) builds.add(record.build);
|
||||
else await pages.delete(request);
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
|
||||
/** Drop unfinished downloads nothing is using, and replaced builds no open page uses. */
|
||||
async function sweep() {
|
||||
for (const name of await caches.keys()) {
|
||||
if (!name.startsWith(CACHE_PREFIX) || name === CACHE) continue;
|
||||
await drop(name, async () => !(await has(name, COMPLETE)) || ((await has(name, RETIRED)) && !(await inUse()).has(name)));
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Clean up first: a page open for days sees updates (and failed ones) without any launch.
|
||||
const install = () => sweep().catch(() => {}).then(() => keep(VERSION, FILES));
|
||||
event.waitUntil(RETIRE ? self.skipWaiting() : install().then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
if (RETIRE) {
|
||||
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
|
||||
return self.registration.unregister();
|
||||
}
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
}
|
||||
await sweep();
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
/** The promise's value, or null when it fails or takes longer than `ms`. */
|
||||
function within(ms, promise) {
|
||||
return Promise.race([promise.catch(() => null), new Promise((resolve) => setTimeout(resolve, ms, null))]);
|
||||
}
|
||||
|
||||
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
|
||||
const GONE = "gone";
|
||||
|
||||
/** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
|
||||
const SIGN_IN = "sign-in";
|
||||
|
||||
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
|
||||
async function deployed() {
|
||||
// `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
|
||||
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
|
||||
if (response.status === 404 || response.status === 410) return GONE;
|
||||
const build = response.ok ? await response.json() : null;
|
||||
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
|
||||
return valid && build.files.every((f) => typeof f.file === "string" && typeof f.sha256 === "string") ? build : null;
|
||||
}
|
||||
|
||||
/** Keep the worker alive for `promise` (allowed while the launch is being answered). */
|
||||
function extend(event, promise) {
|
||||
try {
|
||||
event.waitUntil(promise.catch(() => {}));
|
||||
} catch {
|
||||
// The event has ended; the work goes on while the worker runs.
|
||||
}
|
||||
}
|
||||
|
||||
/** A launch: the start page of a complete build (whatever query the URL has: `?sample`), and that build. */
|
||||
async function launch(event) {
|
||||
const kept = await caches.match(url("index.html"), { cacheName: CACHE });
|
||||
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
|
||||
extend(event, sweep());
|
||||
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
|
||||
if (build === SIGN_IN) return [await fetch(event.request), NETWORK];
|
||||
if (build === GONE) {
|
||||
const page = await fetch(event.request).catch(() => null);
|
||||
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
|
||||
}
|
||||
if (!build || build.version === VERSION) return [kept, CACHE];
|
||||
const download = keep(build.version, build.files);
|
||||
extend(event, download);
|
||||
const name = await within(UPDATE_TIMEOUT_MS, download);
|
||||
const page = name && (await caches.match(url("index.html"), { cacheName: name }));
|
||||
return page ? [page, name] : [kept, CACHE];
|
||||
}
|
||||
|
||||
/**
|
||||
* A launch that fails unexpectedly still starts the kept build (or asks the network). A newer
|
||||
* worker only takes over once the launch has ended, by when its page is remembered (in use).
|
||||
*/
|
||||
async function launchOrKept(event) {
|
||||
let page, build;
|
||||
try {
|
||||
[page, build] = await launch(event);
|
||||
} catch {
|
||||
page = await caches.match(url("index.html"), { cacheName: CACHE }).catch(() => null);
|
||||
build = page ? CACHE : NETWORK;
|
||||
page = page || (await fetch(event.request));
|
||||
}
|
||||
await started(event.resultingClientId, build).catch(() => {});
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A page's files come from the build it started, else the network. */
|
||||
async function asset(event) {
|
||||
const build = await startedBy(event.clientId).catch(() => CACHE);
|
||||
const kept = build === NETWORK ? null : await caches.match(event.request, { cacheName: build, ignoreSearch: true });
|
||||
return kept || fetch(event.request);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (RETIRE || request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
|
||||
const path = request.url.split(/[?#]/)[0];
|
||||
const start = request.mode === "navigate" && (path === url("") || path === url("index.html"));
|
||||
event.respondWith(start ? launchOrKept(event) : asset(event));
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
[package]
|
||||
name = "solidcraft"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solidcraft"
|
||||
|
||||
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
|
||||
# (macOS file-open events), src/native_crash.rs (Windows exception filter) and src/native_frame.rs
|
||||
# (Windows resize border) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-space-mouse = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
# Checks eframe's settings file (app.ron) before eframe reads it (src/startup.rs).
|
||||
ron = "0.12"
|
||||
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
|
||||
pollster = { workspace = true }
|
||||
getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
# API keys of the agent chat's API agents (docs/agent-chat.md) live in the system keychain:
|
||||
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit).
|
||||
raw-window-handle = "0.6"
|
||||
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
|
||||
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Kernel",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
|
||||
solidcraft-3dx-client = { workspace = true }
|
||||
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
|
||||
# driver does not shut the app out.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
@@ -1,17 +0,0 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
|
||||
println!("cargo:rerun-if-changed={}", icon.display());
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
|
||||
let copy = out.join("solidcraft.ico");
|
||||
std::fs::copy(icon, ©)?;
|
||||
let rc = out.join("solidcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
|
||||
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth;
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed
|
||||
/// when the app ends.
|
||||
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// A new random token: 32 bytes from the operating system, as hex.
|
||||
fn new_token() -> Option<String> {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).ok()?;
|
||||
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Remove this launch's token file (at exit).
|
||||
pub fn remove_token_file() {
|
||||
if let Ok(files) = TOKEN_FILES.lock() {
|
||||
for p in files.iter() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// Every connection must show the token before anything runs (docs/control-protocol.md).
|
||||
match control_auth::env_token().or_else(new_token) {
|
||||
Some(token) => {
|
||||
listen(port, token, tx, ctx, false);
|
||||
}
|
||||
None => eprintln!("solidcraft: control server not started: no random token available"),
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
|
||||
/// port with a fresh token of its own (never `SOLIDCRAFT_CONTROL_TOKEN`), written to the usual
|
||||
/// token file, where the MCP server finds it, so the token never passes through the agent. Its
|
||||
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
|
||||
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
|
||||
let Some(token) = new_token() else {
|
||||
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
|
||||
return None;
|
||||
};
|
||||
listen(0, token, tx, ctx, true)
|
||||
}
|
||||
|
||||
/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on.
|
||||
/// `patient`: every request may wait as long as a batch (a person decides on it).
|
||||
fn listen(port: u16, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
match control_auth::token_path(port) {
|
||||
Some(path) => match control_auth::write_token_file(&path, &token) {
|
||||
Ok(()) => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
if let Ok(mut files) = TOKEN_FILES.lock() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
|
||||
clients need SOLIDCRAFT_CONTROL_TOKEN",
|
||||
path.display()
|
||||
),
|
||||
},
|
||||
None => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
|
||||
}
|
||||
}
|
||||
let token = std::sync::Arc::new(token);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &token, tx, ctx, patient);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
Some(port)
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a connection that didn't show the token; the connection is then closed.
|
||||
fn unauthorized() -> Value {
|
||||
json!({"ok": false, "error": "unauthorized: send the control token as \"token\" (see docs/control-protocol.md)"})
|
||||
}
|
||||
|
||||
/// Refuse the connection: the reply, then the end of what we send, then what the client had
|
||||
/// already sent read and dropped (a little, briefly). Closing a socket with unread input resets
|
||||
/// the connection, and a reset can reach the client before it has read the refusal.
|
||||
fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
|
||||
use std::io::Read;
|
||||
let _ = writeln!(out, "{}", unauthorized());
|
||||
let _ = out.shutdown(std::net::Shutdown::Write);
|
||||
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_millis(500)));
|
||||
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
|
||||
}
|
||||
|
||||
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
|
||||
/// on a patient connection).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Waits {
|
||||
short: Duration,
|
||||
long: Duration,
|
||||
}
|
||||
|
||||
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
|
||||
|
||||
/// A request that timed out but is still running in the app (#1618): its id and method, and where
|
||||
/// its reply will land.
|
||||
struct Pending {
|
||||
id: Value,
|
||||
method: String,
|
||||
reply: Receiver<Value>,
|
||||
}
|
||||
|
||||
/// Every request the app has been sent and not answered yet, from every connection, for
|
||||
/// `control.running` (#3654). Each connection's own thread answers that, so it never waits behind
|
||||
/// a running request.
|
||||
struct Running {
|
||||
list: Mutex<Vec<InFlight>>,
|
||||
connections: AtomicU64,
|
||||
keys: AtomicU64,
|
||||
}
|
||||
|
||||
/// What `control.running` says about a request: never its parameters beyond the command id.
|
||||
struct InFlight {
|
||||
key: u64,
|
||||
connection: u64,
|
||||
id: Value,
|
||||
method: String,
|
||||
command: Option<&'static str>,
|
||||
steps: Option<usize>,
|
||||
since: Instant,
|
||||
started: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The app's requests, from both of its listeners.
|
||||
static RUNNING: Running = Running::new();
|
||||
|
||||
impl Running {
|
||||
const fn new() -> Self {
|
||||
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
|
||||
self.list.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// List `req`, sent on `connection`, until the returned ticket is dropped.
|
||||
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
|
||||
let key = self.keys.fetch_add(1, Ordering::Relaxed);
|
||||
// Ids and methods come from the client: only short ones are repeated to the others.
|
||||
let id = match id {
|
||||
Value::Number(_) => id.clone(),
|
||||
Value::String(s) if s.len() <= 64 => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let p = &req.params;
|
||||
let command = match req.method.as_str() {
|
||||
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
|
||||
_ => None,
|
||||
};
|
||||
let steps = match req.method.as_str() {
|
||||
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
|
||||
_ => None,
|
||||
};
|
||||
let method = req.method.chars().take(64).collect();
|
||||
let started = req.started.clone();
|
||||
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
|
||||
Ticket { running: self, key }
|
||||
}
|
||||
|
||||
/// The `control.running` result, as `asker`'s connection sees it.
|
||||
fn result(&self, asker: u64) -> Value {
|
||||
let rows: Vec<Value> = self
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut r = json!({
|
||||
"connection": e.connection,
|
||||
"id": e.id,
|
||||
"method": e.method,
|
||||
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
|
||||
"queued": !e.started.load(Ordering::Relaxed),
|
||||
});
|
||||
if let Some(c) = e.command {
|
||||
r["command"] = json!(c);
|
||||
}
|
||||
if let Some(n) = e.steps {
|
||||
r["steps"] = json!(n);
|
||||
}
|
||||
if e.connection == asker {
|
||||
r["self"] = json!(true);
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
json!({"running": rows})
|
||||
}
|
||||
}
|
||||
|
||||
/// A listed request; dropped when its reply arrives.
|
||||
struct Ticket {
|
||||
running: &'static Running,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl Drop for Ticket {
|
||||
fn drop(&mut self) {
|
||||
let key = self.key;
|
||||
self.running.lock().retain(|e| e.key != key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
|
||||
/// until the app answers, also once its connection has closed (#3654).
|
||||
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
|
||||
let (late_tx, late_rx) = channel();
|
||||
// A small thread per timed-out request (each connection has at most one). If none can be
|
||||
// made, the reply is lost and `control.pending` says the app ended the request without one.
|
||||
// The reply is passed on before the request leaves the list: once `control.running` no longer
|
||||
// shows it, its connection's `control.pending` has it.
|
||||
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
|
||||
if let Ok(v) = reply.recv() {
|
||||
let _ = late_tx.send(v);
|
||||
}
|
||||
drop(late_tx);
|
||||
drop(ticket);
|
||||
});
|
||||
late_rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
|
||||
}
|
||||
|
||||
fn serve_with(
|
||||
stream: TcpStream,
|
||||
token: &str,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
waits: Waits,
|
||||
running: &'static Running,
|
||||
) {
|
||||
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
let mut authenticated = false;
|
||||
let mut pending: Option<Pending> = None;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
// The first request must carry the token; later ones on the same connection may.
|
||||
if !authenticated {
|
||||
match msg.get("token").and_then(Value::as_str) {
|
||||
Some(t) if control_auth::tokens_match(t, token) => authenticated = true,
|
||||
_ => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// A request that timed out may have finished since (#1618). `control.running` only
|
||||
// looks, so it leaves that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
});
|
||||
let mut r = if method == "control.running" {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
|
||||
(Some(p), None) => {
|
||||
let r = json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": false}});
|
||||
pending = Some(p);
|
||||
r
|
||||
}
|
||||
}
|
||||
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
|
||||
// Nothing more is queued behind it, to run unseen when it ends.
|
||||
json!({"ok": false, "busy": true, "error": format!("busy: request {} (`{}`) is still running; ask `control.pending` for its result", p.id, p.method)})
|
||||
} else {
|
||||
if late.is_some() {
|
||||
// Finished, and the client moved on without asking: it is dropped.
|
||||
pending = None;
|
||||
}
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
let ticket = running.add(connection, &id, &req);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
match rrx.recv_timeout(wait) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
|
||||
pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) });
|
||||
r
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(_) if !authenticated => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
|
||||
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
|
||||
/// is queued to run unseen behind it.
|
||||
#[test]
|
||||
fn a_timed_out_request_can_be_asked_for_later() {
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The slow request is held until the test has seen it time out, so the test does not
|
||||
// race the clock on a loaded machine.
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
if req.method == "slow" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
|
||||
static RUNNING: Running = Running::new();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().flatten().next() {
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut out = s.try_clone().unwrap();
|
||||
let mut lines = BufReader::new(s);
|
||||
let mut ask = |v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let r = ask(json!({"id": 1, "method": "slow", "token": token}));
|
||||
assert!(r["ok"] == false && r["running"] == true && r["error"].as_str().is_some_and(|e| e.starts_with("timeout")), "{r}");
|
||||
let r = ask(json!({"id": 2, "method": "ui.view"}));
|
||||
assert!(r["busy"] == true && r["id"] == 2, "{r}");
|
||||
let r = ask(json!({"id": 3, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(1), &json!(false)), "{r}");
|
||||
gate_tx.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let r = loop {
|
||||
let r = ask(json!({"id": 4, "method": "control.pending"}));
|
||||
if r["result"]["done"] == true || std::time::Instant::now() > deadline {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(
|
||||
(&r["result"]["pending"], &r["result"]["done"], &r["result"]["reply"]["result"]),
|
||||
(&json!(1), &json!(true), &json!("slow")),
|
||||
"{r}"
|
||||
);
|
||||
let r = ask(json!({"id": 5, "method": "control.pending"}));
|
||||
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
|
||||
let r = ask(json!({"id": 6, "method": "ui.view"}));
|
||||
assert_eq!((&r["ok"], &r["result"]), (&json!(true), &json!("ui.view")), "{r}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// #3654: `control.running` answers on every connection, at once, with what any connection
|
||||
/// has the app running or queued (command ids, never parameters); a timed-out request stays
|
||||
/// listed until the app answers it, and an idle app lists nothing.
|
||||
#[test]
|
||||
fn every_connection_sees_what_the_app_is_running() {
|
||||
static RUNNING: Running = Running::new();
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The window: one request at a time, as `drain_control` takes them; the export is held
|
||||
// until the test lets it go.
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
req.started.store(true, Ordering::Relaxed);
|
||||
if req.method == "engine.execute" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut out = s.try_clone().unwrap();
|
||||
let mut lines = BufReader::new(s);
|
||||
move |v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
}
|
||||
};
|
||||
let running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
|
||||
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
|
||||
if until(&rows) || Instant::now() > deadline {
|
||||
return (r, rows);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
let mut watcher = open();
|
||||
let (r, rows) = running(&mut watcher, &|_| true);
|
||||
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
|
||||
|
||||
let mut export = open();
|
||||
let path = "/private/part.step";
|
||||
let params = json!({"command": "file.export", "params": {"path": path}});
|
||||
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
// A batch from another connection waits behind the export.
|
||||
let mut batch = open();
|
||||
let queued = std::thread::spawn(move || {
|
||||
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
|
||||
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
|
||||
});
|
||||
// The window may not have taken the export yet, even though it timed out.
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
|
||||
assert_eq!(rows.len(), 2, "{r}");
|
||||
let (e, b) = (&rows[0], &rows[1]);
|
||||
assert_eq!(
|
||||
(&e["id"], &e["method"], &e["command"], &e["queued"]),
|
||||
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
|
||||
"{r}"
|
||||
);
|
||||
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
|
||||
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
|
||||
assert_ne!(e["connection"], b["connection"], "{r}");
|
||||
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
|
||||
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
|
||||
// The connection whose request timed out may ask too, and sees its own marked.
|
||||
let r = export(json!({"id": 8, "method": "control.running"}));
|
||||
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
|
||||
assert_eq!(own.len(), 1, "{r}");
|
||||
assert_eq!(own[0]["command"], "file.export", "{r}");
|
||||
|
||||
gate_tx.send(()).unwrap();
|
||||
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
|
||||
assert!(rows.is_empty(), "done: {r}");
|
||||
// Asking `control.running` did not take the export's reply from its connection.
|
||||
let r = export(json!({"id": 9, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
|
||||
}
|
||||
|
||||
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
|
||||
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
|
||||
/// need not be repeated on that connection.
|
||||
#[test]
|
||||
fn requests_run_only_after_the_token() {
|
||||
use std::io::Read;
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
let _ = req.reply.send(json!({"ok": true, "result": null}));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default(), false));
|
||||
}
|
||||
});
|
||||
let session = |lines: &[String]| {
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
for l in lines {
|
||||
let _ = writeln!(s, "{l}");
|
||||
}
|
||||
let _ = s.shutdown(std::net::Shutdown::Write);
|
||||
let mut text = String::new();
|
||||
let _ = s.read_to_string(&mut text);
|
||||
text.lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect::<Vec<_>>()
|
||||
};
|
||||
for first in [
|
||||
json!({"id": 1, "method": "app.quit", "params": {"force": true}}).to_string(),
|
||||
json!({"id": 1, "method": "app.quit", "token": "0123456789abcdef0123456789abcdee"}).to_string(),
|
||||
json!({"id": 1, "method": "app.quit", "token": "0123"}).to_string(),
|
||||
json!({"id": 1, "method": "app.quit", "token": 7}).to_string(),
|
||||
"not json".to_string(),
|
||||
] {
|
||||
let replies = session(&[first.clone(), json!({"id": 2, "method": "app.quit", "token": token}).to_string()]);
|
||||
assert_eq!(replies.len(), 1, "{first}: closed after refusing: {replies:?}");
|
||||
assert!(replies[0]["error"].as_str().is_some_and(|e| e.starts_with("unauthorized")), "{first}: {replies:?}");
|
||||
}
|
||||
assert!(ran.lock().unwrap().is_empty(), "nothing ran without the token");
|
||||
let replies = session(&[
|
||||
json!({"id": 1, "method": "ui.inspect", "token": token}).to_string(),
|
||||
json!({"id": 2, "method": "engine.commands"}).to_string(),
|
||||
]);
|
||||
assert_eq!(replies.len(), 2, "{replies:?}");
|
||||
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["ui.inspect", "engine.commands"]);
|
||||
}
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(e.to_string().contains("request too large"), "{e}");
|
||||
}
|
||||
|
||||
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::new(text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A line that spans several reads of the underlying stream still arrives whole.
|
||||
#[test]
|
||||
fn lines_longer_than_one_buffer_arrive_whole() {
|
||||
let long = "x".repeat(200_000);
|
||||
let text = format!("{long}\n{long}\n").into_bytes();
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
// The same line against a smaller cap is refused.
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert!(read_line(&mut r, 1000).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,912 +0,0 @@
|
||||
//! SolidCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
|
||||
//!
|
||||
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
|
||||
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
||||
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
|
||||
//!
|
||||
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
||||
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
mod space_mouse;
|
||||
mod startup;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod window_fit;
|
||||
|
||||
use solidcraft_engine::{Session, io};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
/// Windows: put the restored window back inside the monitor's work area if it is out of reach
|
||||
/// (a gone monitor, another scale, a taskbar edge). Sizes are in points, the work area in pixels.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn fit_window_to_work_area(ctx: &egui::Context, frame: &eframe::Frame) {
|
||||
use window_fit::Area;
|
||||
let Some((x, y, w, h)) = native_frame::work_area(frame) else { return };
|
||||
let (outer, inner, scale, maximized) = ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
(v.outer_rect, v.inner_rect, v.native_pixels_per_point, v.maximized.unwrap_or(false) || v.fullscreen.unwrap_or(false))
|
||||
});
|
||||
let (Some(outer), Some(inner), Some(scale)) = (outer, inner, scale.filter(|s| s.is_finite() && *s > 0.0)) else { return };
|
||||
if maximized {
|
||||
return;
|
||||
}
|
||||
let work = Area::new(x as f32 / scale, y as f32 / scale, w as f32 / scale, h as f32 / scale);
|
||||
let now = Area::new(outer.min.x, outer.min.y, outer.width(), outer.height());
|
||||
let fitted = window_fit::fit(now, work, (960.0, 600.0));
|
||||
if fitted != now {
|
||||
// The frame (title bar, borders) keeps its size; only the inner size follows.
|
||||
let inner_size = egui::vec2((inner.width() - (now.w - fitted.w)).max(1.0), (inner.height() - (now.h - fitted.h)).max(1.0));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner_size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(fitted.x, fitted.y)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `SOLIDCRAFT_PROFILE_STARTUP=1` prints the time since launch at each startup step.
|
||||
fn mark(step: &str) {
|
||||
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
|
||||
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
|
||||
if *on {
|
||||
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
/// The settings folder whose start marker the first frame clears (`startup::started`).
|
||||
starting: Option<std::path::PathBuf>,
|
||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||
frames: u32,
|
||||
/// Started over the control channel: the window doesn't ask for the foreground.
|
||||
take_focus_unwanted: bool,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.frames < 2 {
|
||||
self.frames += 1;
|
||||
// Before the window is shown: its title bar must be within reach (window_fit.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
if self.frames == 1 {
|
||||
fit_window_to_work_area(ctx, _frame);
|
||||
}
|
||||
mark(if self.frames == 1 { "first frame begins" } else { "second frame begins (first presented)" });
|
||||
// A second frame right away, which means the first was presented: the app is up.
|
||||
ctx.request_repaint();
|
||||
if self.frames == 2 {
|
||||
// The window is shown only once its first frame is presented, and Windows then
|
||||
// keeps a window of a process started from a shortcut or an installer behind the
|
||||
// others. Ask for the foreground once, now that it is on screen; never again, so
|
||||
// later frames don't take the focus from whatever the user moved to. Automation
|
||||
// over the control channel keeps the focus where it is.
|
||||
if !self.take_focus_unwanted {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
|
||||
if let Some(dir) = self.starting.take() {
|
||||
startup::started(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The design on screen is the one whose commands a crash report replays.
|
||||
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
// designs first; the window closes once they are answered (`quit_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
|
||||
self.solve.open_path_async(&f);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
if !self.solve.file_picker_busy() {
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
}
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
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) {
|
||||
// The agent chat's agent and everything it started end with the app.
|
||||
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
|
||||
// A save still being written ends before the app does.
|
||||
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
|
||||
let mut command = std::process::Command::new(&program);
|
||||
command.args(&args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Not tied to this process: its own process group, and no console (the PowerShell
|
||||
// that waits for an installer is hidden; the app itself is detached).
|
||||
let hidden_shell = program.file_stem().is_some_and(|n| n.eq_ignore_ascii_case("powershell"));
|
||||
command.creation_flags(0x0000_0200 | if hidden_shell { 0x0800_0000 } else { 0x0000_0008 });
|
||||
}
|
||||
if let Err(e) = command.spawn() {
|
||||
log::error!("update: starting {}: {e}", program.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
||||
let open_parent = parent.clone();
|
||||
let program_parent = parent.clone();
|
||||
let file_parent = parent.clone();
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |first: &[&str]| {
|
||||
// The file type the command reads comes first, so the picker opens on it (#1801).
|
||||
let mut dialog = open_parent.clone();
|
||||
if !first.is_empty() {
|
||||
let label = first.iter().map(|e| e.to_uppercase()).collect::<Vec<_>>().join(", ");
|
||||
let both: Vec<String> = first.iter().flat_map(|e| [e.to_lowercase(), e.to_uppercase()]).collect();
|
||||
dialog = dialog.add_filter(label, &both);
|
||||
}
|
||||
let future = dialog
|
||||
.add_filter(
|
||||
"Designs, CAD files and meshes",
|
||||
&[
|
||||
io::DESIGN_EXT,
|
||||
io::LEGACY_DESIGN_EXT,
|
||||
"json",
|
||||
"step",
|
||||
"stp",
|
||||
"STEP",
|
||||
"STP",
|
||||
"igs",
|
||||
"iges",
|
||||
"IGS",
|
||||
"IGES",
|
||||
"FCStd",
|
||||
"fcstd",
|
||||
"3dm",
|
||||
"3DM",
|
||||
"3mf",
|
||||
"3MF",
|
||||
"stl",
|
||||
"STL",
|
||||
"obj",
|
||||
"OBJ",
|
||||
"glb",
|
||||
"GLB",
|
||||
"gltf",
|
||||
"GLTF",
|
||||
],
|
||||
)
|
||||
.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("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
|
||||
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
// One kind of file (the agent chat's attachments): only the types it names, as given and in
|
||||
// lower and upper case (Linux dialogs match case; macOS allows just the listed types).
|
||||
pick_file: Some(Box::new(move |label: &str, exts: &[&str]| {
|
||||
let mut all: Vec<String> = Vec::new();
|
||||
for e in exts {
|
||||
for v in [e.to_string(), e.to_ascii_lowercase(), e.to_ascii_uppercase()] {
|
||||
if !all.contains(&v) {
|
||||
all.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
// Any other file attaches by reference, so the list is not the only choice.
|
||||
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
let dialog = program_parent.clone().set_title("Choose your slicer");
|
||||
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
|
||||
let future = dialog.pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
|
||||
let dialog = parent.clone().set_file_name(name);
|
||||
let dialog = if exts == [io::DESIGN_EXT, "json"] {
|
||||
if name.ends_with(".json") {
|
||||
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
|
||||
} else {
|
||||
dialog.add_filter("SolidCraft container", &[io::DESIGN_EXT]).add_filter("Plain JSON design", &["json"])
|
||||
}
|
||||
} else {
|
||||
dialog.add_filter("File", exts)
|
||||
};
|
||||
let future = dialog.save_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
http_get: Some(std::sync::Arc::new(http_get)),
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
// Already an installed app: only the browser build offers to install.
|
||||
install_app: None,
|
||||
can_restore: None,
|
||||
restore_file: None,
|
||||
download_design: None,
|
||||
browser_files: None,
|
||||
agent_http: Some(std::sync::Arc::new(agent_http)),
|
||||
secrets: Some(std::sync::Arc::new(Keychain)),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||
"explorer"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
});
|
||||
command.arg(folder);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = command.spawn();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTPS GET for the update check (docs/updates.md): certificates from the operating system, a
|
||||
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
|
||||
/// the version or anything about this machine, and the API's media type).
|
||||
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
|
||||
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
|
||||
/// `progress` returns false. The caller verifies the file.
|
||||
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
use std::io::{Read, Write};
|
||||
let fail = |e: String| FetchError::Failed(e);
|
||||
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
|
||||
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
|
||||
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
|
||||
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
let mut done = 0u64;
|
||||
loop {
|
||||
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
done += n as u64;
|
||||
if !progress(done, total) {
|
||||
return Err(fail("cancelled".into()));
|
||||
}
|
||||
}
|
||||
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints, docs/agent-chat.md): to the
|
||||
/// URL the user configured, which may be plain HTTP on this computer (Ollama, LM Studio). No
|
||||
/// redirects (a key must not follow one elsewhere), at most 16 MB back.
|
||||
fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Result<(u16, Vec<u8>), String> {
|
||||
use std::time::Duration;
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900))))
|
||||
.timeout_connect(Some(Duration::from_secs(10)))
|
||||
.user_agent("SolidCraft")
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
let mut response = if r.method == "POST" {
|
||||
let mut req = agent.post(&r.url);
|
||||
for (k, v) in &r.headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
req.send(r.body.as_deref().unwrap_or_default())
|
||||
} else {
|
||||
let mut req = agent.get(&r.url);
|
||||
for (k, v) in &r.headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
req.call()
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| e.to_string())?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent).
|
||||
struct Keychain;
|
||||
|
||||
impl Keychain {
|
||||
fn entry(id: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new("SolidCraft agent chat", id).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl solidcraft_ui_egui::agent_chat::backend::Secrets for Keychain {
|
||||
fn get(&self, id: &str) -> Result<Option<String>, String> {
|
||||
match Self::entry(id)?.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
fn set(&self, id: &str, secret: &str) -> Result<(), String> {
|
||||
Self::entry(id)?.set_password(secret).map_err(|e| e.to_string())
|
||||
}
|
||||
fn delete(&self, id: &str) -> Result<(), String> {
|
||||
match Self::entry(id)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
|
||||
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
use std::time::Duration;
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.https_only(true)
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(timeout))
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.user_agent("SolidCraft-update-check")
|
||||
.max_redirects(3)
|
||||
.build()
|
||||
.into();
|
||||
let response = agent
|
||||
.get(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.call()
|
||||
.map_err(|e| FetchError::Failed(e.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
let header = |k: &str| response.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
|
||||
if status == 429 || (status == 403 && (header("x-ratelimit-remaining") == Some(0) || header("retry-after").is_some())) {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
let retry_after_s = header("retry-after").or_else(|| header("x-ratelimit-reset").map(|reset| reset.saturating_sub(now))).unwrap_or(3600);
|
||||
return Err(FetchError::RateLimited { retry_after_s });
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
|
||||
fn settings_dir() -> Option<std::path::PathBuf> {
|
||||
eframe::storage_dir("SolidCraft")
|
||||
}
|
||||
|
||||
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
|
||||
/// window opens).
|
||||
fn graphics_path() -> Option<std::path::PathBuf> {
|
||||
settings_dir().map(|d| d.join("graphics.json"))
|
||||
}
|
||||
|
||||
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
|
||||
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
|
||||
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
|
||||
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
|
||||
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
|
||||
/// are never changed.
|
||||
fn adopt_legacy_settings() {
|
||||
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
|
||||
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
|
||||
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(¤t);
|
||||
if let Err(e) = adopt_settings(¤t, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
|
||||
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_settings(
|
||||
current: &std::path::Path,
|
||||
old_flatpak: Option<&std::path::Path>,
|
||||
legacy: &std::path::Path,
|
||||
legacy_eframe: Option<&std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
let fresh = !current.exists();
|
||||
if let Some(old) = old_flatpak {
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
|
||||
}
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
|
||||
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
|
||||
std::fs::create_dir_all(current)?;
|
||||
let new = current.join("app.ron");
|
||||
if !new.exists() {
|
||||
std::fs::copy(old, new)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `graphics.json`, read before the window opens.
|
||||
fn graphics() -> Option<serde_json::Value> {
|
||||
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
}
|
||||
|
||||
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
|
||||
fn backend_preference() -> Option<String> {
|
||||
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics()
|
||||
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
||||
.and_then(|m| u16::try_from(m).ok())
|
||||
.filter(|m| [1, 2, 4, 8].contains(m))
|
||||
.unwrap_or(MSAA)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
||||
/// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// A 32-bit float depth buffer, drawn with reversed depth (1 near, 0 far) so its precision follows
|
||||
/// the distance from the eye (#1434). Only where every adapter offers it with a stencil
|
||||
/// ([`startup::float_depth_everywhere`]); else [`DEPTH_BITS`]. Not in safe mode.
|
||||
const FLOAT_DEPTH_BITS: u8 = 32;
|
||||
/// The section cap marks where it shows in the stencil.
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let icon = egui::IconData::default();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let icon =
|
||||
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(icon)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
mark("main");
|
||||
adopt_legacy_settings();
|
||||
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
||||
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--safe-mode" => safe_mode = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--crash-log" => {
|
||||
match solidcraft_engine::crash::default_dir() {
|
||||
Some(dir) => {
|
||||
println!("crash reports: {}", dir.display());
|
||||
for r in solidcraft_engine::crash::list(&dir).iter().take(5) {
|
||||
println!(" {} {} {}", r.id, r.kind, r.message);
|
||||
}
|
||||
println!("more: solidcraft-cli crashes");
|
||||
}
|
||||
None => println!("no crash folder on this system"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// Settings that don't read are moved aside; launches that never got a window are counted,
|
||||
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
|
||||
let settings = settings_dir();
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
let mut failed_starts = 0;
|
||||
if let Some(dir) = &settings {
|
||||
notes.extend(startup::check_settings(dir, &[solidcraft_ui_egui::prefs::STORAGE_KEY, solidcraft_ui_egui::prefs::LEGACY_STORAGE_KEY]));
|
||||
failed_starts = startup::begin(dir);
|
||||
}
|
||||
if failed_starts >= startup::FAILED_STARTS_FOR_SAFE_MODE && !safe_mode {
|
||||
safe_mode = true;
|
||||
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
|
||||
}
|
||||
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
|
||||
if safe_mode {
|
||||
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
||||
}
|
||||
let samples = if safe_mode { MSAA } else { msaa() };
|
||||
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
|
||||
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
|
||||
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
||||
let crashed = solidcraft_engine::crash::default_dir()
|
||||
.map(|dir| {
|
||||
let build = solidcraft_engine::crash::Build {
|
||||
version: solidcraft_ui_egui::help::VERSION,
|
||||
commit: solidcraft_ui_egui::help::COMMIT,
|
||||
built: solidcraft_ui_egui::help::BUILD_DATE,
|
||||
};
|
||||
solidcraft_engine::crash::install(dir, build)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// A crash in native code (a graphics driver) says where it happened: the exception and the
|
||||
// module on Windows, and on every system the startup step the app was in.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::install();
|
||||
eframe::egui_wgpu::set_startup_hook(startup_step);
|
||||
solidcraft_engine::crash::phase("window");
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: depth_bits,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
|
||||
// and writes a scratch copy, leaving the user's settings as they were.
|
||||
persistence_path: if safe_mode {
|
||||
let scratch = std::env::temp_dir().join(format!("solidcraft-safe-mode-{}.ron", std::process::id()));
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
Some(scratch)
|
||||
} else {
|
||||
settings.as_ref().map(|d| d.join("app.ron"))
|
||||
},
|
||||
persist_window: !safe_mode,
|
||||
wgpu_options,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if prefer_x11(
|
||||
std::env::var_os("DISPLAY").is_some(),
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
||||
solidcraft_engine::geom::env::var("WAYLAND").as_deref(),
|
||||
) {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_app = created.clone();
|
||||
let starting = settings.clone();
|
||||
mark("run_native");
|
||||
let result = eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
solidcraft_engine::crash::phase("app setup");
|
||||
mark("window and device ready");
|
||||
solidcraft_ui_egui::fonts::setup_bundled_fonts(&cc.egui_ctx);
|
||||
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
|
||||
mark("fonts");
|
||||
let mut app = SolveApp::new(Session::default(), services(cc));
|
||||
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
// Windows: the OS hit test offers the resize border, at the window's DPI, from the
|
||||
// first message (#1381).
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
|
||||
if CUSTOM_TITLEBAR && !app.native_resize_border {
|
||||
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
|
||||
}
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.cpu_meshing = safe_mode;
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
mark("app state and prefs");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu(rs, depth_bits, STENCIL_BITS, rs.msaa_samples);
|
||||
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::reinstall();
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
solidcraft_engine::crash::set_environment(&format!(
|
||||
"Renderer: {:?} {} ({}), MSAA {}x",
|
||||
info.backend, info.name, info.driver, rs.msaa_samples
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
|
||||
// on a free port the first time the chat connects (docs/agent-chat.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.agent_chat.control_starter = Some(Box::new(move |tx| control_server::listen_agent(tx, ctx.clone())));
|
||||
if sample {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
app.session.mark_saved();
|
||||
app.session.undo.clear();
|
||||
}
|
||||
// A launch from Finder: its files arrived before the window.
|
||||
#[cfg(target_os = "macos")]
|
||||
let files = {
|
||||
open_documents::set_ui(&cc.egui_ctx);
|
||||
[files, open_documents::take()].concat()
|
||||
};
|
||||
for f in &files {
|
||||
app.open_path_async(f);
|
||||
}
|
||||
// No file and no sample: the empty design takes Units of new designs (#1205).
|
||||
// An opened file and the sample part keep the units they were saved or built with.
|
||||
if files.is_empty() && !sample {
|
||||
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
||||
}
|
||||
// No design given: the start page (not when driven over the control channel, whose
|
||||
// clients expect the design view; `ui.home` shows it there).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
// Autosave, and offer what crashed sessions left behind.
|
||||
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
|
||||
if safe_mode {
|
||||
app.recoverable.clear();
|
||||
}
|
||||
}
|
||||
// Tell the user about crashes since the last start (after the recovery offer is set up).
|
||||
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
|
||||
if !notes.is_empty() {
|
||||
app.set_status(notes.join(" "), true);
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
mark("app created");
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
take_focus_unwanted: control_port.is_some(),
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
control_server::remove_token_file();
|
||||
match result {
|
||||
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
|
||||
/// the crash journal keeps it, and the adapter once it is picked.
|
||||
fn startup_step(step: &'static str, detail: &str) {
|
||||
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
|
||||
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
|
||||
}
|
||||
solidcraft_engine::crash::phase(step);
|
||||
}
|
||||
|
||||
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
|
||||
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
|
||||
/// in use is started); `preference` is `graphics.json`'s `backend`.
|
||||
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
||||
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
|
||||
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
|
||||
eprintln!("SolidCraft graphics: starting {backends:?}");
|
||||
setup.instance_descriptor.backends = backends;
|
||||
}
|
||||
if cpu {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
|
||||
}
|
||||
// A float depth buffer with a stencil, wherever the adapter has it (see `FLOAT_DEPTH_BITS`).
|
||||
let base = setup.device_descriptor.clone();
|
||||
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut d = base(adapter);
|
||||
d.required_features |= adapter.features() & startup::FLOAT_DEPTH_STENCIL;
|
||||
d
|
||||
});
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
|
||||
/// the software renderer, else a message box saying what failed instead of a silent exit.
|
||||
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
|
||||
log::error!("the window could not open: {error}");
|
||||
eprintln!("SolidCraft: the window could not open: {error}");
|
||||
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
|
||||
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
|
||||
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
|
||||
Ok(status) if status.success() => return Ok(()),
|
||||
// The second try showed its own message.
|
||||
Ok(_) => return Err(error),
|
||||
Err(e) => log::error!("could not start again with the software renderer: {e}"),
|
||||
}
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("SolidCraft could not start")
|
||||
.set_description(startup::no_window_message(&error.to_string(), settings))
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
Err(error)
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
|
||||
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
|
||||
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
|
||||
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
|
||||
match wayland_override.map(str::trim) {
|
||||
Some("0") => return display,
|
||||
Some(_) => return false,
|
||||
None => {}
|
||||
}
|
||||
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
|
||||
display && !(niri && wayland_display)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod backend_tests {
|
||||
use super::prefer_x11;
|
||||
|
||||
#[test]
|
||||
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
|
||||
// XWayland whenever there's an X display, for file drag-and-drop.
|
||||
assert!(prefer_x11(true, true, Some("GNOME"), None));
|
||||
assert!(prefer_x11(true, false, None, None));
|
||||
assert!(!prefer_x11(false, true, Some("KDE"), None));
|
||||
// niri (alone or in a desktop list) under Wayland stays native.
|
||||
assert!(!prefer_x11(true, true, Some("niri"), None));
|
||||
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
|
||||
// niri without a Wayland socket (nested X session) still uses X11.
|
||||
assert!(prefer_x11(true, false, Some("niri"), None));
|
||||
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
|
||||
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
||||
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
let icon = viewport.icon.expect("running window must have an icon");
|
||||
assert_eq!((icon.width, icon.height), (64, 64));
|
||||
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
#[test]
|
||||
fn first_launch_adopts_the_previous_release_settings() {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let (current, legacy, legacy_eframe) = (d.join("solidcraft"), d.join("solvecraft"), d.join("ai.storyteller.solvecraft"));
|
||||
std::fs::create_dir_all(&legacy).unwrap();
|
||||
std::fs::create_dir_all(&legacy_eframe).unwrap();
|
||||
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
|
||||
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
|
||||
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
|
||||
// Later launches leave the current settings alone.
|
||||
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
|
||||
std::fs::remove_file(current.join("graphics.json")).unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
|
||||
assert!(!current.join("graphics.json").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_multisampling_uses_the_selected_backend_policy() {
|
||||
use eframe::wgpu::Backend;
|
||||
for requested in [1, 2, 4, 8] {
|
||||
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
|
||||
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
|
||||
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user