Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 069ba1223f | |||
| 4c09fa5355 |
@@ -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,25 +1,21 @@
|
||||
# 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).
|
||||
# PR runs build the tests once and run them in 4 shards (.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).
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solidcraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(/^tests::robustness_goldens::seed_/)
|
||||
| test(/^tests::robustness_goldens::/)
|
||||
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
|
||||
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solidcraft-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))
|
||||
)
|
||||
"""
|
||||
@@ -32,97 +28,3 @@ 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
|
||||
|
||||
# Each nextest test has its own process/device. Concurrent cold lavapipe path
|
||||
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
|
||||
# Serialize this module per CI shard; other tests retain their normal parallelism.
|
||||
[test-groups.gpu-path]
|
||||
max-threads = 1
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "package(=solidcraft-gpu) & test(/^path::/)"
|
||||
test-group = "gpu-path"
|
||||
|
||||
# Full local validation opts into its own scheduling policy. Keep GitHub CI's
|
||||
# worker reservation and slow-test priorities above independent of this runner.
|
||||
[profile.local-ci]
|
||||
inherits = "ci"
|
||||
|
||||
[[profile.local-ci.overrides]]
|
||||
filter = "binary(action_matrix) & test(=action_matrix_sample)"
|
||||
threads-required = "num-cpus"
|
||||
priority = 100
|
||||
|
||||
[[profile.local-ci.overrides]]
|
||||
filter = "(package(solidcraft-engine) & test(=tests::robustness_goldens::seed_81)) | (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))"
|
||||
priority = 99
|
||||
|
||||
[[profile.local-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 = 98
|
||||
|
||||
# Native Windows shader/device creation must not overlap within the local run.
|
||||
[test-groups.windows-gpu]
|
||||
max-threads = 1
|
||||
|
||||
[[profile.local-ci.overrides]]
|
||||
platform = "cfg(windows)"
|
||||
filter = "package(solidcraft-gpu) | package(solidcraft) | (package(solidcraft-ui-egui) & test(/(offscreen|gpu)/))"
|
||||
test-group = "windows-gpu"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolidCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
## 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.
|
||||
@@ -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,13 +1,95 @@
|
||||
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) ·
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solidcraft-cli mcp` ([how](https://docs.solidcraft.io/guide/mcp.html)).
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
**What's new** in SolidCraft {version} is listed below, and the
|
||||
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
|
||||
the known limitations. Designs from every earlier version open in this one.
|
||||
**Why 0.9:** this is a minor bump because the design file format moved to version 17, and
|
||||
because it adds many new features.
|
||||
|
||||
**What's new**
|
||||
- **Face actions work on about 90% of flat faces.** Sketches, offset planes and press-pull now
|
||||
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
|
||||
revolved faces, pieces Offset Face moved and walls a Join merged
|
||||
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
|
||||
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
|
||||
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
|
||||
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
|
||||
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
|
||||
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
|
||||
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
|
||||
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
|
||||
identically on every run.
|
||||
- **Sketch:** type a dimension right after picking (D, click, type;
|
||||
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
|
||||
constraints are accepted, such as a rectangle clover
|
||||
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
|
||||
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
|
||||
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
|
||||
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
|
||||
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
|
||||
menu.
|
||||
- **Features:** feature history on the left side with folders (opt in under Preferences;
|
||||
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
|
||||
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
|
||||
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
|
||||
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
|
||||
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
|
||||
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
|
||||
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
|
||||
flanges run along what cut-outs leave of an edge
|
||||
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
|
||||
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
|
||||
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
|
||||
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
|
||||
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
|
||||
stays responsive and draggable while a design opens, with a Cancel button that stays put
|
||||
([#643](https://github.com/bherbruck/solidcraft/pull/643),
|
||||
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
|
||||
- **Quality:** cuts no longer split walls they don't touch
|
||||
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
|
||||
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
|
||||
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
|
||||
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
|
||||
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
|
||||
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
|
||||
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
|
||||
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
|
||||
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
|
||||
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
|
||||
the Save changes prompt takes arrow keys and Enter.
|
||||
- **MCP:** agent skills for modeling and editing over MCP
|
||||
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
|
||||
- A new README demo and the SELENE rover showcase.
|
||||
|
||||
**File format 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
|
||||
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
|
||||
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
|
||||
|
||||
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
|
||||
from then on). Feature-history folders on
|
||||
the left are off by default; turn them on in Preferences.
|
||||
|
||||
**Known limitations**
|
||||
- Some through-all holes along a wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
|
||||
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
|
||||
- Some fillets are still refused, such as along an outline of lines and arcs
|
||||
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
|
||||
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
|
||||
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
|
||||
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
|
||||
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
|
||||
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
|
||||
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
|
||||
- `sheet.fold` refuses a fixed point exactly on the sheet face
|
||||
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
|
||||
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
|
||||
[#639](https://github.com/bherbruck/solidcraft/issues/639)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
**Upgrading from 0.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
|
||||
@@ -41,20 +123,16 @@ 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 remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solidcraft
|
||||
flatpak run ai.storyteller.solidcraft
|
||||
```
|
||||
|
||||
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).
|
||||
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
|
||||
@@ -1,53 +1,11 @@
|
||||
# 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.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# 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:
|
||||
@@ -65,10 +23,7 @@ permissions:
|
||||
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) || '' }}
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -78,7 +33,7 @@ jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -90,27 +45,10 @@ jobs:
|
||||
|
||||
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
|
||||
runs-on: ubuntu-24.04
|
||||
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:
|
||||
@@ -121,260 +59,83 @@ jobs:
|
||||
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
|
||||
if [ "$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 ;;
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
|
||||
*) native=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"
|
||||
echo "native=$native" >> "$GITHUB_OUTPUT"
|
||||
echo "kernel=$kernel" >> "$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
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
- run: cargo clippy --workspace --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 }}
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
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:
|
||||
@@ -383,14 +144,7 @@ jobs:
|
||||
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
|
||||
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
@@ -402,126 +156,65 @@ jobs:
|
||||
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
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
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
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- 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
|
||||
SHARD: ${{ matrix.shard }}
|
||||
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
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
|
||||
- name: Robustness goldens sample (pull requests that touch the kernel)
|
||||
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
|
||||
# the nightly run every seed in the step above.
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true'
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter \
|
||||
-E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
|
||||
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
|
||||
# the whole matrix runs nightly (or by hand) in shards of about equal cost, cut by
|
||||
# tests/action_matrix/costs.json. Keep SHARDS in step with the matrix below.
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
# the whole matrix runs nightly (or by hand), a quarter per job.
|
||||
name: Action matrix (${{ matrix.part }}/4)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 55
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
part: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
@@ -530,165 +223,54 @@ jobs:
|
||||
- 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
|
||||
- name: Run a quarter of the matrix
|
||||
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
|
||||
PART: ${{ matrix.part }}
|
||||
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)"
|
||||
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.doctests == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
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
|
||||
- run: cargo test --workspace --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
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:
|
||||
@@ -696,7 +278,7 @@ jobs:
|
||||
- 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"
|
||||
run: cargo xtask oracle --cli target/ci/solidcraft-cli
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -704,159 +286,37 @@ jobs:
|
||||
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
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
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 "$BUILD" = success
|
||||
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
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -866,13 +326,12 @@ jobs:
|
||||
- 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
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -888,7 +347,6 @@ jobs:
|
||||
- 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.
|
||||
|
||||
@@ -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,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
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
# 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
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
|
||||
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# 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:
|
||||
@@ -47,18 +45,11 @@ jobs:
|
||||
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 ./
|
||||
run: trunk build --release --public-url /solidcraft/app/
|
||||
- name: Test the web app offline and across an update (headless Chrome)
|
||||
run: cargo xtask web-offline-test --site dist/web
|
||||
- name: Assemble the site
|
||||
|
||||
@@ -203,8 +203,6 @@ jobs:
|
||||
name: Snap ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# The Snap Store hasn't accepted SolidCraft yet, so a snap failure never holds up a release.
|
||||
continue-on-error: 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 }}
|
||||
@@ -431,32 +429,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -7,7 +7,3 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
.vscode/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
@@ -27,17 +27,7 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft 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), 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) and the Windows-only
|
||||
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
|
||||
mice, so no other device is sent anything).
|
||||
`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
|
||||
@@ -52,8 +42,6 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
`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).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
@@ -71,11 +59,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
nothing from FreeCAD is linked, vendored or shipped.) 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**
|
||||
artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
|
||||
(`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
|
||||
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/*`
|
||||
@@ -90,33 +75,18 @@ 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
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **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/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
@@ -135,46 +105,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- 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.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
@@ -206,11 +143,5 @@ before the draft is verified.
|
||||
|
||||
## 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.
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
|
||||
@@ -14,81 +14,38 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
|
||||
| examples/material-card.json | SolidCraft contributors | original analytic box and explicitly synthetic engineering card; invented illustrative constants, no external material dataset | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/stage1-sweep.json | SolidCraft contributors | original tangent circular-arc CAD cyclorama; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/stage1.json | SolidCraft contributors | original editable CAD studio scene; no imported geometry or external image assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/stage1-render.json | SolidCraft contributors | original studio camera and lighting parameters | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
| examples/render-path-studio.json | SolidCraft contributors | original procedural opaque enclosure, teal insert and metal fasteners for progressive rendering; no imported models | MIT OR Apache-2.0 |
|
||||
| crates/gpu/src/path/path.wgsl | SolidCraft contributors | original software BVH traversal and path integration using the existing SolidCraft surface/studio equations; no external shader source | 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/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | retained editable historical VectorCraft source, with named face and background layers; redundant exports retired | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/archive/icons/macos-v1/exports/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/archive/icons/macos-v1/exports/icon.svg from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
|
||||
| assets/models/test_boat.bin | 3DBenchy.com | 3DBenchy.com | Public Domain |
|
||||
| assets/models/make_test_boat.py | SolidCraft contributors | original script that packs a model into the compact file above | 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 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
@@ -109,11 +66,9 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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/appearances/starter_pack.rs | SolidCraft contributors | original illustrative optical recipes, authored scalar values and future procedural resource descriptors; no measured or external material dataset | 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 |
|
||||
@@ -123,39 +78,20 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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 drawing code: the borderless mark, drawn from the faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/icons.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/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 |
|
||||
@@ -166,340 +102,9 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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/settings_keyboard.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_monitor.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_mouse.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_palette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_plug.svg | SolidCraft contributors | original icon master (64 grid) | 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/status_recovery.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/frame_member.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 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
@@ -514,7 +119,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -528,27 +132,6 @@ work cooperative and adds checked run lengths, zero-run rejection, width validat
|
||||
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.3 (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 and browser 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 |
|
||||
@@ -559,16 +142,9 @@ copied into SolidCraft.
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
|---|---|---|---|
|
||||
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| 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-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/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
@@ -579,79 +155,3 @@ its original `egui.wgsl` shader are retained; no images or other artwork are cop
|
||||
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.
|
||||
|
||||
## Runtime studio lighting mathematics
|
||||
|
||||
`crates/gpu/src/path/path.wgsl` implements the power-heuristic estimator from the
|
||||
mathematical description in [PBRT fourth edition, section 13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
|
||||
No PBRT code or assets were copied. `crates/render/src/job/lighting.rs` evaluates
|
||||
[Krzysztof Narkowicz's published 2016 ACES rational fit](https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/)
|
||||
using its published coefficients (author licenses the fit under CC0/MIT).
|
||||
These original implementations are under SolidCraft's MIT OR Apache-2.0 licence.
|
||||
|
||||
## Original coffee framing variant
|
||||
|
||||
| File | Author | Provenance | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/studio/author_coffee_framing.py | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing-sweep.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing-render.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing.md | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
|
||||
## Original cylindrical coffee housing variant
|
||||
|
||||
| File | Author | Provenance | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/studio/author_coffee_cylinder.py | SolidCraft contributors | original parametric cylindrical CAD construction and editable command recipes; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder.json | SolidCraft contributors | original cylindrical housing on the original exploded coffee composition; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder-sweep.json | SolidCraft contributors | original cylindrical housing and continuous CAD cyclorama; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder-render.json | SolidCraft contributors | original camera, studio lighting and optical settings retained from the framing variant | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder.md | SolidCraft contributors | original dimensional recipe, provenance and validation limitations | MIT OR Apache-2.0 |
|
||||
|
||||
## Runtime environment sampling
|
||||
|
||||
`crates/render/src/lighting/environment.rs` and the environment/sun portions of
|
||||
`crates/gpu/src/path/path.wgsl` are original SolidCraft implementations of
|
||||
solid-angle importance sampling and MIS, informed by the mathematical description
|
||||
in [PBRT fourth edition, sections 12.5](https://pbr-book.org/4ed/Light_Sources/Infinite_Area_Lights)
|
||||
and [13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
|
||||
The procedural gradient and inline RGBE test fixtures are original mathematical
|
||||
colours, not downloaded imagery. No third-party environment images or shader code
|
||||
are included. Licence: MIT OR Apache-2.0.
|
||||
|
||||
## Original tumbler showcase assets
|
||||
|
||||
The CAD room, tumblers, window opening, wood/weave patterns and RGBE sky below
|
||||
are original SolidCraft contributor work. No third-party scene, photograph, HDRI,
|
||||
texture, or AI-generated artwork was used. Python authoring sources are included.
|
||||
All listed files use the repository MIT OR Apache-2.0 licence.
|
||||
|
||||
| File | Source / author | Use | License |
|
||||
| --- | --- | --- | --- |
|
||||
| examples/studio/author_showcase.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_tumblers.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/ATTRIBUTION.md | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/author_textures.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/environment-manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-hdr-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-hdr-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-materials.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
|
||||
The original studio generators produce local ignored PNG maps and an RGBE gradient.
|
||||
Generated image bytes are not source assets; their authorship, licences and expected
|
||||
hashes are documented under `examples/studio/textures`. Rendered evidence belongs
|
||||
on the results branch. No photographs, scans or external image assets are included.
|
||||
|
||||
@@ -1,3 +1,124 @@
|
||||
@AGENTS.md
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
The agent instructions for this repo live in AGENTS.md; edit that file only.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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 SolidCraft 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
|
||||
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
|
||||
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
|
||||
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — ever.** Every icon is drawn in code
|
||||
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command 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. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## 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).
|
||||
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}`
|
||||
- `{"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`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
|
||||
@@ -10,28 +10,13 @@ Help is welcome, whether that's a one-line fix or owning a whole area. This page
|
||||
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/)).
|
||||
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/tangency-labs/solidcraft && cd solidcraft
|
||||
git clone https://github.com/bherbruck/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)
|
||||
@@ -81,7 +66,7 @@ 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:
|
||||
The [README](README.md#architecture) has the layer table. The short version:
|
||||
|
||||
- `crates/geom`: vectors, planes, meshes, measures.
|
||||
- `crates/sketch`: our own constraint solver, plus profile finding.
|
||||
@@ -113,8 +98,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
|
||||
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`.
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
@@ -122,26 +107,13 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
|
||||
`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.
|
||||
There's no `unsafe`, and clippy denies it.
|
||||
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every 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.
|
||||
@@ -154,12 +126,6 @@ the most complete description of the house style.
|
||||
| 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.
|
||||
|
||||
@@ -189,31 +155,18 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**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
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
|
||||
quarter each, so every test runs in its own process: don't rely on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
|
||||
`main` and nightly; check the `main` run after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
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`),
|
||||
`tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part),
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, 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)
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
@@ -221,96 +174,28 @@ corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/act
|
||||
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`
|
||||
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
|
||||
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
|
||||
the surface itself). Expected values are either worked out in closed form
|
||||
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
|
||||
program (`tools/freecad-oracle/`); each file says which. 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).
|
||||
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
|
||||
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
|
||||
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
|
||||
change an expected value to match SolidCraft's output.
|
||||
|
||||
## Sending a change
|
||||
|
||||
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.
|
||||
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
|
||||
comment on the issue so it gets the `in progress` label and two people don't build the same
|
||||
thing. For anything bigger than a bug fix that has no issue yet, open one first.
|
||||
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,
|
||||
@@ -325,48 +210,22 @@ Changes with no useful observable behavior do not need screenshot or video evide
|
||||
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.
|
||||
Keep checking upstream commits and the relevant PR's merge status throughout the work and
|
||||
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
|
||||
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
|
||||
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
|
||||
any conflicts resolved and validation results. While a task remains active, follow an open
|
||||
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
|
||||
|
||||
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
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.
|
||||
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
|
||||
In order of how much they'd help users:
|
||||
|
||||
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
|
||||
@@ -375,24 +234,15 @@ In order of how much they'd help users:
|
||||
faces, and fillets that consume neighbouring faces.
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
3. **Y-up / Z-up.** SolidCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
|
||||
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
|
||||
orientation" preference.
|
||||
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
|
||||
imported body. They should become design appearances on import.
|
||||
5. **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).
|
||||
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.21.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -23,26 +23,14 @@ 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" }
|
||||
|
||||
# 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"
|
||||
@@ -56,40 +44,14 @@ spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "3", 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; native and browser builds).
|
||||
# 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.3"
|
||||
cadmpeg-ir = "=0.6.3"
|
||||
cadmpeg-registry = { version = "=0.6.3", default-features = false, features = ["fcstd", "rhino", "step"] }
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# Ends a process group with the system call (MIT; already in the tree through zip).
|
||||
nix = { version = "0.31", default-features = false, features = ["signal"] }
|
||||
|
||||
# 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"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
@@ -99,7 +61,6 @@ 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"
|
||||
|
||||
@@ -134,9 +95,6 @@ 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
|
||||
@@ -148,7 +106,6 @@ debug = "line-tables-only"
|
||||
|
||||
# truck-shapeops with SolidCraft 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" }
|
||||
@@ -156,9 +113,3 @@ 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
|
||||
|
||||
@@ -29,8 +29,5 @@ for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL
|
||||
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.
|
||||
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
describe compatibility goals.
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<p align="center">
|
||||
<img src="assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" width="96" height="96" alt="SolidCraft" />
|
||||
</p>
|
||||
# SolidCraft
|
||||
|
||||
<h1 align="center">SolidCraft</h1>
|
||||
**An open-source parametric 3D CAD application built in pure Rust.**
|
||||
|
||||
<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>
|
||||
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.
|
||||
|
||||
<div align="center">
|
||||

|
||||
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
[![Download for Windows][badge-windows]][windows-msi]
|
||||
[![Download for macOS][badge-macos]][macos-dmg]
|
||||
@@ -19,126 +18,223 @@
|
||||
[![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]).
|
||||
- **Windows:** download and run the [installer][windows-msi].
|
||||
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
|
||||
[portable zip][windows-portable] that runs without installing.
|
||||
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
|
||||
(Apple silicon and Intel).
|
||||
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
|
||||
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
|
||||
openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
|
||||
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
|
||||
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).
|
||||
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
|
||||
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
|
||||
|
||||
## 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 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
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
More commands, the Windows cross-build and the browser build are in [docs/building.md](docs/building.md).
|
||||
|
||||
## 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).
|
||||
|
||||
## 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).
|
||||
|
||||
## Contributing
|
||||
|
||||
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).
|
||||
|
||||
## Sponsors
|
||||
|
||||
[](https://www.blacksmith.sh)
|
||||
|
||||
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
|
||||
sponsors its CI minutes.
|
||||
|
||||
## 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)).
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/bherbruck/solidcraft/releases).
|
||||
Install details, upgrading from earlier builds and building from source are in
|
||||
[Getting started](https://bherbruck.github.io/solidcraft/guide/getting-started.html).
|
||||
|
||||
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
|
||||
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
|
||||
[badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white
|
||||
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
|
||||
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
|
||||
[windows-msi]: https://github.com/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
|
||||
[windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
|
||||
[windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
|
||||
[windows-x86-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
|
||||
[windows-portable]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
|
||||
[macos-dmg]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
|
||||
[linux-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[linux-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
|
||||
[linux-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
|
||||
[linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
|
||||
[linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
|
||||
[linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
|
||||
[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://solidcraft.io/app
|
||||
[web-app]: https://bherbruck.github.io/solidcraft/app/
|
||||
|
||||
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Status: early but usable for simple parts.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
|
||||
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
|
||||
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Built with SolidCraft
|
||||
|
||||
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
|
||||
|
||||
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
|
||||
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
|
||||
rebuild with no errors, replayed here at 2× speed.
|
||||
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
|
||||
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
|
||||
projection, trimming, fillets and chamfers, including corners at the origin.
|
||||
New face attachments retain supported construction identities and chosen occurrences;
|
||||
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
|
||||
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
|
||||
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
|
||||
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
|
||||
the boundary and keeps existing extrusions usable.
|
||||
Curve drawing and picking adapt together to zoom and display scale, including small circles
|
||||
at Fit and sketches in placed components.
|
||||
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
|
||||
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
|
||||
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
|
||||
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
|
||||
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
|
||||
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
|
||||
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
|
||||
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
|
||||
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
|
||||
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
|
||||
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
|
||||
New revolves preserve symmetric and two-sided angular placement across reversed axes;
|
||||
older designs retain their saved convention when reopened or edited.
|
||||
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
|
||||
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
|
||||
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
|
||||
interference and material-aware mass properties. Recorded body transfers preserve modelling
|
||||
history and world placement; 3D sketch wires retain their coordinate frames through edits.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
|
||||
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
|
||||
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
|
||||
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
|
||||
provisional picture from warm geometry caches while rebuilding before editing; caches never
|
||||
replace the parametric design. Export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
|
||||
assemblies with joints as URDF for robot simulators (docs/urdf.md);
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
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
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
Feature errors and Help can open an editable **Create Issue** draft with relevant context
|
||||
and an optional reviewed screenshot; opening GitHub does not submit the report.
|
||||
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
|
||||
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
in the running app (see below).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse.
|
||||
|
||||
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
|
||||
device. A udev rule that adds the `uaccess` tag does that, for example
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
|
||||
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
|
||||
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
|
||||
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
|
||||
report descriptor, so they work as well.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
## Scripts
|
||||
|
||||
A script is a JSON list of commands, the same ones the UI runs:
|
||||
|
||||
```json
|
||||
{"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
|
||||
]}
|
||||
```
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
|
||||
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
|
||||
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
<!-- ANCHOR: architecture -->
|
||||
| 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 | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
<!-- ANCHOR_END: architecture -->
|
||||
|
||||
## Contributing
|
||||
|
||||
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
|
||||
sketches, solid features, an editable timeline, parameters and assemblies. See the
|
||||
[roadmap](ROADMAP.md) for supported operations and current limitations.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
|
||||
and licence rules, and where help is wanted most.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0, at your option. Developed under the
|
||||
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
|
||||
Fusion are trademarks of Autodesk, Inc.
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
Status as of **2026-10-11**. SolidCraft supports 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. |
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | [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). |
|
||||
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
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).
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
@@ -25,8 +21,6 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
|
||||
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).
|
||||
@@ -50,68 +44,20 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| # | 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 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, 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, browser 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 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; 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 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| 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 |
|
||||
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
|
||||
|
||||
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.
|
||||
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
|
||||
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
|
||||
applies the same gestures to model and Render views and blocks orbit on flat pages.
|
||||
|
||||
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
|
||||
action tiles, recent rows and sample cards, with recovery review and Save As/Export
|
||||
entry points. Document loading, sample caches and recovery safety retain their
|
||||
existing command paths.
|
||||
|
||||
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
|
||||
|
||||
@@ -121,18 +67,8 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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
|
||||
@@ -143,63 +79,14 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
|
||||
|
||||
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
|
||||
|
||||
- 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)
|
||||
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
|
||||
render jobs 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
|
||||
@@ -244,9 +131,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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.
|
||||
@@ -271,21 +155,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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
|
||||
|
||||
@@ -306,25 +175,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## 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.
|
||||
@@ -344,25 +194,8 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
- 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.
|
||||
|
||||
@@ -382,28 +215,19 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
- 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.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
|
||||
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
|
||||
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); 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
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
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
|
||||
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
|
||||
@@ -418,8 +242,7 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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).
|
||||
piece of sphere.
|
||||
- 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;
|
||||
@@ -453,12 +276,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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.
|
||||
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.
|
||||
@@ -500,13 +317,10 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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:
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
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.
|
||||
sections ruled.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
@@ -593,10 +407,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Assembly dragging
|
||||
|
||||
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
@@ -611,15 +421,6 @@ editing. Construction planes and points resolve drawn 3D wires without applying
|
||||
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
|
||||
@@ -665,14 +466,6 @@ and reference-object placement remain separate work, as do loft rails and center
|
||||
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.
|
||||
|
||||
@@ -21,11 +21,5 @@ 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,102 +0,0 @@
|
||||
//! Reproducible evidence through the production command/job interfaces.
|
||||
//! Usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png
|
||||
//! Writes the editable design, command timings and actual adapter/job metadata beside PNG.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
render::job::{CpuHost, ErrorCode, JobId, Phase},
|
||||
};
|
||||
use std::{
|
||||
io::Read,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn read_json(path: &str) -> Result<Value, String> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?.take(16_777_217).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
if bytes.len() > 16_777_216 {
|
||||
return Err("evidence input exceeds 16 MiB".into());
|
||||
}
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [recipe_path, params_path, output_path] = args.as_slice() else {
|
||||
return Err("usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png".into());
|
||||
};
|
||||
let output = Path::new(output_path);
|
||||
if !output.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) {
|
||||
return Err("output must have a .png extension".into());
|
||||
}
|
||||
let recipe = read_json(recipe_path)?;
|
||||
let params = read_json(params_path)?;
|
||||
let mut session = Session::default();
|
||||
eprintln!("Generating original CAD scene from {recipe_path}");
|
||||
let generation_start = Instant::now();
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
|
||||
let generation_seconds = generation_start.elapsed().as_secs_f64();
|
||||
let design_path = output.with_extension("solidcraft");
|
||||
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
|
||||
let scene = solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?;
|
||||
let bodies: Vec<Value> = scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|mesh| json!({"name":mesh.name,"vertices":mesh.mesh.positions.len(),"triangles":mesh.mesh.triangles.len()}))
|
||||
.collect();
|
||||
drop(scene);
|
||||
|
||||
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
|
||||
let (adapter, adapter_report) = solidcraft_gpu::select::pick(policy).ok_or("no adapter satisfies the requested GPU policy")?;
|
||||
let host_start = Instant::now();
|
||||
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
|
||||
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let host_seconds = host_start.elapsed().as_secs_f64();
|
||||
let preflight = session.execute("render.preflight", ¶ms).map_err(|e| e.to_string())?;
|
||||
let start = Instant::now();
|
||||
let started = session.execute("render.start", ¶ms).map_err(|e| e.to_string())?;
|
||||
let id: JobId = serde_json::from_value(started.get("job").cloned().ok_or("missing job id")?).map_err(|e| e.to_string())?;
|
||||
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
|
||||
eprintln!("Rendering through production gpu-path host: {adapter_report:?}");
|
||||
let status = loop {
|
||||
match handle.wait_timeout(Duration::from_secs(10)) {
|
||||
Ok(status) => break status,
|
||||
Err(error) if error.code == ErrorCode::TimedOut && start.elapsed() < Duration::from_secs(3600) => {
|
||||
let status = handle.status().map_err(|e| e.to_string())?;
|
||||
eprintln!(
|
||||
"Render progress: {:?} {}/{} after {:.1}s",
|
||||
status.progress.phase,
|
||||
status.progress.done,
|
||||
status.progress.total,
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = handle.cancel();
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
let render_and_encode_seconds = start.elapsed().as_secs_f64();
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(status.error.map_or_else(|| "render did not complete".into(), |e| e.to_string()));
|
||||
}
|
||||
let exported = session.execute("render.export", &json!({"job":id,"path":output_path,"overwrite":true})).map_err(|e| e.to_string())?;
|
||||
let report = json!({
|
||||
"recipe":recipe_path,"render_params":params,"adapter":adapter_report,
|
||||
"environment":{"SOLIDCRAFT_GPU":std::env::var("SOLIDCRAFT_GPU").ok(),"WGPU_BACKEND":std::env::var("WGPU_BACKEND").ok()},
|
||||
"cad_generation_seconds":generation_seconds,"gpu_host_setup_seconds":host_seconds,
|
||||
"render_and_encode_seconds":render_and_encode_seconds,
|
||||
"timing_scope":"wall time from render.start through completed PNG encoding; includes job preparation and readback, excludes CAD generation and initial GPU pipeline setup",
|
||||
"bodies":bodies,"commands":commands,"command_timings":command_timings,
|
||||
"preflight":preflight,"export":exported,"editable_design":design_path
|
||||
});
|
||||
std::fs::write(output.with_extension("json"), serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
run()
|
||||
}
|
||||
@@ -1,240 +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}),
|
||||
);
|
||||
}
|
||||
|
||||
/// A part placed after it is built: free, even as the design's first part (#2804).
|
||||
fn component(&mut self, name: &str) {
|
||||
self.c("component.create", json!({"name": name, "grounded": false}));
|
||||
}
|
||||
|
||||
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,700 +0,0 @@
|
||||
//! `bench-assistant`: the standard Assistant model test (v1, #4851), run against a desktop app.
|
||||
//!
|
||||
//! The app is already running with `--control PORT`; everything goes through its control channel
|
||||
//! (`docs/control-protocol.md`), so the command needs no window code of its own. For each task it
|
||||
//! starts a new design, runs the task's prescribed setup (a box, for some), adds the endpoint as an
|
||||
//! API agent with All permissions, starts a new chat, sends the prompt once and waits for the turn
|
||||
//! to end. The result is checked on the design (`document.inspect` with measurements), never on
|
||||
//! what the model says, except for the one question whose answer is the reply. A failed task is
|
||||
//! put in one of the issue's failure categories from the conversation's items.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT --base-url URL --model NAME
|
||||
//! [--token T] [--tasks 1,2,5] [--repeat N] [--timeout-s 300]
|
||||
//! [--json out.json] [--markdown out.md]
|
||||
//! ```
|
||||
//! prints a table of the results; `--markdown` writes the issue's comment template filled in,
|
||||
//! `--json` every task's rules, conversation and design before and after.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Standard test v1, as #4851 states it. Changing a prompt or a rule makes it another test.
|
||||
const TASKS: &str = r#"[
|
||||
{"id": "01-cube", "prompt": "Make a 20 mm cube.", "check": {"bodies": 1, "volume": 8000, "bbox_size": [20, 20, 20]}},
|
||||
{"id": "02-cylinder", "prompt": "Make a cylinder 30 mm in diameter and 50 mm tall.", "check": {"bodies": 1, "volume": 35342.92, "bbox_size": [30, 30, 50]}},
|
||||
{"id": "03-block", "prompt": "Create a block 60 mm long, 40 mm wide and 10 mm thick.", "check": {"bodies": 1, "volume": 24000, "bbox_size": [10, 40, 60]}},
|
||||
{"id": "04-sketch-extrude", "prompt": "Sketch a 50 by 30 mm rectangle on the XY plane and extrude it 15 mm.", "check": {"bodies": 1, "volume": 22500, "bbox_size": [15, 30, 50], "feature_kinds": ["Extrude"]}},
|
||||
{"id": "05-plate-hole", "prompt": "Make a plate 80 x 60 x 10 mm with a 20 mm diameter hole straight through the middle.", "check": {"bodies": 1, "volume": 44858.41, "bbox_size": [10, 60, 80], "note": "80*60*10 - pi*10^2*10"}},
|
||||
{"id": "06-fillet-box", "prompt": "Round all the edges of the box with a 3 mm radius.", "setup": [{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 40}}], "check": {"bodies": 1, "volume": 63108.53, "tol": 0.003, "feature_kinds": ["Fillet"], "note": "all 12 edges of a 40 mm cube rounded r=3"}},
|
||||
{"id": "07-move-body", "prompt": "Move the box 50 mm along the X axis.", "setup": [{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}}], "check": {"bodies": 1, "volume": 8000, "bbox_min": [50, 0, 0]}},
|
||||
{"id": "08-rename-body", "prompt": "Rename the body to Bracket.", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "body_name": "Bracket"}},
|
||||
{"id": "09-volume-question", "prompt": "What is the volume of the part?", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "unchanged": true, "reply_number": "6[ ,. ]?000(?![0-9])|6(\\.0+)? ?cm"}},
|
||||
{"id": "10-two-holes", "prompt": "Make a 100 x 50 x 5 mm plate and drill two 10 mm holes through it, each 15 mm in from the short ends and centred across the width.", "check": {"bodies": 1, "volume": 24214.6, "bbox_size": [5, 50, 100], "note": "100*50*5 - 2*pi*5^2*5"}},
|
||||
{"id": "11-any-change", "prompt": "can you use the software to make a change to the current shape, any change at all", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"changed": true, "note": "verbatim user prompt from #4846; pass = the design changed"}}
|
||||
]"#;
|
||||
|
||||
/// The agent the test adds. A fixed id replaces it on every run instead of adding another.
|
||||
const AGENT_ID: &str = "api-bench-assistant";
|
||||
const AGENT_NAME: &str = "bench";
|
||||
/// Longest control reply read (the channel's own request limit).
|
||||
const MAX_REPLY: usize = 16 << 20;
|
||||
/// Most result data kept for `--json`.
|
||||
const MAX_RESULTS: usize = 64 << 20;
|
||||
/// Deadline of the requests that only set up or read (each is answered at once by the app).
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// How often a running turn is polled.
|
||||
const POLL: Duration = Duration::from_millis(250);
|
||||
/// After this long, an idle chat with no reply yet counts as a turn that ended without one.
|
||||
const START_GRACE: Duration = Duration::from_secs(30);
|
||||
/// Absolute tolerance of the lowest corner, in millimetres.
|
||||
const MIN_CORNER_TOL: f64 = 0.05;
|
||||
|
||||
/// A JSON-lines connection to the app. Requests are sent once; a lost reply is never retried,
|
||||
/// so nothing runs twice.
|
||||
struct Control {
|
||||
addr: SocketAddr,
|
||||
token: String,
|
||||
reader: BufReader<TcpStream>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Control {
|
||||
fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
|
||||
let addr: SocketAddr = addr.parse().map_err(|_| format!("--connect: `{addr}` is not an IP address and port"))?;
|
||||
if !addr.ip().is_loopback() || addr.port() == 0 {
|
||||
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
|
||||
}
|
||||
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
|
||||
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
|
||||
if token.len() > 4096 {
|
||||
return Err("the control token is longer than 4096 bytes".into());
|
||||
}
|
||||
Self::open(addr, token)
|
||||
}
|
||||
|
||||
fn open(addr: SocketAddr, token: String) -> Result<Self, String> {
|
||||
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("connecting to {addr}: {e}"))?;
|
||||
Ok(Self { addr, token, reader: BufReader::new(stream), id: 0 })
|
||||
}
|
||||
|
||||
/// A new connection to the same app: a request that outlived its deadline keeps the old one
|
||||
/// busy until it is answered.
|
||||
fn reopen(&self) -> Result<Self, String> {
|
||||
Self::open(self.addr, self.token.clone())
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value, deadline: Instant) -> Result<Value, String> {
|
||||
let left = || deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or_else(|| "timeout".to_string());
|
||||
let io = |e: std::io::Error| match e.kind() {
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout".to_string(),
|
||||
_ => format!("control channel: {e}"),
|
||||
};
|
||||
self.reader.get_ref().set_write_timeout(Some(left()?)).map_err(io)?;
|
||||
self.id += 1;
|
||||
// The first request on a connection carries the token; sending it on every one is allowed.
|
||||
let request = json!({"id": self.id, "method": method, "params": params, "token": self.token});
|
||||
writeln!(self.reader.get_mut(), "{request}").map_err(io)?;
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
self.reader.get_ref().set_read_timeout(Some(left()?)).map_err(io)?;
|
||||
let bytes = self.reader.fill_buf().map_err(io)?;
|
||||
if bytes.is_empty() {
|
||||
return Err("the app closed the control connection".into());
|
||||
}
|
||||
let newline = bytes.iter().position(|b| *b == b'\n');
|
||||
let used = newline.map_or(bytes.len(), |n| n + 1);
|
||||
if line.len() + used > MAX_REPLY {
|
||||
return Err("a control reply is larger than 16 MiB".into());
|
||||
}
|
||||
line.extend_from_slice(bytes.get(..used).unwrap_or_default());
|
||||
self.reader.consume(used);
|
||||
if newline.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let reply: Value = serde_json::from_slice(&line).map_err(|e| format!("control reply: {e}"))?;
|
||||
if reply["id"].as_u64() != Some(self.id) {
|
||||
return Err("control reply for another request".into());
|
||||
}
|
||||
if reply["ok"] != true {
|
||||
return Err(format!("{method}: {}", reply["error"].as_str().unwrap_or("failed")));
|
||||
}
|
||||
reply.get("result").cloned().ok_or_else(|| format!("{method}: reply without a result"))
|
||||
}
|
||||
|
||||
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
|
||||
self.call("engine.execute", json!({"command": command, "params": params, "animate": false}), Instant::now() + REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
fn chat(&mut self, params: Value) -> Result<Value, String> {
|
||||
self.call("ui.agent_chat", params, Instant::now() + REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
fn inspect(&mut self) -> Result<Value, String> {
|
||||
let design = self.call("document.inspect", json!({"measure": true}), Instant::now() + REQUEST_TIMEOUT)?;
|
||||
if design.is_object() { Ok(design) } else { Err("document.inspect returned no design".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
fn tasks() -> Result<Vec<Value>, String> {
|
||||
serde_json::from_str(TASKS).map_err(|e| format!("embedded tasks: {e}"))
|
||||
}
|
||||
|
||||
fn triple(value: &Value) -> Option<[f64; 3]> {
|
||||
match value.as_array()?.as_slice() {
|
||||
[x, y, z] => {
|
||||
let n = |v: &Value| v.as_f64().filter(|n| n.is_finite());
|
||||
Some([n(x)?, n(y)?, n(z)?])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the model said (thoughts left out).
|
||||
fn reply(items: &[Value]) -> String {
|
||||
items.iter().filter(|i| i["thought"] != true).filter_map(|i| i["agent"].as_str()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// v1's reply pattern `6[ ,. ]?000(?![0-9])|6(\.0+)? ?cm`: "6000", "6 000", "6,000", "6.000" not
|
||||
/// followed by a digit, or "6 cm", "6.0cm", … (a narrow no-break space also separates thousands).
|
||||
fn volume_reply(text: &str) -> bool {
|
||||
text.match_indices('6').any(|(at, _)| {
|
||||
let tail = text.get(at + 1..).unwrap_or_default();
|
||||
let thousands = |t: &str| t.strip_prefix("000").is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_digit()));
|
||||
let separated = tail.strip_prefix([' ', ',', '.', '\u{202f}']).is_some_and(thousands);
|
||||
let cm = {
|
||||
let t = tail.strip_prefix(".0").map_or(tail, |t| t.trim_start_matches('0'));
|
||||
t.strip_prefix(' ').unwrap_or(t).starts_with("cm")
|
||||
};
|
||||
thousands(tail) || separated || cm
|
||||
})
|
||||
}
|
||||
|
||||
/// The design as the user sees it, without what reading or saving changes (path, saved state,
|
||||
/// revision, selection, undo depth, evaluation timings).
|
||||
fn content(design: &Value) -> Value {
|
||||
let mut v = design.clone();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
for key in ["path", "dirty", "revision", "selection", "undo", "redo"] {
|
||||
o.remove(key);
|
||||
}
|
||||
}
|
||||
for feature in v.get_mut("timeline").and_then(Value::as_array_mut).into_iter().flatten() {
|
||||
if let Some(o) = feature.as_object_mut() {
|
||||
o.remove("ms");
|
||||
o.remove("boolean_diagnostics");
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether the design changed: its content, or a step on the undo stack (a colour, say, that the
|
||||
/// inspection does not list).
|
||||
fn changed(before: &Value, after: &Value) -> bool {
|
||||
content(before) != content(after) || after["undo"].as_u64() > before["undo"].as_u64()
|
||||
}
|
||||
|
||||
/// The rules a task's result breaks (none: it passed). `before` and `after` are
|
||||
/// `document.inspect {measure: true}` before the prompt and after the turn.
|
||||
pub(super) fn check(rule: &Value, before: &Value, after: &Value, items: &[Value]) -> Vec<String> {
|
||||
let Some(all) = after["bodies"].as_array() else { return vec!["the design could not be read".into()] };
|
||||
// Bodies the user sees; a hidden one (a tool body, a hidden original) is not the result.
|
||||
let bodies: Vec<&Value> = all.iter().filter(|b| b["visible"] != false).collect();
|
||||
let mut errors = Vec::new();
|
||||
if let Some(n) = rule["bodies"].as_u64()
|
||||
&& bodies.len() as u64 != n
|
||||
{
|
||||
errors.push(format!("{} bodies, expected {n}", bodies.len()));
|
||||
}
|
||||
if let Some(expected) = rule["volume"].as_f64() {
|
||||
let tol = rule["tol"].as_f64().unwrap_or(0.01);
|
||||
let volumes: Vec<f64> = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).filter(|v| v.is_finite()).collect();
|
||||
let total: f64 = volumes.iter().sum();
|
||||
if volumes.len() != bodies.len() || (total - expected).abs() > expected * tol {
|
||||
errors.push(format!("volume {total:.1} mm³, expected {expected} mm³ ±{}%", tol * 100.0));
|
||||
}
|
||||
}
|
||||
let first = bodies.first().copied().unwrap_or(&Value::Null);
|
||||
let min = triple(&first["bbox"]["min"]);
|
||||
if let Some(mut expected) = triple(&rule["bbox_size"]) {
|
||||
expected.sort_by(f64::total_cmp);
|
||||
let size = min.zip(triple(&first["bbox"]["max"])).map(|(lo, hi)| {
|
||||
let mut size = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
|
||||
size.sort_by(f64::total_cmp);
|
||||
size
|
||||
});
|
||||
if !size.is_some_and(|s| s.iter().zip(expected).all(|(a, e)| (a - e).abs() <= 0.01 * e.max(1.0))) {
|
||||
errors.push(format!("size {size:?}, expected {expected:?} in any orientation"));
|
||||
}
|
||||
}
|
||||
if let Some(expected) = triple(&rule["bbox_min"])
|
||||
&& !min.is_some_and(|m| m.iter().zip(expected).all(|(a, e)| (a - e).abs() <= MIN_CORNER_TOL))
|
||||
{
|
||||
errors.push(format!("lowest corner {min:?}, expected {expected:?}"));
|
||||
}
|
||||
if let Some(name) = rule["body_name"].as_str()
|
||||
&& !bodies.iter().any(|b| b["name"] == name)
|
||||
{
|
||||
errors.push(format!("no body named {name}"));
|
||||
}
|
||||
for kind in rule["feature_kinds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let live = |f: &&Value| f["suppressed"] != true && f["rolled_back"] != true && f["error"].is_null();
|
||||
if !after["timeline"].as_array().into_iter().flatten().filter(live).any(|f| f["type"].as_str().is_some_and(|t| t.contains(kind))) {
|
||||
errors.push(format!("no {kind} in the timeline"));
|
||||
}
|
||||
}
|
||||
if rule["changed"] == true || rule["unchanged"] == true {
|
||||
let changed = before.is_object() && changed(before, after);
|
||||
if rule["changed"] == true && !changed {
|
||||
errors.push("the design did not change".into());
|
||||
}
|
||||
if rule["unchanged"] == true && (changed || !before.is_object()) {
|
||||
errors.push("the design changed".into());
|
||||
}
|
||||
}
|
||||
if rule["reply_number"].is_string() && !volume_reply(&reply(items)) {
|
||||
errors.push("the reply does not give the volume".into());
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// A tool call written into the reply as JSON: `{"name": …`, `{"command": …`, `{"function": …`,
|
||||
/// `{"tool": …`.
|
||||
fn call_as_text(text: &str) -> bool {
|
||||
text.match_indices('{').any(|(at, _)| {
|
||||
let rest = text.get(at + 1..).unwrap_or_default().trim_start();
|
||||
["\"name\"", "\"command\"", "\"function\"", "\"tool\""]
|
||||
.iter()
|
||||
.any(|key| rest.strip_prefix(key).is_some_and(|r| r.trim_start().starts_with(':')))
|
||||
})
|
||||
}
|
||||
|
||||
/// How a failed task failed, in the issue's words, from the conversation's items.
|
||||
pub(super) fn classify(items: &[Value], timed_out: bool) -> &'static str {
|
||||
if timed_out {
|
||||
return "timeout";
|
||||
}
|
||||
let tools: Vec<&Value> = items.iter().filter(|i| i.get("tool").is_some()).collect();
|
||||
// A failed call, or a batch whose answer reports a failed step (`"ok": false`).
|
||||
let failed: Vec<String> = tools
|
||||
.iter()
|
||||
.map(|t| t["output"].as_str().unwrap_or_default())
|
||||
.zip(&tools)
|
||||
.filter(|(output, t)| t["status"] == "Failed" || serde_json::from_str::<Value>(output).is_ok_and(|v| v["ok"] == false))
|
||||
.map(|(output, _)| output.to_lowercase())
|
||||
.collect();
|
||||
let notices: Vec<String> = items.iter().filter_map(|i| i["notice"].as_str()).map(str::to_lowercase).collect();
|
||||
let answer = reply(items);
|
||||
if notices.iter().any(|n| n.contains("http 400")) {
|
||||
return "the endpoint refused tools (HTTP 400)";
|
||||
}
|
||||
if notices.iter().any(|n| n.contains("max_turn_requests")) || answer.contains("requests for one message") {
|
||||
return "hit the request limit";
|
||||
}
|
||||
if call_as_text(&answer) {
|
||||
return "wrote the call as text";
|
||||
}
|
||||
if failed.iter().any(|o| o.contains("unknown command") || o.starts_with("unknown tool")) {
|
||||
return "wrong or unknown command";
|
||||
}
|
||||
if failed.iter().any(|o| o.contains("invalid parameters") || o.starts_with("not run:") || o.contains("not valid json")) {
|
||||
return "wrong arguments";
|
||||
}
|
||||
if !failed.is_empty() || notices.iter().any(|n| n.starts_with("http ")) || items.iter().any(|i| i["error"] == true) {
|
||||
return "gave up after errors";
|
||||
}
|
||||
if tools.is_empty() {
|
||||
return if answer.trim().is_empty() { "an empty reply" } else { "only talked" };
|
||||
}
|
||||
"wrong result"
|
||||
}
|
||||
|
||||
/// One run's results in the issue's comment template (`run`: which repetition).
|
||||
pub(super) fn markdown(model: &str, version: &str, results: &[Value], run: u64) -> String {
|
||||
let clean = |s: &str| s.replace(['\n', '\r', '|'], " ");
|
||||
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
|
||||
let passed = rows.iter().filter(|r| r["passed"] == true).count();
|
||||
let mut out = format!(
|
||||
"### {} (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: {}\nResult: {passed}/11\n| # | pass/fail | how it failed |\n|---|---|---|\n",
|
||||
clean(model),
|
||||
clean(version)
|
||||
);
|
||||
for n in 1..=11 {
|
||||
match rows.iter().find(|r| r["task"] == n) {
|
||||
Some(r) if r["passed"] == true => out.push_str(&format!("| {n} | pass | |\n")),
|
||||
Some(r) => out.push_str(&format!("| {n} | fail | {} |\n", clean(r["failure"].as_str().unwrap_or_default()))),
|
||||
None => out.push_str(&format!("| {n} | | not run |\n")),
|
||||
}
|
||||
}
|
||||
out.push_str(if rows.len() == 11 {
|
||||
"Notes: <anything odd, e.g. very slow, needed a retry>\n"
|
||||
} else {
|
||||
"Notes: partial run; the tasks marked \"not run\" were left out.\n"
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &str, text: &str) -> Result<(), String> {
|
||||
solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
struct Options<'a> {
|
||||
connect: &'a str,
|
||||
token: Option<&'a str>,
|
||||
base_url: &'a str,
|
||||
model: &'a str,
|
||||
tasks: Vec<usize>,
|
||||
repeat: u64,
|
||||
timeout: Duration,
|
||||
json: Option<&'a str>,
|
||||
markdown: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn options(args: &[String]) -> Result<Options<'_>, String> {
|
||||
const FLAGS: [&str; 9] = ["--connect", "--token", "--base-url", "--model", "--tasks", "--repeat", "--timeout-s", "--json", "--markdown"];
|
||||
let mut given = std::collections::BTreeMap::new();
|
||||
let mut args = args.iter();
|
||||
while let Some(flag) = args.next() {
|
||||
let Some(flag) = FLAGS.iter().find(|f| **f == flag.as_str()) else { return Err(format!("unknown bench-assistant option: {flag}")) };
|
||||
let value = args
|
||||
.next()
|
||||
.filter(|v| !v.starts_with("--") && !v.is_empty() && v.len() <= 4096)
|
||||
.ok_or_else(|| format!("{flag} needs a value (at most 4096 bytes)"))?;
|
||||
if given.insert(*flag, value.as_str()).is_some() {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
}
|
||||
let required = |key: &str| given.get(key).copied().ok_or_else(|| format!("bench-assistant needs {key}"));
|
||||
let number = |key: &str, default: u64, max: u64| -> Result<u64, String> {
|
||||
given
|
||||
.get(key)
|
||||
.map_or(Ok(default), |s| s.parse::<u64>().ok().filter(|n| (1..=max).contains(n)).ok_or_else(|| format!("{key} must be 1 to {max}")))
|
||||
};
|
||||
let tasks = match given.get("--tasks") {
|
||||
None => (1..=11).collect(),
|
||||
Some(list) => {
|
||||
let mut tasks = Vec::new();
|
||||
for n in list.split(',') {
|
||||
let n = n
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|n| (1..=11).contains(n))
|
||||
.ok_or("--tasks takes task numbers 1 to 11, separated by commas")?;
|
||||
if tasks.contains(&n) {
|
||||
return Err(format!("--tasks lists task {n} twice"));
|
||||
}
|
||||
tasks.push(n);
|
||||
}
|
||||
tasks
|
||||
}
|
||||
};
|
||||
Ok(Options {
|
||||
connect: required("--connect")?,
|
||||
token: given.get("--token").copied(),
|
||||
base_url: required("--base-url")?,
|
||||
model: required("--model")?,
|
||||
tasks,
|
||||
repeat: number("--repeat", 1, 100)?,
|
||||
timeout: Duration::from_secs(number("--timeout-s", 300, 3600)?),
|
||||
json: given.get("--json").copied(),
|
||||
markdown: given.get("--markdown").copied(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for the turn the prompt started to end. Returns the chat's last state and whether the
|
||||
/// deadline passed first.
|
||||
fn wait_for_turn(control: &mut Control, mut chat: Value, began: Instant, end: Instant) -> Result<(Value, bool), String> {
|
||||
let mut seen_busy = false;
|
||||
loop {
|
||||
let state = chat["state"].as_str().unwrap_or_default();
|
||||
let turn = !chat["turn"].is_null();
|
||||
let queued = chat["pending"].as_array().is_some_and(|p| !p.is_empty());
|
||||
seen_busy |= state == "busy" || turn;
|
||||
let idle = !matches!(state, "busy" | "starting") && !turn && !queued;
|
||||
// A turn can start and end between two polls: then the conversation has its answer.
|
||||
let answered = chat["items"].as_array().into_iter().flatten().any(|i| i.get("user").is_none());
|
||||
if idle && (seen_busy || answered || matches!(state, "exited" | "auth" | "off") || began.elapsed() >= START_GRACE) {
|
||||
return Ok((chat, false));
|
||||
}
|
||||
let Some(left) = end.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) else { return Ok((chat, true)) };
|
||||
std::thread::sleep(POLL.min(left));
|
||||
match control.call("ui.agent_chat", json!({}), end) {
|
||||
Ok(next) => chat = next,
|
||||
Err(e) if e == "timeout" => return Ok((chat, true)),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let o = options(args)?;
|
||||
let tasks = tasks()?;
|
||||
let mut control = Control::connect(o.connect, o.token)?;
|
||||
let help = control.call("ui.help", json!({}), Instant::now() + REQUEST_TIMEOUT)?;
|
||||
let version = help["version"].as_str().filter(|s| !s.is_empty() && s.len() <= 256).ok_or("the app reported no version")?.to_string();
|
||||
let config = control.chat(json!({
|
||||
"features": true,
|
||||
"add_agent": {"id": AGENT_ID, "name": AGENT_NAME, "kind": "api", "base_url": o.base_url, "model": o.model},
|
||||
}))?;
|
||||
if !config["agents"].as_array().into_iter().flatten().any(|a| a["id"] == AGENT_ID) {
|
||||
return Err("the app did not add the benchmark's agent".into());
|
||||
}
|
||||
// Put the user's permission setting back afterwards.
|
||||
let permissions = config["permissions"].as_str().map(str::to_string);
|
||||
let result = run_tasks(&mut control, &o, &tasks, &version);
|
||||
let _ = control.chat(json!({"stop": true}));
|
||||
if let Some(p) = permissions {
|
||||
let _ = control.chat(json!({"permissions": p}));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn run_tasks(control: &mut Control, o: &Options, tasks: &[Value], version: &str) -> Result<(), String> {
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let mut kept = 0;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut say = |line: String| writeln!(stdout, "{line}").map_err(|e| format!("stdout: {e}"));
|
||||
say(format!("bench-assistant v1: {} on SolidCraft {version}", o.model))?;
|
||||
say("| run | # | task | pass/fail | how it failed | seconds |\n|---|---|---|---|---|---|".into())?;
|
||||
for run in 1..=o.repeat {
|
||||
for &n in &o.tasks {
|
||||
let task = tasks.get(n - 1).ok_or("missing embedded task")?;
|
||||
// A fresh design and a fresh chat, with nothing of the last task still running.
|
||||
control.chat(json!({"stop": true}))?;
|
||||
control.execute("file.new", json!({"name": "Benchmark"}))?;
|
||||
for step in task["setup"].as_array().into_iter().flatten() {
|
||||
control.execute(step["command"].as_str().ok_or("embedded setup without a command")?, step["params"].clone())?;
|
||||
}
|
||||
control.chat(json!({"agent": AGENT_ID, "permissions": "all", "new_chat": true, "attach_view": false}))?;
|
||||
let before = control.inspect()?;
|
||||
let began = Instant::now();
|
||||
let end = began + o.timeout;
|
||||
let (chat, timed_out) = match control.call("ui.agent_chat", json!({"send": task["prompt"]}), end) {
|
||||
Ok(chat) => wait_for_turn(control, chat, began, end)?,
|
||||
Err(e) if e == "timeout" => (json!({}), true),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if timed_out {
|
||||
// The late reply would arrive on the old connection; stop the model on a new one
|
||||
// before reading the design.
|
||||
*control = control.reopen()?;
|
||||
}
|
||||
// Stop the agent before reading the result, so nothing changes the design meanwhile.
|
||||
let stopped = control.chat(json!({"stop": true}))?;
|
||||
let items = stopped["items"].as_array().or(chat["items"].as_array()).cloned().unwrap_or_default();
|
||||
let after = control.inspect()?;
|
||||
let failed_rules = check(&task["check"], &before, &after, &items);
|
||||
let passed = failed_rules.is_empty();
|
||||
let failure = if passed { "" } else { classify(&items, timed_out) };
|
||||
let seconds = began.elapsed().as_secs_f64();
|
||||
say(format!(
|
||||
"| {run} | {n} | {} | {} | {failure} | {seconds:.0} |",
|
||||
task["id"].as_str().unwrap_or_default(),
|
||||
if passed { "pass" } else { "fail" }
|
||||
))?;
|
||||
let result = json!({
|
||||
"task": n, "id": task["id"], "prompt": task["prompt"], "repeat": run, "passed": passed, "failure": failure,
|
||||
"failed_rules": failed_rules, "timed_out": timed_out, "seconds": seconds, "check": task["check"],
|
||||
"items": items, "before": before, "after": after,
|
||||
});
|
||||
kept += result.to_string().len();
|
||||
if kept > MAX_RESULTS {
|
||||
return Err("the results passed 64 MiB; run fewer tasks or repetitions".into());
|
||||
}
|
||||
results.push(result);
|
||||
// Written after every task, so an interrupted run keeps what it measured.
|
||||
if let Some(path) = o.json {
|
||||
let all = json!({"test": "v1", "model": o.model, "base_url": o.base_url, "solidcraft": version, "results": results});
|
||||
write(path, &serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?)?;
|
||||
}
|
||||
if let Some(path) = o.markdown {
|
||||
write(path, &(1..=run).map(|r| markdown(o.model, version, &results, r)).collect::<Vec<_>>().join("\n"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in 1..=o.repeat {
|
||||
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
|
||||
say(format!("run {run}: {}/{} passed", rows.iter().filter(|r| r["passed"] == true).count(), rows.len()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn design(bodies: Value, timeline: Value) -> Value {
|
||||
json!({"name": "Benchmark", "params": [], "timeline": timeline, "bodies": bodies, "sketches": [], "undo": 1, "revision": 3, "dirty": true})
|
||||
}
|
||||
|
||||
fn body(name: &str, volume: f64, min: [f64; 3], max: [f64; 3]) -> Value {
|
||||
json!({"name": name, "volume_mm3": volume, "bbox": {"min": min, "max": max}, "visible": true})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_embedded_tasks_are_v1() {
|
||||
let tasks = tasks().unwrap();
|
||||
assert_eq!(tasks.len(), 11);
|
||||
assert_eq!(tasks[0]["prompt"], "Make a 20 mm cube.");
|
||||
assert_eq!(tasks[10]["prompt"], "can you use the software to make a change to the current shape, any change at all");
|
||||
assert_eq!(tasks[5]["check"]["tol"], 0.003);
|
||||
assert_eq!(tasks[6]["check"]["bbox_min"], json!([50, 0, 0]));
|
||||
let boxes: Vec<_> = tasks.iter().filter(|t| t["setup"].is_array()).map(|t| t["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(boxes, ["06-fillet-box", "07-move-body", "08-rename-body", "09-volume-question", "11-any-change"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_rules() {
|
||||
let tasks = tasks().unwrap();
|
||||
let rule = |n: usize| tasks[n - 1]["check"].clone();
|
||||
let empty = design(json!([]), json!([]));
|
||||
let pass = |n: usize, before: &Value, after: &Value, items: &[Value]| check(&rule(n), before, after, items);
|
||||
// 3: any orientation, within 1%.
|
||||
let block = design(json!([body("Body1", 24_100.0, [0.0; 3], [40.0, 10.0, 60.2])]), json!([{"type": "Box"}]));
|
||||
assert!(pass(3, &empty, &block, &[]).is_empty());
|
||||
let thick = design(json!([body("Body1", 24_000.0, [0.0; 3], [40.0, 12.0, 60.0])]), json!([]));
|
||||
assert_eq!(pass(3, &empty, &thick, &[]).len(), 1);
|
||||
let two = design(json!([body("A", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0]), body("B", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0])]), json!([]));
|
||||
assert!(!pass(3, &empty, &two, &[]).is_empty());
|
||||
// A hidden body is not part of the result.
|
||||
let mut hidden = two.clone();
|
||||
hidden["bodies"][1]["visible"] = json!(false);
|
||||
hidden["bodies"][0]["volume_mm3"] = json!(24_000.0);
|
||||
hidden["bodies"][0]["bbox"]["max"] = json!([60.0, 40.0, 10.0]);
|
||||
assert!(pass(3, &empty, &hidden, &[]).is_empty());
|
||||
// 4: the timeline needs a working Extrude.
|
||||
let plate = |kind: Value| design(json!([body("Body1", 22_500.0, [0.0; 3], [50.0, 30.0, 15.0])]), kind);
|
||||
assert!(pass(4, &empty, &plate(json!([{"type": "Sketch"}, {"type": "Extrude"}])), &[]).is_empty());
|
||||
assert!(!pass(4, &empty, &plate(json!([{"type": "Box"}])), &[]).is_empty());
|
||||
for flag in [json!({"suppressed": true}), json!({"rolled_back": true}), json!({"error": "failed"})] {
|
||||
let mut feature = json!({"type": "Extrude"});
|
||||
feature.as_object_mut().unwrap().extend(flag.as_object().unwrap().clone());
|
||||
assert!(!pass(4, &empty, &plate(json!([feature])), &[]).is_empty(), "{flag}");
|
||||
}
|
||||
// 6: the fillet's tolerance is 0.3%.
|
||||
let start = design(json!([body("Body1", 64_000.0, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}]));
|
||||
let filleted = |v: f64| design(json!([body("Body1", v, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}, {"type": "Fillet"}]));
|
||||
assert!(pass(6, &start, &filleted(63_108.53 * 1.002), &[]).is_empty());
|
||||
assert!(!pass(6, &start, &filleted(63_108.53 * 1.004), &[]).is_empty());
|
||||
// 7: the lowest corner.
|
||||
let moved = |x: f64| design(json!([body("Body1", 8000.0, [x, 0.0, 0.0], [x + 20.0, 20.0, 20.0])]), json!([]));
|
||||
assert!(pass(7, &empty, &moved(50.0), &[]).is_empty());
|
||||
assert!(!pass(7, &empty, &moved(0.0), &[]).is_empty());
|
||||
// 8: the name.
|
||||
let named = |name: &str| design(json!([body(name, 6000.0, [0.0; 3], [30.0, 20.0, 10.0])]), json!([]));
|
||||
assert!(pass(8, &empty, &named("Bracket"), &[]).is_empty());
|
||||
assert!(!pass(8, &empty, &named("Body1"), &[]).is_empty());
|
||||
// 9: unchanged, and the reply gives the volume.
|
||||
let start = named("Body1");
|
||||
let said = |text: &str| [json!({"user": "What is the volume of the part?"}), json!({"agent": text, "thought": false})];
|
||||
let mut read = start.clone();
|
||||
read["revision"] = json!(7);
|
||||
read["dirty"] = json!(false);
|
||||
read["selection"] = json!([{"body": "Body1"}]);
|
||||
assert!(pass(9, &start, &read, &said("The volume is 6000 mm³.")).is_empty());
|
||||
assert!(!pass(9, &start, &read, &said("About 60 cm³.")).is_empty());
|
||||
assert!(!pass(9, &start, &named("Bracket"), &said("6 000 mm³")).is_empty());
|
||||
let mut undone = start.clone();
|
||||
undone["undo"] = json!(2);
|
||||
assert!(!pass(9, &start, &undone, &said("6000 mm³")).is_empty());
|
||||
// 11: any change counts; reading does not.
|
||||
assert!(pass(11, &start, &named("Bracket"), &[]).is_empty());
|
||||
assert!(pass(11, &start, &undone, &[]).is_empty());
|
||||
assert!(!pass(11, &start, &read, &[]).is_empty());
|
||||
// An unreadable design passes nothing.
|
||||
assert!(!pass(11, &start, &json!({}), &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_volume_reply() {
|
||||
for yes in ["6000", "6 000 mm³", "6,000", "6.000", "6\u{202f}000", "6 cm³", "6.00cm3", "is 6000."] {
|
||||
assert!(volume_reply(yes), "{yes}");
|
||||
}
|
||||
for no in ["60000", "6,0001", "60 cm³", "600", "6.01 cm³", ""] {
|
||||
assert!(!volume_reply(no), "{no}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_categories() {
|
||||
for (items, expected) in [
|
||||
(
|
||||
json!([{"user": "Make a 20 mm cube."}, {"agent": "{\"name\": \"execute\", \"arguments\": {}}", "thought": false}]),
|
||||
"wrote the call as text",
|
||||
),
|
||||
(
|
||||
json!([{"notice": "HTTP 400: registry.ollama.ai/library/gemma3:4b does not support tools", "error": true}]),
|
||||
"the endpoint refused tools (HTTP 400)",
|
||||
),
|
||||
(json!([{"user": "Make a 20 mm cube."}, {"agent": "", "thought": false}]), "an empty reply"),
|
||||
(json!([{"agent": "Let me think", "thought": true}]), "an empty reply"),
|
||||
(json!([{"agent": "Sure, here is how to make a cube.", "thought": false}]), "only talked"),
|
||||
(
|
||||
json!([{"tool": "1", "status": "Failed", "output": "unknown command `solid.cube` (did you mean `solid.box`?)"}]),
|
||||
"wrong or unknown command",
|
||||
),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "unknown tool `make_cube`"}]), "wrong or unknown command"),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "invalid parameters for `solid.box`: `length` is required"}]), "wrong arguments"),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "Not run: the tool arguments were not valid JSON"}]), "wrong arguments"),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "no body `Box`"}]), "gave up after errors"),
|
||||
(
|
||||
json!([{"tool": "1", "status": "Completed", "output": "{\"ok\": false, \"failed\": {\"error\": \"unknown command `solid.delete`\"}}"}]),
|
||||
"wrong or unknown command",
|
||||
),
|
||||
(
|
||||
json!([{"tool": "1", "status": "Completed"}, {"agent": "\n(stopped after 24 requests for one message; send another message to go on)"}]),
|
||||
"hit the request limit",
|
||||
),
|
||||
(json!([{"tool": "1", "status": "Completed", "output": "{}"}]), "wrong result"),
|
||||
] {
|
||||
let items = items.as_array().unwrap();
|
||||
assert_eq!(classify(items, false), expected, "{items:?}");
|
||||
assert_eq!(classify(items, true), "timeout");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_is_the_comment_template() {
|
||||
let rows: Vec<Value> =
|
||||
(1..=11).map(|n| json!({"task": n, "repeat": 1, "passed": n != 2, "failure": if n == 2 { "an empty reply" } else { "" }})).collect();
|
||||
let report = markdown("qwen2.5:7b", "0.19.0", &rows, 1);
|
||||
assert!(report.starts_with(
|
||||
"### qwen2.5:7b (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: 0.19.0\nResult: 10/11\n| # | pass/fail | how it failed |\n|---|---|---|\n| 1 | pass | |\n| 2 | fail | an empty reply |\n"
|
||||
));
|
||||
assert!(report.ends_with("| 11 | pass | |\nNotes: <anything odd, e.g. very slow, needed a retry>\n"));
|
||||
let partial = markdown("m|x", "v", &rows[..1], 1);
|
||||
assert!(partial.starts_with("### m x ("));
|
||||
assert!(partial.contains("Result: 1/11") && partial.contains("| 2 | | not run |") && partial.contains("partial run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_are_checked_before_connecting() {
|
||||
for extra in [
|
||||
&["--repeat", "0"][..],
|
||||
&["--repeat", "101"],
|
||||
&["--timeout-s", "3601"],
|
||||
&["--tasks", "0"],
|
||||
&["--tasks", "12"],
|
||||
&["--tasks", "1,1"],
|
||||
&["--model", "again"],
|
||||
&["--unexpected", "1"],
|
||||
&["--token"],
|
||||
] {
|
||||
let mut args: Vec<String> =
|
||||
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "http://127.0.0.1:11434/v1"].iter().map(|s| s.to_string()).collect();
|
||||
args.extend(extra.iter().map(|s| s.to_string()));
|
||||
assert!(options(&args).is_err(), "{extra:?}");
|
||||
}
|
||||
let args: Vec<String> =
|
||||
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "u", "--tasks", "2, 5"].iter().map(|s| s.to_string()).collect();
|
||||
let o = options(&args).unwrap();
|
||||
assert_eq!((o.tasks, o.repeat, o.timeout), (vec![2, 5], 1, Duration::from_secs(300)));
|
||||
assert!(Control::connect("10.0.0.1:9271", Some("t")).is_err());
|
||||
assert!(Control::connect("localhost", Some("t")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,11 @@ use std::sync::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::crash;
|
||||
|
||||
/// The command's usage, from the same lines the top-level usage lists.
|
||||
fn usage() -> String {
|
||||
crate::command_usage("crashes")
|
||||
}
|
||||
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);
|
||||
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);
|
||||
@@ -46,7 +43,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(usage()),
|
||||
_ => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,9 @@ 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)
|
||||
}
|
||||
|
||||
/// The options `fuzz` takes, and the ones that stand alone.
|
||||
const OPTIONS: &[&str] = &["--from", "--count", "--jobs", "--timeout", "--steps", "--out", "--report"];
|
||||
const SWITCHES: &[&str] = &["--minimise"];
|
||||
|
||||
/// 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]")?;
|
||||
// The seed is the first argument; the rest are options, and an option `fuzz-one` doesn't
|
||||
// take is refused rather than ignored.
|
||||
crate::known_args("fuzz-one", &args[1..], &["--steps"], SWITCHES, false)?;
|
||||
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.
|
||||
@@ -71,9 +64,6 @@ struct Job {
|
||||
/// 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> {
|
||||
// An option `fuzz` doesn't take is refused before a seed runs: it would otherwise be
|
||||
// ignored, and the run would go on for minutes with the wrong settings.
|
||||
crate::known_args("fuzz", args, OPTIONS, SWITCHES, false)?;
|
||||
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")?;
|
||||
|
||||
@@ -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,40 +1,26 @@
|
||||
//! 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 bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N]
|
||||
//! [--timeout-s 300] [--json out.json] [--markdown out.md]
|
||||
//! the standard Assistant model test (#4851) on a running app
|
||||
//! 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 run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--headless] [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
//! MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//!
|
||||
//! --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 bench_assistant;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
mod render;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
@@ -44,107 +30,33 @@ use serde_json::{Value, json};
|
||||
mod app_icon;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::view::Guides;
|
||||
use solidcraft_engine::render::{StandardView, render_png};
|
||||
|
||||
/// One command the CLI takes: the name to type, the usage it prints for `--help`,
|
||||
/// and whether the top-level usage lists it (an alias answers to another command's name).
|
||||
struct Command {
|
||||
name: &'static str,
|
||||
usage: &'static str,
|
||||
listed: bool,
|
||||
}
|
||||
|
||||
/// `run`'s lines in the usage.
|
||||
const USAGE_RUN: &str = " 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]\n solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
/// `eval`'s lines in the usage.
|
||||
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
|
||||
/// `script`'s lines in the usage.
|
||||
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.solidcraft|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
|
||||
/// `bench-open`'s lines in the usage.
|
||||
const USAGE_BENCH_OPEN: &str = " solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]\n (--ops: after opening, run each command of the script and undo it, timing the command, the\n evaluations it caused and the view it leaves: placing and meshing what changed)\n";
|
||||
/// `bench-agent`'s lines in the usage.
|
||||
const USAGE_BENCH_AGENT: &str = " solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]\n";
|
||||
/// `bench-assistant`'s lines in the usage.
|
||||
const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]\n";
|
||||
/// `snapshot`'s lines in the usage.
|
||||
const USAGE_SNAPSHOT: &str = " 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]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]\n solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...\n (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;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --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\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)\n";
|
||||
/// `render`'s lines in the usage.
|
||||
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
|
||||
/// `exec`'s lines in the usage.
|
||||
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
|
||||
/// `commands`'s lines in the usage.
|
||||
const USAGE_COMMANDS: &str = " solidcraft-cli commands\n";
|
||||
/// `recipe`'s lines in the usage.
|
||||
const USAGE_RECIPE: &str = " solidcraft-cli recipe <recipe.json>\n";
|
||||
/// `oracle`'s lines in the usage.
|
||||
const USAGE_ORACLE: &str = " solidcraft-cli oracle <case-dir>... [--json]\n";
|
||||
/// `fuzz`'s lines in the usage.
|
||||
const USAGE_FUZZ: &str =
|
||||
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
|
||||
/// `mcp`'s lines in the usage.
|
||||
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict]\n";
|
||||
/// `crashes`'s lines in the usage.
|
||||
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
|
||||
/// `licences`'s lines in the usage.
|
||||
const USAGE_LICENCES: &str = " solidcraft-cli licences\n";
|
||||
/// `gpu`'s lines in the usage.
|
||||
const USAGE_GPU: &str = " solidcraft-cli gpu [--policy auto|hardware|software|cpu]\n (the GPU adapters a headless render can open, best first, the one the policy picks and the tier it\n lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults\n to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)\n";
|
||||
/// `icon`'s lines in the usage.
|
||||
const USAGE_ICON: &str = " solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico\n";
|
||||
/// `fuzz-one`'s lines in the usage: a seed and the flags of one fuzz run.
|
||||
const USAGE_FUZZ_ONE: &str = " solidcraft-cli fuzz-one <seed> [--steps N] [--minimise]\n";
|
||||
/// `bench-edit`'s lines in the usage: the parameter a script changes.
|
||||
const USAGE_BENCH_EDIT: &str = " solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>\n";
|
||||
/// `save-stress`'s lines in the usage: the file saved over and over.
|
||||
const USAGE_SAVE_STRESS: &str = " solidcraft-cli save-stress <path> <rounds>\n";
|
||||
/// The flags every command takes, at the end of the top-level usage.
|
||||
const USAGE_DEBUG: &str = " --debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its\n model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate\n";
|
||||
|
||||
/// The commands the CLI takes, in the order the top-level usage lists them.
|
||||
const COMMANDS: &[Command] = &[
|
||||
Command { name: "run", usage: USAGE_RUN, listed: true },
|
||||
Command { name: "eval", usage: USAGE_EVAL, listed: true },
|
||||
Command { name: "inspect", usage: USAGE_EVAL, listed: false },
|
||||
Command { name: "script", usage: USAGE_SCRIPT, listed: true },
|
||||
Command { name: "bench-open", usage: USAGE_BENCH_OPEN, listed: true },
|
||||
Command { name: "bench-agent", usage: USAGE_BENCH_AGENT, listed: true },
|
||||
Command { name: "bench-assistant", usage: USAGE_BENCH_ASSISTANT, listed: true },
|
||||
Command { name: "snapshot", usage: USAGE_SNAPSHOT, listed: true },
|
||||
Command { name: "render", usage: USAGE_RENDER, listed: true },
|
||||
Command { name: "exec", usage: USAGE_EXEC, listed: true },
|
||||
Command { name: "commands", usage: USAGE_COMMANDS, listed: true },
|
||||
Command { name: "recipe", usage: USAGE_RECIPE, listed: true },
|
||||
Command { name: "oracle", usage: USAGE_ORACLE, listed: true },
|
||||
Command { name: "fuzz", usage: USAGE_FUZZ, listed: true },
|
||||
Command { name: "fuzz-one", usage: USAGE_FUZZ_ONE, listed: false },
|
||||
Command { name: "mcp", usage: USAGE_MCP, listed: true },
|
||||
Command { name: "crashes", usage: USAGE_CRASHES, listed: true },
|
||||
Command { name: "licences", usage: USAGE_LICENCES, listed: true },
|
||||
Command { name: "licenses", usage: USAGE_LICENCES, listed: false },
|
||||
Command { name: "gpu", usage: USAGE_GPU, listed: true },
|
||||
Command { name: "icon", usage: USAGE_ICON, listed: true },
|
||||
Command { name: "bench-edit", usage: USAGE_BENCH_EDIT, listed: false },
|
||||
Command { name: "save-stress", usage: USAGE_SAVE_STRESS, listed: false },
|
||||
];
|
||||
const USAGE: &str = "usage:
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
|
||||
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
|
||||
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
|
||||
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
logger::init(&mut args);
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
// Asked-for help is not an error: the command's usage on stdout, exit 0. It is read
|
||||
// before the command runs, so `--help` never starts a run (a fuzz run, a server).
|
||||
Some(cmd) if wants_help(&args[1..]) && is_command(cmd) => {
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(command_usage(cmd).as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
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();
|
||||
@@ -158,7 +70,7 @@ fn main() -> ExitCode {
|
||||
}
|
||||
})
|
||||
}
|
||||
Some("recipe") => args.get(1).ok_or_else(usage).and_then(|p| {
|
||||
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)))
|
||||
}),
|
||||
@@ -179,21 +91,11 @@ fn main() -> ExitCode {
|
||||
// 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..]),
|
||||
// The standard Assistant model test (#4851), run against a desktop app over its control channel.
|
||||
Some("bench-assistant") => bench_assistant::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()),
|
||||
_ => Err(USAGE.to_string()),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -208,35 +110,6 @@ fn pretty(v: &Value) -> String {
|
||||
serde_json::to_string_pretty(v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The whole usage: every command's lines under one `usage:` line. The lines come from the
|
||||
/// same place a command's own `--help` prints, so the two cannot drift apart.
|
||||
fn usage() -> String {
|
||||
let mut s = String::from("usage:\n");
|
||||
for c in COMMANDS.iter().filter(|c| c.listed) {
|
||||
s.push_str(c.usage);
|
||||
}
|
||||
s.push_str(USAGE_DEBUG);
|
||||
s
|
||||
}
|
||||
|
||||
/// A command's own usage: its lines under a `usage:` line.
|
||||
fn command_usage(cmd: &str) -> String {
|
||||
match COMMANDS.iter().find(|c| c.name == cmd) {
|
||||
Some(c) => format!("usage:\n{}", c.usage),
|
||||
None => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the name is a command the CLI takes.
|
||||
fn is_command(cmd: &str) -> bool {
|
||||
COMMANDS.iter().any(|c| c.name == cmd)
|
||||
}
|
||||
|
||||
/// Whether the arguments ask for help.
|
||||
fn wants_help(args: &[String]) -> bool {
|
||||
args.iter().any(|a| a == "--help" || a == "-h")
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -246,14 +119,7 @@ pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
|
||||
/// 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);
|
||||
@@ -263,22 +129,16 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
|
||||
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 if v.get("features").is_some() && v.get("commands").is_none() {
|
||||
let script = recipe::to_script(&v)?;
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
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())?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -296,11 +156,7 @@ fn positional(args: &[String]) -> Option<&String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if a.starts_with("--") {
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--timings" | "--profile" | "--allow-errors" | "--no-grid" | "--no-axes") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
@@ -308,66 +164,32 @@ fn positional(args: &[String]) -> Option<&String> {
|
||||
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());
|
||||
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))
|
||||
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
|
||||
let mut s = match base {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
let results = if args.iter().any(|a| a == "--results") {
|
||||
// Collect every command's result (scripts only), then process requested files too.
|
||||
let v = read_json(path.ok_or("--results needs a script")?)?;
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
Some(Value::Array(r))
|
||||
} else {
|
||||
(open(base, path)?, None)
|
||||
match (base, path) {
|
||||
(Some(_), Some(p)) => {
|
||||
let v = read_json(p)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
(None, Some(p)) => s = load(p)?,
|
||||
_ => {}
|
||||
}
|
||||
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
|
||||
@@ -382,48 +204,10 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
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.
|
||||
println!("{}", pretty(&results));
|
||||
} else if !args.iter().any(|a| a == "--quiet") {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. `positionals` says whether the
|
||||
/// command takes a word of its own (a file, a seed): `fuzz` takes none, and would start a run.
|
||||
fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], positionals: bool) -> 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 switches.contains(&a.as_str()) {
|
||||
i += 1;
|
||||
} else if a.starts_with("--") || !positionals {
|
||||
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
println!("{}", pretty(&m));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -442,8 +226,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
}
|
||||
|
||||
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)?;
|
||||
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())?;
|
||||
@@ -455,119 +238,24 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
"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)?;
|
||||
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"])),
|
||||
}
|
||||
println!("{}", pretty(&out));
|
||||
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_args(
|
||||
"snapshot",
|
||||
args,
|
||||
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
|
||||
&["--no-grid", "--no-axes"],
|
||||
true,
|
||||
)?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(usage());
|
||||
}
|
||||
let path = args.first().ok_or_else(|| 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 w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
|
||||
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
|
||||
let s = load(path)?;
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
if let Some(v) = flag(args, "--view") {
|
||||
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
|
||||
}
|
||||
let 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));
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
|
||||
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -613,8 +301,7 @@ fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let id = args.first().ok_or_else(usage)?;
|
||||
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!({}),
|
||||
@@ -629,7 +316,6 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
/// 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 {
|
||||
@@ -645,65 +331,23 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
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)?)),
|
||||
// No arguments: the running desktop app, found through the discovery file it wrote
|
||||
// (docs/control-protocol.md); none running (or `--headless`): a session of its own.
|
||||
(None, None) => {
|
||||
if !args.iter().any(|a| a == "--headless")
|
||||
&& let Some(port) = discover_app()
|
||||
{
|
||||
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
|
||||
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
|
||||
}
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
Box::new(headless)
|
||||
}
|
||||
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect_with_token(addr, flag(args, "--token")).map_err(|e| {
|
||||
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
|
||||
format!(
|
||||
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
|
||||
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
|
||||
)
|
||||
})?),
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
|
||||
};
|
||||
serve_mcp(backend, scope)
|
||||
}
|
||||
|
||||
fn serve_mcp(backend: Box<dyn solidcraft_mcp::Backend>, scope: Option<solidcraft_engine::io::vfs::PathScope>) -> Result<(), String> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// The port of the running desktop app: the one its discovery file names, if something listens
|
||||
/// there (a file left by an app that crashed names a closed port).
|
||||
fn discover_app() -> Option<u16> {
|
||||
let port = solidcraft_engine::control_auth::read_current_port()?;
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
@@ -756,38 +400,8 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
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> {
|
||||
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);
|
||||
@@ -798,111 +412,32 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
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 doc = if step {
|
||||
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
read = Some(imported.import);
|
||||
(doc, None)
|
||||
doc
|
||||
} 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())?
|
||||
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
};
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len(), "saved_model": saved.is_some()}));
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::opened(doc, saved.as_ref());
|
||||
let s = Session::new(doc);
|
||||
drop(read);
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
|
||||
// only the features whose state a thinned file left out.
|
||||
let adopted = saved.is_some() && s.model.last_recomputed == 0;
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted, "recomputed": s.model.last_recomputed}));
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
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})
|
||||
);
|
||||
}
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ fn extent(f: &Value, p: &mut Value) {
|
||||
"symmetric" => {
|
||||
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
|
||||
p["direction"] = json!("symmetric");
|
||||
p["distance"] = if full { dist } else { json!(format!("({}) * 2", expr_text(&dist))) };
|
||||
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
|
||||
}
|
||||
"two_sides" => {
|
||||
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
|
||||
@@ -296,17 +296,6 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
Some("sketch") => sketch_cmds(f, &mut out)?,
|
||||
Some("extrude") => {
|
||||
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
|
||||
// Thin: {sketch, curves, thickness, side?, thickness2?} instead of profiles.
|
||||
if let (Some(thin), Some(params)) = (f.get("thin"), p.as_object_mut()) {
|
||||
params.remove("profiles");
|
||||
params.insert("thin".into(), json!(true));
|
||||
params.insert("sketch".into(), thin.get("sketch").cloned().unwrap_or(Value::Null));
|
||||
for key in ["curves", "thickness", "side", "thickness2"] {
|
||||
if let Some(v) = thin.get(key) {
|
||||
params.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
extent(f, &mut p);
|
||||
out.push(json!({"command": "solid.extrude", "params": p}));
|
||||
}
|
||||
@@ -423,24 +412,6 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
|
||||
"operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
|
||||
// (`u`, `v`), as `surface.boundary` takes them.
|
||||
Some("boundary_surface") => {
|
||||
let mut p = json!({"name": name});
|
||||
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
|
||||
if let Some(v) = f.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
|
||||
p["body_name"] = b.clone();
|
||||
}
|
||||
out.push(json!({"command": "surface.boundary", "params": p}));
|
||||
}
|
||||
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
|
||||
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
|
||||
"operation": op(f), "name": name}})),
|
||||
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
|
||||
Some("shell") => {
|
||||
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
|
||||
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
|
||||
|
||||
@@ -1,115 +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 progressive = false;
|
||||
let mut samples = None;
|
||||
let mut max_depth = None;
|
||||
let mut seed = None;
|
||||
let mut exposure = None;
|
||||
let mut lighting = None;
|
||||
let mut tone_map = None;
|
||||
let mut environment = 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() {
|
||||
"--progressive" => progressive = true,
|
||||
"--transparent" => transparent = true,
|
||||
"--overwrite" => overwrite = true,
|
||||
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
|
||||
| "--tone-map" | "--environment" => {
|
||||
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")?),
|
||||
"--samples" => samples = Some(value.parse::<u32>().map_err(|_| "invalid samples")?),
|
||||
"--max-depth" => max_depth = Some(value.parse::<u32>().map_err(|_| "invalid depth")?),
|
||||
"--seed" => seed = Some(value.parse::<u32>().map_err(|_| "invalid seed")?),
|
||||
"--exposure" => exposure = Some(value.parse::<f64>().map_err(|_| "invalid exposure")?),
|
||||
"--environment" => {
|
||||
let text =
|
||||
solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read environment: {e}"))?;
|
||||
environment = Some(serde_json::from_str::<serde_json::Value>(&text).map_err(|e| e.to_string())?);
|
||||
}
|
||||
"--lighting" => {
|
||||
let text = solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read lighting: {e}"))?;
|
||||
lighting = Some(serde_json::from_str::<solidcraft_engine::render::job::StudioLighting>(&text).map_err(|e| e.to_string())?);
|
||||
}
|
||||
"--tone-map" => {
|
||||
tone_map = Some(match value.as_str() {
|
||||
"reinhard" => solidcraft_engine::render::job::ToneMap::Reinhard,
|
||||
"aces_fitted" => solidcraft_engine::render::job::ToneMap::AcesFitted,
|
||||
_ => return Err("tone-map must be reinhard or aces_fitted".into()),
|
||||
})
|
||||
}
|
||||
_ => 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())?;
|
||||
if progressive {
|
||||
session.register_render_job_host(solidcraft_gpu::path::host().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;
|
||||
}
|
||||
if let Some(exposure) = exposure {
|
||||
settings.exposure_ev = exposure;
|
||||
}
|
||||
let params = json!({"settings":settings,"transparent_background":transparent,"progressive":progressive,"samples":samples,"max_depth":max_depth,"seed":seed,"lighting":lighting,"tone_map":tone_map,"environment":environment});
|
||||
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(())
|
||||
}
|
||||
@@ -13,10 +13,8 @@
|
||||
//! fails the test (a regression). Newly passing actions are reported; record them with
|
||||
//! `cargo xtask action-matrix --bless` (or `ACTION_MATRIX_BLESS=1` on [`action_matrix_all`]).
|
||||
//! Every CI run checks a fixed sample ([`action_matrix_sample`], under a minute); the full matrix
|
||||
//! is the ignored [`action_matrix_shard`], which the nightly "Action matrix" jobs run one shard
|
||||
//! each (`ACTION_MATRIX_SHARD=k/n`), cut by what each design costs (`tests/action_matrix/costs.json`).
|
||||
//! [`action_matrix_determinism`] runs a sample twice and lists any action whose outcome changed.
|
||||
//! See docs/action-matrix.md.
|
||||
//! is four ignored tests (`action_matrix_full_N`) that the nightly "Action matrix" job runs side by
|
||||
//! side. See docs/action-matrix.md.
|
||||
//!
|
||||
//! Each design runs in a child process (this test binary, [`action_matrix_worker`]) so an action
|
||||
//! that hangs or aborts is recorded as such and the rest of the design still runs.
|
||||
@@ -35,10 +33,6 @@ use solidcraft_engine::geom::Vec3;
|
||||
|
||||
/// One in this many actions runs on pull requests (by a stable hash of its key).
|
||||
const PR_SAMPLE: u64 = 40;
|
||||
/// One in this many actions runs twice in the nightly determinism check, on the designs whose
|
||||
/// whole matrix takes at most [`DETERMINISM_MAX_SECS`] on a runner (most of the corpus).
|
||||
const DETERMINISM_SAMPLE: u64 = 8;
|
||||
const DETERMINISM_MAX_SECS: f64 = 300.0;
|
||||
/// Designs that take seconds to build: the full matrix only.
|
||||
const SLOW: [&str; 7] = [
|
||||
"oracle/41-tube-radial-holes",
|
||||
@@ -350,7 +344,7 @@ fn actions(s: &Session) -> Vec<Action> {
|
||||
// The surface's normal at `p` (either way round): exact on analytic faces.
|
||||
let normal_at = |f: u32, p: Vec3| -> Option<Vec3> {
|
||||
match face_surface(&b.body, f as usize) {
|
||||
Some(FaceSurface::Plane { normal, .. }) => Some(*normal),
|
||||
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => {
|
||||
let v = p - origin;
|
||||
(v - axis * v.dot(axis)).normalized()
|
||||
@@ -629,25 +623,17 @@ fn action_matrix_worker() {
|
||||
out.flush().unwrap();
|
||||
};
|
||||
let Some((_, path)) = designs().into_iter().find(|(n, _)| *n == design) else { panic!("no design {design}") };
|
||||
// A slice of a heavy design (`j/n`): the actions whose name hashes to `j` modulo `n` (by
|
||||
// index, slices would take whole kinds of action when n shares a factor with the number of
|
||||
// actions per entity, and a slice of only fillets runs three times as long); the first
|
||||
// slice records the load.
|
||||
let slice: Option<(usize, usize)> = std::env::var("ACTION_MATRIX_SLICE").ok().and_then(|v| parse_part(&v));
|
||||
let first = slice.is_none_or(|(j, _)| j == 0);
|
||||
let t0 = Instant::now();
|
||||
let s = match load(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if first {
|
||||
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
|
||||
}
|
||||
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
|
||||
emit(json!({"done": true}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let before = errors(&s);
|
||||
if from == 0 && first {
|
||||
if from == 0 {
|
||||
let r = if before.is_empty() { Ok(()) } else { Err(("timeline_error".into(), before.values().cloned().collect::<Vec<_>>().join("; "))) };
|
||||
emit(outcome(&design, "design", "load", &r, t0.elapsed().as_millis()));
|
||||
}
|
||||
@@ -661,9 +647,6 @@ fn action_matrix_worker() {
|
||||
if only.as_ref().is_some_and(|o| !o.iter().any(|x| x == a.action)) {
|
||||
continue;
|
||||
}
|
||||
if slice.is_some_and(|(j, n)| stable_hash(&format!("{} {}", a.entity, a.action)) % n as u64 != j as u64) {
|
||||
continue;
|
||||
}
|
||||
// Whole families of pieces are sampled together (see [`family`]).
|
||||
let k = key(&design, &family(&a.entity), a.action);
|
||||
if !stable_hash(&k).is_multiple_of(sample) {
|
||||
@@ -710,8 +693,6 @@ fn action_matrix_worker() {
|
||||
&& let Some((name, expr)) = &edit
|
||||
&& stable_hash(&format!("{k} edit")).is_multiple_of(EDIT_SAMPLE)
|
||||
{
|
||||
// Marked like an action, so a hang here is put down to the edit round (not to loading).
|
||||
emit(json!({"start": i, "edit": true}));
|
||||
let t = Instant::now();
|
||||
let start = s.doc.features.len();
|
||||
let r = scratch
|
||||
@@ -725,155 +706,46 @@ fn action_matrix_worker() {
|
||||
emit(json!({"done": true, "actions": list.len()}));
|
||||
}
|
||||
|
||||
/// `"k/n"` (1-based `k`) as `(k - 1, n)`.
|
||||
fn parse_part(v: &str) -> Option<(usize, usize)> {
|
||||
let (k, n) = v.split_once('/')?;
|
||||
let (k, n): (usize, usize) = (k.trim().parse().ok()?, n.trim().parse().ok()?);
|
||||
(k >= 1 && k <= n).then_some((k - 1, n))
|
||||
}
|
||||
|
||||
/// What one design costs on a CI runner (`tests/action_matrix/costs.json`, measured by the
|
||||
/// nightly shards): the whole run and the load before its first action, in seconds.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cost {
|
||||
secs: f64,
|
||||
load: f64,
|
||||
}
|
||||
|
||||
fn costs_path() -> PathBuf {
|
||||
root().join("tests/action_matrix/costs.json")
|
||||
}
|
||||
|
||||
fn read_costs() -> BTreeMap<String, Cost> {
|
||||
let Some(v) = std::fs::read(costs_path()).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else { return BTreeMap::new() };
|
||||
v["designs"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(d, c)| (d.clone(), Cost { secs: c["secs"].as_f64().unwrap_or(0.0), load: c["load"].as_f64().unwrap_or(0.0) }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A design new to the corpus (not in costs.json yet) is planned as this.
|
||||
const UNKNOWN_COST: Cost = Cost { secs: 120.0, load: 5.0 };
|
||||
/// A design that takes longer than this on a runner is split into slices of about this much, or
|
||||
/// five loads if that is more (each slice loads the design again), so no shard waits on one design.
|
||||
const SLICE_SECS: f64 = 600.0;
|
||||
/// Designs that take longer than this to load on a runner stay out of the pull request sample.
|
||||
const SAMPLE_MAX_LOAD_SECS: f64 = 8.0;
|
||||
|
||||
/// A piece of work: one design, or a slice `(j, n)` of a heavy one, and what it should cost.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Unit {
|
||||
design: String,
|
||||
slice: Option<(usize, usize)>,
|
||||
cost: f64,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
fn name(&self) -> String {
|
||||
match self.slice {
|
||||
Some((j, n)) => format!("{} [{}/{n}]", self.design, j + 1),
|
||||
None => self.design.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole matrix split into `parts` shards of about equal cost: heavy designs in slices, then
|
||||
/// the largest pieces first, each to the shard with the least so far. The same corpus and
|
||||
/// costs.json always give the same shards.
|
||||
fn plan(designs: &[String], costs: &BTreeMap<String, Cost>, parts: usize) -> Vec<Vec<Unit>> {
|
||||
let mut units = Vec::new();
|
||||
for d in designs {
|
||||
let c = costs.get(d).copied().unwrap_or(UNKNOWN_COST);
|
||||
let n = ((c.secs / SLICE_SECS.max(5.0 * c.load)).ceil() as usize).max(1);
|
||||
if n == 1 {
|
||||
units.push(Unit { design: d.clone(), slice: None, cost: c.secs });
|
||||
} else {
|
||||
let each = c.load + (c.secs - c.load).max(0.0) / n as f64;
|
||||
units.extend((0..n).map(|j| Unit { design: d.clone(), slice: Some((j, n)), cost: each }));
|
||||
}
|
||||
}
|
||||
units.sort_by(|a, b| b.cost.total_cmp(&a.cost).then_with(|| a.name().cmp(&b.name())));
|
||||
let mut shards: Vec<(f64, Vec<Unit>)> = vec![(0.0, Vec::new()); parts];
|
||||
for u in units {
|
||||
let i = (0..parts).min_by(|&a, &b| shards[a].0.total_cmp(&shards[b].0)).unwrap_or(0);
|
||||
shards[i].0 += u.cost;
|
||||
shards[i].1.push(u);
|
||||
}
|
||||
shards.into_iter().map(|(_, v)| v).collect()
|
||||
}
|
||||
|
||||
/// Every design's name, with `ACTION_MATRIX_FILTER` applied.
|
||||
fn design_names() -> Vec<String> {
|
||||
/// Run the actions (one in `sample`) of every design in part `part` of `parts`, in child
|
||||
/// processes, `jobs` at a time.
|
||||
fn run_matrix(sample: u64, part: usize, parts: usize) -> Vec<Value> {
|
||||
let designs = designs();
|
||||
assert!(designs.len() > 80, "only {} designs", designs.len());
|
||||
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
|
||||
designs.into_iter().map(|(n, _)| n).filter(|n| filter.as_deref().is_none_or(|f| n.contains(f))).collect()
|
||||
}
|
||||
|
||||
/// Run the actions (one in `sample`) of these units in child processes, `jobs` at a time.
|
||||
///
|
||||
/// `ACTION_MATRIX_WORKDIR` keeps each design's raw outcomes there (one JSON line each, written as
|
||||
/// they come, so a run cut short still shows what it did) and `ACTION_MATRIX_TIMINGS` appends
|
||||
/// each unit's time to that file (JSON lines).
|
||||
fn run_units(sample: u64, queue: &[Unit]) -> Vec<Value> {
|
||||
let keep = std::env::var_os("ACTION_MATRIX_WORKDIR").map(PathBuf::from);
|
||||
static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("action-matrix-{}-{run}", std::process::id())));
|
||||
let dir = std::env::temp_dir().join(format!("action-matrix-{}-{part}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let timings = std::env::var_os("ACTION_MATRIX_TIMINGS").map(PathBuf::from);
|
||||
let jobs: usize = std::env::var("ACTION_MATRIX_JOBS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_else(|| std::thread::available_parallelism().map_or(2, |n| n.get()))
|
||||
.max(1);
|
||||
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
|
||||
let queue: Vec<(String, PathBuf)> = designs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, (n, _))| i % parts == part && filter.as_deref().is_none_or(|f| n.contains(f)) && (sample == 1 || !SLOW.contains(&n.as_str())))
|
||||
.map(|(_, d)| d)
|
||||
.collect();
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let results = std::sync::Mutex::new(Vec::new());
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..jobs {
|
||||
scope.spawn(|| {
|
||||
while let Some(u) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let file = u.name().replace('/', "__").replace([' ', '[', ']'], "_");
|
||||
let out = dir.join(format!("{file}.r{run}.jsonl"));
|
||||
let t = Instant::now();
|
||||
let lines = run_design(&u.design, &out, sample, u.slice);
|
||||
let secs = t.elapsed().as_secs_f64();
|
||||
let load = lines.iter().find(|o| o["entity"] == "design" && o["action"] == "load").and_then(|o| o["ms"].as_f64()).unwrap_or(0.0) / 1000.0;
|
||||
if let Some(p) = &timings {
|
||||
let line = json!({"design": u.design, "slice": u.slice.map(|(j, n)| format!("{}/{n}", j + 1)), "secs": round3(secs), "load": round3(load), "actions": lines.len()});
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(p).unwrap();
|
||||
writeln!(f, "{line}").unwrap();
|
||||
}
|
||||
eprintln!("action matrix: {} took {secs:.1} s ({} outcomes, load {load:.1} s)", u.name(), lines.len());
|
||||
while let Some((name, _)) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let out = dir.join(format!("{}.jsonl", name.replace('/', "__")));
|
||||
let lines = run_design(name, &out, sample);
|
||||
results.lock().unwrap().extend(lines);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if keep.is_none() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut v = results.into_inner().unwrap();
|
||||
v.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
|
||||
v
|
||||
}
|
||||
|
||||
/// Run the actions (one in `sample`) of every design (the pull request sample leaves out the
|
||||
/// slow-loading ones).
|
||||
fn run_matrix(sample: u64) -> Vec<Value> {
|
||||
let costs = read_costs();
|
||||
let queue: Vec<Unit> = design_names()
|
||||
.into_iter()
|
||||
.filter(|n| sample == 1 || !(SLOW.contains(&n.as_str()) || costs.get(n).is_some_and(|c| c.load > SAMPLE_MAX_LOAD_SECS)))
|
||||
.map(|design| Unit { design, slice: None, cost: 0.0 })
|
||||
.collect();
|
||||
run_units(sample, &queue)
|
||||
}
|
||||
|
||||
/// One design in child processes, restarting after an action that hangs or aborts.
|
||||
fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize)>) -> Vec<Value> {
|
||||
fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
let _ = std::fs::remove_file(out);
|
||||
let mut from = 0usize;
|
||||
let mut results = Vec::new();
|
||||
@@ -885,7 +757,6 @@ fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize
|
||||
.env("ACTION_MATRIX_OUT", out)
|
||||
.env("ACTION_MATRIX_FROM", from.to_string())
|
||||
.env("ACTION_MATRIX_SAMPLE", sample.to_string())
|
||||
.env("ACTION_MATRIX_SLICE", slice.map(|(j, n)| format!("{}/{n}", j + 1)).unwrap_or_default())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
@@ -914,18 +785,13 @@ fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize
|
||||
.map(|f| std::io::BufReader::new(f).lines().map_while(Result::ok).filter_map(|l| serde_json::from_str(&l).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let done = lines.iter().any(|l| l.get("done").is_some());
|
||||
let last = lines.iter().rev().take_while(|l| l.get("start").is_some() || l.get("design").is_none()).find(|l| l.get("start").is_some());
|
||||
let last_start = last.and_then(|l| l["start"].as_u64());
|
||||
let in_edit = last.is_some_and(|l| l["edit"] == true);
|
||||
let last_start = lines.iter().rev().take_while(|l| l.get("start").is_some() || l.get("design").is_none()).find_map(|l| l["start"].as_u64());
|
||||
results = lines.iter().filter(|l| l.get("design").is_some()).cloned().collect();
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
// It stopped inside action `last_start`: record it and go on after it.
|
||||
let Some(i) = last_start else {
|
||||
if slice.is_some_and(|(j, _)| j > 0) {
|
||||
break;
|
||||
}
|
||||
extra.push(
|
||||
json!({"design": design, "entity": "design", "action": "load", "ok": false, "kind": if timed_out { "timeout" } else { "crash" },
|
||||
"message": format!("the design did not load ({status:?})")}),
|
||||
@@ -934,7 +800,7 @@ fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize
|
||||
};
|
||||
// Which action was it? The child enumerates them; ask it by running from there with a
|
||||
// marker: simplest is to record the index and let the next run name it.
|
||||
extra.push(json!({"design": design, "index": i, "edit": in_edit, "kind": if timed_out { "timeout" } else { "crash" },
|
||||
extra.push(json!({"design": design, "index": i, "kind": if timed_out { "timeout" } else { "crash" },
|
||||
"message": if timed_out { format!("no result after {} s", ACTION_TIMEOUT.as_secs()) } else { format!("the process ended: {status:?}") }}));
|
||||
from = i as usize + 1;
|
||||
}
|
||||
@@ -943,10 +809,7 @@ fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize
|
||||
let names = action_names(design);
|
||||
for e in &mut extra {
|
||||
if let Some(i) = e.get("index").and_then(Value::as_u64) {
|
||||
let (entity, mut action) = names.get(i as usize).cloned().unwrap_or_else(|| (format!("action{i}"), "unknown".into()));
|
||||
if e["edit"] == true {
|
||||
action += "+edit";
|
||||
}
|
||||
let (entity, action) = names.get(i as usize).cloned().unwrap_or_else(|| (format!("action{i}"), "unknown".into()));
|
||||
*e = json!({"design": design, "entity": entity, "action": action, "ok": false, "kind": e["kind"], "message": e["message"]});
|
||||
}
|
||||
}
|
||||
@@ -993,12 +856,9 @@ fn write_baseline(results: &[Value]) {
|
||||
m.entry(design).or_default().entry(entity).or_default().extend(actions.as_str().unwrap_or("").split_whitespace().map(str::to_string));
|
||||
}
|
||||
}
|
||||
// A timeout of an action that passed keeps it passing (see [`ratchet`]).
|
||||
let base = read_baseline();
|
||||
for o in results {
|
||||
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
|
||||
let kept = o["kind"] == "timeout" && base.get(&key(design, entity, action)) == Some(&true);
|
||||
let mark = if o["ok"].as_bool() == Some(true) || kept { "" } else { "!" };
|
||||
let mark = if o["ok"].as_bool() == Some(true) { "" } else { "!" };
|
||||
m.entry(design).or_default().entry(entity).or_default().push(format!("{mark}{action}"));
|
||||
}
|
||||
// One entity per line, sorted: small diffs when a few actions change.
|
||||
@@ -1027,18 +887,10 @@ fn ratchet(results: &[Value], full: bool) {
|
||||
// baseline, over the same names this run tried, and in this run.
|
||||
let mut fam: BTreeMap<String, (usize, usize, Vec<String>, Vec<String>)> = BTreeMap::new();
|
||||
let mut fresh = 0usize;
|
||||
// An action that passed in the baseline and now runs past ACTION_TIMEOUT is listed but not
|
||||
// judged: on a busy runner the slowest actions (40-80 s on oracle 41's tube) land either side
|
||||
// of the limit from one night to the next. An error or a crash is judged as usual.
|
||||
let mut slow = Vec::new();
|
||||
for o in results {
|
||||
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
|
||||
let k = key(design, entity, action);
|
||||
let ok = o["ok"].as_bool() == Some(true);
|
||||
if o["kind"] == "timeout" && base.get(&k) == Some(&true) {
|
||||
slow.push(k);
|
||||
continue;
|
||||
}
|
||||
let f = fam.entry(key(design, &family(entity), action)).or_default();
|
||||
match base.get(&k) {
|
||||
Some(was) => {
|
||||
@@ -1075,14 +927,6 @@ fn ratchet(results: &[Value], full: bool) {
|
||||
eprintln!("baseline written: {}", baseline_path().display());
|
||||
return;
|
||||
}
|
||||
if !slow.is_empty() {
|
||||
eprintln!(
|
||||
"{} actions that pass in the baseline ran past the {} s limit this time (not judged):\n{}",
|
||||
slow.len(),
|
||||
ACTION_TIMEOUT.as_secs(),
|
||||
slow.join("\n")
|
||||
);
|
||||
}
|
||||
if !fixed.is_empty() || fresh > 0 {
|
||||
eprintln!(
|
||||
"{} actions pass now that failed in the baseline, and {fresh} are not in it: record them with `cargo xtask action-matrix --bless`:\n{}",
|
||||
@@ -1101,156 +945,51 @@ fn ratchet(results: &[Value], full: bool) {
|
||||
/// Pull requests: a fixed sample of the matrix (one action in [`PR_SAMPLE`]) against the ratchet.
|
||||
#[test]
|
||||
fn action_matrix_sample() {
|
||||
let results = run_matrix(PR_SAMPLE);
|
||||
let results = run_matrix(PR_SAMPLE, 0, 1);
|
||||
assert!(results.len() > 300, "only {} actions ran", results.len());
|
||||
ratchet(&results, false);
|
||||
}
|
||||
|
||||
/// One shard of the whole matrix against the ratchet: `ACTION_MATRIX_SHARD=k/n` (the nightly
|
||||
/// "Action matrix" CI jobs run the n shards side by side, `--run-ignored only`). Shards are cut by
|
||||
/// what each design costs (tests/action_matrix/costs.json), see [`plan`].
|
||||
/// `ACTION_MATRIX_SHARD_LIST=1` only prints the plan.
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI jobs run it, one shard each"]
|
||||
fn action_matrix_shard() {
|
||||
let Some((k, n)) = std::env::var("ACTION_MATRIX_SHARD").ok().and_then(|v| parse_part(&v)) else { panic!("set ACTION_MATRIX_SHARD=k/n") };
|
||||
let shards = plan(&design_names(), &read_costs(), n);
|
||||
for (i, sh) in shards.iter().enumerate() {
|
||||
let total: f64 = sh.iter().map(|u| u.cost).sum();
|
||||
eprintln!("shard {}/{n}: {} pieces, about {:.0} s of work{}", i + 1, sh.len(), total, if i == k { " (this one)" } else { "" });
|
||||
}
|
||||
eprintln!("this shard: {}", shards[k].iter().map(Unit::name).collect::<Vec<_>>().join(", "));
|
||||
if std::env::var_os("ACTION_MATRIX_SHARD_LIST").is_some() {
|
||||
return;
|
||||
}
|
||||
let results = run_units(1, &shards[k]);
|
||||
/// The whole matrix against the ratchet, in four parts that the nightly CI job runs side by side
|
||||
/// (`--run-ignored only`).
|
||||
fn full_part(part: usize) {
|
||||
let results = run_matrix(1, part, 4);
|
||||
ratchet(&results, false);
|
||||
}
|
||||
|
||||
/// Determinism: a sample of the matrix (one family in [`DETERMINISM_SAMPLE`], the heaviest
|
||||
/// designs left out) run twice must give the same outcome for every action. Lists each action
|
||||
/// whose outcome changed between the runs.
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix determinism CI job runs it"]
|
||||
fn action_matrix_determinism() {
|
||||
let outcomes = |v: Vec<Value>| -> BTreeMap<String, String> {
|
||||
v.into_iter()
|
||||
.map(|o| {
|
||||
let k = key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
|
||||
let r = if o["ok"].as_bool() == Some(true) {
|
||||
"pass".to_string()
|
||||
} else {
|
||||
format!("{}: {}", o["kind"].as_str().unwrap_or(""), o["message"].as_str().unwrap_or(""))
|
||||
};
|
||||
(k, r)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let costs = read_costs();
|
||||
let queue: Vec<Unit> = design_names()
|
||||
.into_iter()
|
||||
.filter(|n| costs.get(n).copied().unwrap_or(UNKNOWN_COST).secs <= DETERMINISM_MAX_SECS)
|
||||
.map(|design| Unit { design, slice: None, cost: 0.0 })
|
||||
.collect();
|
||||
let a = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
|
||||
let b = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
|
||||
let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
|
||||
let mut changed = Vec::new();
|
||||
// Pieces that share a name can swap numbers between runs (#569): compare passes per family
|
||||
// too, and report a family whose pass count differs.
|
||||
let mut fam: BTreeMap<String, (usize, usize)> = BTreeMap::new();
|
||||
for k in &keys {
|
||||
let (x, y) = (a.get(*k).map_or("missing", String::as_str), b.get(*k).map_or("missing", String::as_str));
|
||||
let mut parts = k.splitn(3, " :: ");
|
||||
let (d, e, act) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
|
||||
let f = fam.entry(key(d, &family(e), act)).or_default();
|
||||
f.0 += usize::from(x == "pass");
|
||||
f.1 += usize::from(y == "pass");
|
||||
if x != y {
|
||||
changed.push(format!("{k}\n first: {x}\n second: {y}"));
|
||||
}
|
||||
}
|
||||
let families: Vec<String> = fam.into_iter().filter(|(_, (x, y))| x != y).map(|(k, (x, y))| format!("{k}: {x} then {y} pass")).collect();
|
||||
eprintln!(
|
||||
"action matrix determinism: {} actions, {} changed between two runs, {} families pass a different number of times",
|
||||
keys.len(),
|
||||
changed.len(),
|
||||
families.len()
|
||||
);
|
||||
for c in &changed {
|
||||
eprintln!(" {c}");
|
||||
}
|
||||
assert!(
|
||||
families.is_empty(),
|
||||
"{} families of actions pass a different number of times in two runs of the same build:\n{}",
|
||||
families.len(),
|
||||
families.join("\n")
|
||||
);
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_1() {
|
||||
full_part(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_2() {
|
||||
full_part(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_3() {
|
||||
full_part(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_4() {
|
||||
full_part(3);
|
||||
}
|
||||
|
||||
/// The whole matrix at once (`cargo xtask action-matrix`): `ACTION_MATRIX_BLESS=1` rewrites the
|
||||
/// baseline; `ACTION_MATRIX_RESULTS=file.json` keeps every outcome with its message.
|
||||
/// `ACTION_MATRIX_MERGE=DIR` runs nothing and takes the outcomes from the `results-*.json` files
|
||||
/// in DIR instead (the nightly shards' artifacts), and the timings from its `timings-*.jsonl`
|
||||
/// files, which `ACTION_MATRIX_BLESS` then also writes to tests/action_matrix/costs.json.
|
||||
#[test]
|
||||
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the action_matrix_shard jobs)"]
|
||||
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the four action_matrix_full parts)"]
|
||||
fn action_matrix_all() {
|
||||
let Some(dir) = std::env::var_os("ACTION_MATRIX_MERGE").map(PathBuf::from) else {
|
||||
let results = run_matrix(1);
|
||||
ratchet(&results, true);
|
||||
return;
|
||||
};
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let mut timings: Vec<Value> = Vec::new();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
let name = f.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let text = std::fs::read_to_string(&f).unwrap_or_default();
|
||||
if name.starts_with("results-") && name.ends_with(".json") {
|
||||
results.extend(serde_json::from_str::<Vec<Value>>(&text).unwrap_or_else(|e| panic!("{name}: {e}")));
|
||||
} else if name.starts_with("timings-") {
|
||||
timings.extend(text.lines().filter_map(|l| serde_json::from_str(l).ok()));
|
||||
}
|
||||
}
|
||||
let ran: BTreeSet<&str> = results.iter().filter_map(|o| o["design"].as_str()).collect();
|
||||
let missing: Vec<String> = design_names().into_iter().filter(|d| !ran.contains(d.as_str())).collect();
|
||||
assert!(missing.is_empty(), "no results for {}", missing.join(", "));
|
||||
results.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
|
||||
if std::env::var_os("ACTION_MATRIX_BLESS").is_some() && !timings.is_empty() {
|
||||
write_costs(&timings);
|
||||
}
|
||||
let results = run_matrix(1, 0, 1);
|
||||
ratchet(&results, true);
|
||||
}
|
||||
|
||||
/// tests/action_matrix/costs.json from the shards' timings: per design, its whole time (a sliced
|
||||
/// design's slices added up, less the loads all but one of them repeat) and its load.
|
||||
fn write_costs(timings: &[Value]) {
|
||||
let mut m: BTreeMap<String, (f64, f64, usize)> = BTreeMap::new();
|
||||
for t in timings {
|
||||
let e = m.entry(t["design"].as_str().unwrap_or("").to_string()).or_default();
|
||||
e.0 += t["secs"].as_f64().unwrap_or(0.0);
|
||||
e.1 = e.1.max(t["load"].as_f64().unwrap_or(0.0));
|
||||
e.2 += 1;
|
||||
}
|
||||
// Designs these timings don't cover keep their old costs.
|
||||
for (d, c) in read_costs() {
|
||||
m.entry(d).or_insert((c.secs, c.load, 1));
|
||||
}
|
||||
let mut s = String::from(
|
||||
"{\n \"about\": \"What each action matrix design costs on a CI runner, in seconds (secs: the whole design, load: before its first action), for cutting the nightly shards (apps/solidcraft-cli/tests/action_matrix.rs, plan). Written with the baseline by cargo xtask action-matrix --bless --from DIR.\",\n \"designs\": {\n",
|
||||
);
|
||||
let n = m.len();
|
||||
for (i, (d, (secs, load, pieces))) in m.into_iter().enumerate() {
|
||||
let secs = (secs - load * pieces.saturating_sub(1) as f64).max(load);
|
||||
s += &format!(" {}: {{\"secs\": {:.1}, \"load\": {:.1}}}{}\n", json!(d), secs, load, if i + 1 < n { "," } else { "" });
|
||||
}
|
||||
s += " }\n}\n";
|
||||
std::fs::write(costs_path(), s).unwrap();
|
||||
eprintln!("costs written: {}", costs_path().display());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The matrix's causes: one small reproduction per open issue, ignored until it is fixed (the
|
||||
// issue says how many matrix actions it accounts for). Each runs commands as the desktop sends
|
||||
@@ -1292,106 +1031,6 @@ fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: a round on a knuckle's cap circle (oracle/63's hinge), 0.73 wide on a ring of radius
|
||||
// 4. The run past each end of the circle followed the end tangent so far that its sections
|
||||
// wrapped round the ring and the blend was refused; it is shortened now while it still
|
||||
// crosses the end face. Before #1101 a 0.13 mm station on the leaf's top edge kept the
|
||||
// matrix's radius at 0.013, which never got that far.
|
||||
#[test]
|
||||
fn a_round_on_a_knuckle_cap_circle() {
|
||||
for (cmd, size, removed) in [("solid.fillet", "radius", (2.4, 2.8)), ("solid.chamfer", "distance", (5.6, 6.3))] {
|
||||
let mut s = load(&root().join("tests/oracle/63-hinge-knuckle/recipe.json")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate(0.001).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute(cmd, &json!({"edges": [[40.0, 29.837_722, 6.449_490]], size: 0.728})).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{cmd}: {kind}: {e}"));
|
||||
let st = s.model.state();
|
||||
let body = &st.bodies.first().unwrap().body;
|
||||
assert!(body.validity().is_empty(), "{cmd}: {:?}", body.validity());
|
||||
let gone = before - volume(&s);
|
||||
assert!(gone > removed.0 && gone < removed.1, "{cmd} took {gone} mm³");
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: a hole 0.133 mm across on the 0.5 mm ledge a loft cut leaves in a pocket floor of a
|
||||
// 900 mm slab. The Boolean needed a retry, and its guard meshed the drill as coarsely as the
|
||||
// slab's size allows (0.4 mm, three times the drill's width): the mesh had no inside, the drill
|
||||
// measured −30272 mm³ and the right cut was refused. Volumes are measured finer than each
|
||||
// body's smallest radius now.
|
||||
#[test]
|
||||
fn a_hair_thin_hole_on_a_ledge_beside_a_loft_cut() {
|
||||
let mut s = load(&root().join("examples/regressions/loft_cut_beside_round_hole.json")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.005, true).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute("solid.hole", &json!({"diameter": 0.133, "position": [357.22, -150.993_333, 730.0]})).unwrap();
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
// The drill goes through the 4 mm ledge and on through the slab below: 690 to 730.
|
||||
let gone = before - volume(&s);
|
||||
let expected = std::f64::consts::PI * 0.0665 * 0.0665 * 40.0;
|
||||
assert!((gone - expected).abs() < 0.2, "the hole took {gone} mm³, not {expected}");
|
||||
}
|
||||
|
||||
// #1255: a 0.8 mm hole through the side wall of the plastic part's rest. Meshed at the
|
||||
// tolerance the 200 mm drill sets, the part with its rounds, bosses and draft measured 6.8 mm³
|
||||
// more after the cut than before; ten times finer it is 1.44 mm³ less, the drill's volume
|
||||
// through the 2.9 mm wall. A volume out of bounds is measured again finer before it refuses.
|
||||
#[test]
|
||||
fn a_small_hole_through_the_wall_of_a_plastic_rest() {
|
||||
let mut s = load(&root().join("apps/solidcraft-cli/tests/corpus/modern-plastic.solidcraft")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute("solid.hole", &json!({"diameter": 0.8, "position": [63.069_841_5, 28.333_333, 4.0]})).unwrap();
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
let gone = before - volume(&s);
|
||||
assert!(gone > 1.2 && gone < 1.7, "the hole took {gone} mm³");
|
||||
}
|
||||
|
||||
// #1255: a hole square to a flange bent at 45°. The drill's cap, evaluated at that angle, lay
|
||||
// 2.9e-11 mm off the plane it was made on against a bound of 2.2e-11, so the sheet didn't take
|
||||
// the hole as a cut-out of its flat pattern and the cut was refused. The sheet keeps the hole
|
||||
// and still unfolds and folds back.
|
||||
#[test]
|
||||
fn a_hole_square_to_a_45_degree_flange() {
|
||||
let mut s = load(&root().join("tests/oracle/70-sm-flange-45/recipe.json")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute("solid.hole", &json!({"diameter": 3.448, "position": [66.666_667, -4.511_845, 4.511_845]})).unwrap();
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
// Through the 2.5 mm flange.
|
||||
let gone = before - volume(&s);
|
||||
let expected = std::f64::consts::PI * 1.724 * 1.724 * 2.5;
|
||||
assert!((gone - expected).abs() < 0.1, "the hole took {gone} mm³, not {expected}");
|
||||
let sheets: Vec<String> = s.model.state().sheets.iter().map(|sh| sh.body.clone()).collect();
|
||||
assert!(!sheets.is_empty());
|
||||
for body in sheets {
|
||||
for id in ["sheet.unfold", "sheet.refold"] {
|
||||
s.execute(id, &json!({"body": body})).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: an axis through a corner round of the sensor enclosure's lid, then a parameter that
|
||||
// widens the lid. The round is found again by name, but its point nearest the old pick is the
|
||||
// end of its arc, on the edge it shares with the flat side, and picking again by that point
|
||||
// took the side: "the face is not a cylinder". The named face is used as found now.
|
||||
#[test]
|
||||
fn an_axis_through_a_corner_round_that_a_parameter_moves() {
|
||||
let mut s = load(&root().join("examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [36.242_617, 29.977_740, 27.0]}})).unwrap();
|
||||
let start = s.doc.features.len();
|
||||
s.execute("parameters.change", &json!({"name": "board_w", "expression": "(60 mm) * 1.1"})).unwrap();
|
||||
check(&s, start, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
let st = s.model.state();
|
||||
let axis = st.construct.iter().find_map(|c| match &c.geom {
|
||||
solidcraft_engine::doc::construct::ConstructGeom::Axis { origin, dir } => Some((*origin, *dir)),
|
||||
_ => None,
|
||||
});
|
||||
let (origin, dir) = axis.expect("the axis");
|
||||
// The round's axis moved with the lid's corner, from x = 36 to 39.
|
||||
let off = origin - Vec3::new(39.0, 26.0, 0.0);
|
||||
assert!((off - dir * off.dot(dir)).len() < 1e-6, "the axis passes {origin:?} along {dir:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_patterned_boss_top() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 20.]).into();
|
||||
@@ -1412,7 +1051,7 @@ fn a_sketch_on_a_patterned_boss_top() {
|
||||
fn construction_geometry_and_holes_on_a_moved_component() {
|
||||
let moved = || {
|
||||
vec![
|
||||
json!({"command": "component.create", "params": {"name": "Arm", "grounded": false}}),
|
||||
json!({"command": "component.create", "params": {"name": "Arm"}}),
|
||||
json!({"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}}),
|
||||
json!({"command": "component.activate", "params": {"component": 0}}),
|
||||
json!({"command": "occurrence.move", "params": {"occurrence": "Arm:1", "translate": [0, 50, 0]}}),
|
||||
@@ -1519,6 +1158,7 @@ fn sketches_on_loft_and_sweep_faces() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #562 is fixed"]
|
||||
fn press_pull_on_the_pi_case_floor() {
|
||||
reproduces(
|
||||
Some("tests/action_matrix/designs/pi-case.solidcraft"),
|
||||
@@ -1559,6 +1199,7 @@ fn a_sketch_on_the_rim_a_shell_leaves() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #565 is fixed"]
|
||||
fn sketches_after_split_body_and_intersect() {
|
||||
reproduces(
|
||||
None,
|
||||
@@ -1665,6 +1306,7 @@ fn a_tangent_plane_on_a_drill_tip() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #568 is fixed"]
|
||||
fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() {
|
||||
let mut c = vec![json!({"command": "parameters.change", "params": {"name": "thick", "expression": "10 mm"}})];
|
||||
c.extend(rectangle(json!("XY"), [0., 0.], [80., 30.]));
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{"commands": [
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
|
||||
{"command": "component.create", "params": {"name": "Arm", "grounded": false}},
|
||||
{"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}},
|
||||
|
||||
@@ -27,7 +27,7 @@ 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",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
|
||||
),
|
||||
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
||||
];
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
//! An explicit help request prints usage to stdout and succeeds; an unknown command still fails.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Every command the CLI takes, with the name its own usage names: `inspect` is `eval`, and
|
||||
/// `licenses` is `licences`.
|
||||
const COMMANDS: &[(&str, &str)] = &[
|
||||
("run", "run"),
|
||||
("eval", "eval"),
|
||||
("inspect", "eval"),
|
||||
("script", "script"),
|
||||
("bench-open", "bench-open"),
|
||||
("bench-agent", "bench-agent"),
|
||||
("snapshot", "snapshot"),
|
||||
("render", "render"),
|
||||
("exec", "exec"),
|
||||
("commands", "commands"),
|
||||
("recipe", "recipe"),
|
||||
("oracle", "oracle"),
|
||||
("fuzz", "fuzz"),
|
||||
("fuzz-one", "fuzz-one"),
|
||||
("mcp", "mcp"),
|
||||
("crashes", "crashes"),
|
||||
("licences", "licences"),
|
||||
("licenses", "licences"),
|
||||
("gpu", "gpu"),
|
||||
("icon", "icon"),
|
||||
("bench-edit", "bench-edit"),
|
||||
("save-stress", "save-stress"),
|
||||
];
|
||||
|
||||
/// The commands the usage lists: the aliases answer to another command's name.
|
||||
const LISTED: &[&str] = &[
|
||||
"run",
|
||||
"eval",
|
||||
"script",
|
||||
"bench-open",
|
||||
"bench-agent",
|
||||
"snapshot",
|
||||
"render",
|
||||
"exec",
|
||||
"commands",
|
||||
"recipe",
|
||||
"oracle",
|
||||
"fuzz",
|
||||
"mcp",
|
||||
"crashes",
|
||||
"licences",
|
||||
"gpu",
|
||||
"icon",
|
||||
];
|
||||
|
||||
/// The command names a text mentions after `solidcraft-cli `, each one only once.
|
||||
fn mentioned(text: &str) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = text.split("solidcraft-cli ").skip(1).filter_map(|rest| rest.split_whitespace().next()).collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Runs the binary and waits at most `limit` for it to finish. `None` means it was still
|
||||
/// running and was killed: the fuzz run `fuzz --help` used to start goes on for minutes.
|
||||
fn run_within(limit: Duration, args: &[&str]) -> Option<(Option<i32>, String, String)> {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match child.try_wait().unwrap() {
|
||||
Some(status) => {
|
||||
let out = child.wait_with_output().unwrap();
|
||||
return Some((status.code(), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned()));
|
||||
}
|
||||
None if started.elapsed() >= limit => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(25)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_prints_its_own_usage() {
|
||||
for (cmd, named) in COMMANDS {
|
||||
for flag in ["--help", "-h"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args([*cmd, flag]).output().unwrap();
|
||||
assert!(output.status.success(), "{cmd} {flag}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.starts_with("usage:"), "{cmd} {flag}: {stdout}");
|
||||
assert_eq!(mentioned(&stdout), vec![*named], "{cmd} {flag} printed another command's usage: {stdout}");
|
||||
assert!(output.stderr.is_empty(), "{cmd} {flag}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_top_level_usage_still_lists_every_command() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--help").output().unwrap();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for cmd in LISTED {
|
||||
assert!(stdout.contains(&format!("solidcraft-cli {cmd}")), "{cmd}: {stdout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_help_does_not_start_a_fuzz_run() {
|
||||
let Some((code, stdout, stderr)) = run_within(Duration::from_secs(20), &["fuzz", "--help"]) else {
|
||||
panic!("`fuzz --help` kept running: it started a fuzz run instead of printing the usage");
|
||||
};
|
||||
assert_eq!(code, Some(0), "{stderr}");
|
||||
assert!(stdout.starts_with("usage:"), "{stdout}");
|
||||
assert_eq!(mentioned(&stdout), vec!["fuzz"], "{stdout}");
|
||||
assert!(stderr.is_empty(), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_refuses_an_argument_it_does_not_take() {
|
||||
for arg in ["--not-a-flag", "stray"] {
|
||||
let Some((code, _stdout, stderr)) = run_within(Duration::from_secs(20), &["fuzz", arg]) else {
|
||||
panic!("`fuzz {arg}` kept running: it started a fuzz run instead of refusing the argument");
|
||||
};
|
||||
assert_ne!(code, Some(0), "`fuzz {arg}` succeeded");
|
||||
assert!(stderr.contains(arg), "`fuzz {arg}`: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_keeps_the_registered_gpu_request_controls() {
|
||||
for args in [vec!["--help"], vec!["render", "--help"]] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(&args).output().unwrap();
|
||||
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stderr.is_empty());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
|
||||
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,153 +1,18 @@
|
||||
//! `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.
|
||||
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `mcp` with no arguments finds the running app through the discovery file and uses that
|
||||
/// launch's token; with no app behind the file it stays a headless session.
|
||||
#[test]
|
||||
fn mcp_without_arguments_finds_the_running_app() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-discover-{}-{port}", std::process::id()));
|
||||
std::fs::create_dir_all(&control_dir).unwrap();
|
||||
let token = "fedcba9876543210".repeat(4);
|
||||
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
|
||||
std::fs::write(control_dir.join("current.port"), format!("{port}\n")).unwrap();
|
||||
let app = std::thread::spawn(move || {
|
||||
// The discovery probe connects and leaves without a request; the tool call follows.
|
||||
let (stream, line) = loop {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
if !line.is_empty() {
|
||||
break (stream, line);
|
||||
}
|
||||
};
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let ok = req["token"] == json!(token);
|
||||
let reply = json!({"id": req["id"], "ok": ok, "result": {"bodies": [], "method": req["method"]}, "error": "unauthorized"});
|
||||
writeln!(&stream, "{reply}").unwrap();
|
||||
});
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--allow-any-path"])
|
||||
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
|
||||
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "measure", "arguments": {}}})).unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).unwrap();
|
||||
let r: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"));
|
||||
assert_ne!(r["result"]["isError"], true, "{r}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("engine.execute"), "{r}");
|
||||
app.join().unwrap();
|
||||
drop(stdin);
|
||||
assert!(child.wait().unwrap().success());
|
||||
|
||||
// The file names a port nothing listens on (the app crashed): a headless session instead.
|
||||
let closed = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dead = closed.local_addr().unwrap().port();
|
||||
drop(closed);
|
||||
std::fs::write(control_dir.join("current.port"), format!("{dead}\n")).unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--allow-any-path"])
|
||||
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
// A port that was free a moment ago: nothing listens on it.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("bridged to the running app"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
std::fs::remove_dir_all(&control_dir).ok();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
|
||||
}
|
||||
|
||||
@@ -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,290 +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, SavedModel};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// `built` saved with `saved`, a model of it that keeps only some states (as a file without room
|
||||
/// for all of them does, #3220), opens as the design evaluated again: evaluating just the
|
||||
/// features whose state the file left out, then after suppressing a feature at, before or after
|
||||
/// each state it kept, after undoing that, and with the marker moved there and back.
|
||||
fn check_thinned(name: &str, built: &Session, saved: &SavedModel) {
|
||||
let design = built.design();
|
||||
let bytes =
|
||||
solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(saved)).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the file keeps the final state");
|
||||
let path = temp(&name.replace(' ', "-"));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut cached = open(&path);
|
||||
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
|
||||
let features = saved.records["features"].as_array().unwrap();
|
||||
let unkept = features.iter().filter(|f| f["skipped"] == false && f.get("state").is_none()).count();
|
||||
assert!(unkept > 0, "{name}: some states left out");
|
||||
assert_eq!(cached.model.recomputed, unkept, "{name}: evaluated just the features whose state the file left out");
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
|
||||
let n = cached.doc.features.len();
|
||||
let mut at: Vec<usize> = features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f["skipped"] == false && f.get("state").is_some())
|
||||
.flat_map(|(k, _)| [k.saturating_sub(1), k, k + 1])
|
||||
.filter(|i| *i < n)
|
||||
.collect();
|
||||
at.dedup();
|
||||
for i in at {
|
||||
let edit = json!({"feature": cached.doc.features[i].id, "suppressed": true});
|
||||
let (a, b) = (cached.execute("timeline.suppress", &edit), rebuilt.execute("timeline.suppress", &edit));
|
||||
assert_eq!(a.is_ok(), b.is_ok(), "{name}: suppressing feature {i}");
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: feature {i} suppressed");
|
||||
if a.is_ok() {
|
||||
cached.execute("edit.undo", &json!({})).unwrap();
|
||||
rebuilt.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: suppressing feature {i} undone");
|
||||
}
|
||||
for marker in [json!({"position": i}), json!({})] {
|
||||
cached.execute("timeline.rollback", &marker).unwrap();
|
||||
rebuilt.execute("timeline.rollback", &marker).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: the marker at {marker}, from feature {i}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A model too big for its file keeps the final state, then the slowest features' states as fit
|
||||
/// (#3220). Both the smallest such model (the final state, and states that add no body to it)
|
||||
/// and one that keeps more open as the design evaluated again. The placed components' design has
|
||||
/// a cut whose fingerprint reads where the bodies before it lie, which a state left out does not
|
||||
/// say until it is evaluated.
|
||||
#[test]
|
||||
fn thinned_models_open_as_evaluated() {
|
||||
let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/components-arm.solidcraft");
|
||||
for sample in ["Sample Plate", "Plastic Enclosure", "components-arm"] {
|
||||
let built = match solidcraft_engine::sample::samples().into_iter().find(|s| s.name == sample) {
|
||||
Some(s) => {
|
||||
let mut built = Session::default();
|
||||
for c in (s.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
built
|
||||
}
|
||||
None => open(&corpus),
|
||||
};
|
||||
let saved = built.model.saved(&built.design()).unwrap();
|
||||
// Every body a megabyte (the records fit in the room left over): each budget keeps one more
|
||||
// body's worth of states.
|
||||
let body = 1 << 20;
|
||||
let pieces: Vec<Vec<(usize, usize)>> = (0..saved.bodies.len()).map(|i| vec![(i, body)]).collect();
|
||||
let thinned: Vec<SavedModel> =
|
||||
(0..=saved.bodies.len()).filter_map(|n| saved.thinned(&pieces, n * body + body - 1).map(|(m, _, _)| m)).collect();
|
||||
let kept = |m: &SavedModel| m.records["features"].as_array().unwrap().iter().filter(|f| f.get("state").is_some()).count();
|
||||
let smallest = thinned.first().unwrap();
|
||||
assert!(smallest.records["states"].as_array().unwrap().len() < saved.records["states"].as_array().unwrap().len(), "{sample}");
|
||||
let more = thinned.iter().find(|m| kept(m) > kept(smallest) && kept(m) < built.doc.features.len()).unwrap();
|
||||
check_thinned(&format!("{sample} smallest"), &built, smallest);
|
||||
check_thinned(&format!("{sample} thinned"), &built, more);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
@@ -132,80 +132,3 @@ fn legacy_extension_and_format_tag_still_open() {
|
||||
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,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");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "MediaQueryList", "Gamepad", "Performance",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
|
||||
] }
|
||||
|
||||
@@ -9,43 +9,29 @@
|
||||
<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:url" content="https://bherbruck.github.io/solidcraft/app/">
|
||||
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SolidCraft: try it in your browser">
|
||||
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://docs.solidcraft.io/og.png">
|
||||
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<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>
|
||||
<div id="solidcraft_loading">Loading SolidCraft…</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", ".fcstd", ".3dm"] {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,22 @@
|
||||
//! 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.
|
||||
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
|
||||
//! in OPFS too 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;
|
||||
pub const OPFS: &str = "/opfs/";
|
||||
/// Paths written here are handed to the user as downloads.
|
||||
pub const DOWNLOADS: &str = disk::DOWNLOADS_PREFIX;
|
||||
pub const DOWNLOADS: &str = "/downloads/";
|
||||
/// Files brought in from the computer.
|
||||
pub const UPLOAD: &str = "/upload/";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
|
||||
@@ -55,14 +51,6 @@ 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).
|
||||
@@ -76,17 +64,11 @@ pub struct Files {
|
||||
}
|
||||
|
||||
impl Files {
|
||||
pub fn say(&self, text: impl Into<String>, error: bool) {
|
||||
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));
|
||||
@@ -94,7 +76,7 @@ impl Files {
|
||||
}
|
||||
|
||||
/// Run a frame (the app sleeps until something happens).
|
||||
pub fn wake(&self) {
|
||||
fn wake(&self) {
|
||||
if let Some(c) = self.ctx.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
@@ -145,7 +127,7 @@ pub fn local_only() -> bool {
|
||||
LOCAL.with(Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn err(e: JsValue) -> String {
|
||||
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:?}"))
|
||||
}
|
||||
|
||||
@@ -168,7 +150,7 @@ async fn root() -> Result<FileSystemDirectoryHandle, String> {
|
||||
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> {
|
||||
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
|
||||
}
|
||||
@@ -271,10 +253,6 @@ pub fn start(files: &Rc<Files>) {
|
||||
}
|
||||
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() {
|
||||
@@ -317,32 +295,26 @@ pub fn start(files: &Rc<Files>) {
|
||||
/// 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());
|
||||
}
|
||||
if let Some(name) = path.strip_prefix(OPFS) {
|
||||
let name = name.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) {
|
||||
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::Memory => {}
|
||||
});
|
||||
} else if let Some(name) = path.strip_prefix(DOWNLOADS)
|
||||
&& let Err(e) = download(name, &bytes)
|
||||
{
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -455,15 +427,8 @@ pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
|
||||
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 opts = Object::new();
|
||||
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
|
||||
let promise = f.call1(&window, &opts);
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
@@ -473,13 +438,7 @@ pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
|
||||
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);
|
||||
}
|
||||
files.arrive(h.name(), bytes, intent);
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
};
|
||||
@@ -499,10 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
// Everything the browser build imports, from the format registry: cadmpeg is pure Rust, so
|
||||
// FreeCAD and Rhino files are part of it.
|
||||
let accept = solidcraft_engine::io::formats::open_extensions(true).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
input.set_accept(&accept);
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
if let Some(body) = doc.body() {
|
||||
|
||||
@@ -1,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) }
|
||||
}
|
||||
@@ -2,28 +2,12 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?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;
|
||||
|
||||
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
|
||||
/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and
|
||||
/// Ctrl+PageDown already reach the browser.)
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn browser_switches_tabs(key: &str, ctrl: bool) -> bool {
|
||||
ctrl && key == "Tab"
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
@@ -31,44 +15,14 @@ mod web {
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
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);
|
||||
}
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
@@ -92,27 +46,12 @@ mod web {
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
|
||||
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
|
||||
/// the canvas, before eframe sees them; the browser then acts on them as usual.
|
||||
fn leave_tab_switching_to_browser(window: &web_sys::Window) {
|
||||
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
|
||||
if crate::browser_switches_tabs(&e.key(), e.ctrl_key()) {
|
||||
e.stop_propagation();
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true);
|
||||
// The listener lives as long as the page.
|
||||
on_key.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,
|
||||
@@ -126,12 +65,6 @@ mod web {
|
||||
/// 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,
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -140,43 +73,11 @@ mod web {
|
||||
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
|
||||
});
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
@@ -231,9 +132,7 @@ mod web {
|
||||
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)));
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
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) {
|
||||
@@ -241,10 +140,6 @@ mod web {
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -252,27 +147,27 @@ mod web {
|
||||
if !self.files.panel.get() {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
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| {
|
||||
egui::Window::new("Files in this browser")
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_width(520.0)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, "Open from computer…", ui_kit::Button::Normal).clicked() {
|
||||
if ui.button("Open from computer…").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()
|
||||
{
|
||||
if ui.button("Insert from computer…").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() {
|
||||
if ui.button("Download open design").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| {
|
||||
@@ -290,7 +185,7 @@ mod web {
|
||||
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);
|
||||
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
|
||||
if std::mem::take(&mut self.rename_focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -308,31 +203,21 @@ mod web {
|
||||
}
|
||||
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() {
|
||||
if ui.small_button("Open").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()
|
||||
{
|
||||
if !design && ui.small_button("Insert").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() {
|
||||
if ui.small_button("Rename").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() {
|
||||
if ui.small_button("Download").clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
if ui_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
|
||||
if ui.small_button("Delete").clicked() {
|
||||
act = Some(("delete", e.name.clone()));
|
||||
}
|
||||
});
|
||||
@@ -341,7 +226,7 @@ mod web {
|
||||
});
|
||||
});
|
||||
});
|
||||
if closed {
|
||||
if !open {
|
||||
self.files.panel.set(false);
|
||||
self.renaming = None;
|
||||
}
|
||||
@@ -364,10 +249,6 @@ mod web {
|
||||
}
|
||||
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
|
||||
@@ -409,19 +290,20 @@ mod web {
|
||||
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| {
|
||||
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
|
||||
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() {
|
||||
if ui.button("Recover").clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, "Discard", ui_kit::Button::Normal).clicked() {
|
||||
if ui.button("Discard").clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
let Some(recover) = answer else { return };
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
if recover {
|
||||
@@ -441,10 +323,7 @@ mod web {
|
||||
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
|
||||
}
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
@@ -456,29 +335,11 @@ mod web {
|
||||
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):
|
||||
@@ -504,19 +365,11 @@ mod web {
|
||||
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) {
|
||||
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
|
||||
if let Some(delay) = self.wheel_gestures.update(raw, now) {
|
||||
ctx.request_repaint_after(delay);
|
||||
}
|
||||
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) {
|
||||
@@ -539,29 +392,6 @@ mod web {
|
||||
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")?;
|
||||
@@ -580,14 +410,13 @@ mod web {
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
leave_tab_switching_to_browser(&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()
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -600,47 +429,21 @@ mod web {
|
||||
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 panel = 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));
|
||||
}
|
||||
// Open shows the browser's files (and the computer's, from there).
|
||||
pick_open: Some(Box::new(move || {
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
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]| {
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|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)))),
|
||||
}
|
||||
Some(format!("{}{name}", if design { files::OPFS } else { 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);
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
@@ -656,17 +459,11 @@ mod web {
|
||||
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,
|
||||
@@ -675,17 +472,13 @@ mod web {
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
cjk_bytes: None,
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => dismiss_loading(el),
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
@@ -704,17 +497,3 @@ fn main() {
|
||||
fn main() {
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_ctrl_tab_is_left_to_the_browser() {
|
||||
// Ctrl+Tab and Ctrl+Shift+Tab (the event's key is "Tab" either way) switch browser tabs.
|
||||
assert!(browser_switches_tabs("Tab", true));
|
||||
// Tab and Shift+Tab move between the app's fields; Ctrl with other keys stays the app's.
|
||||
assert!(!browser_switches_tabs("Tab", false));
|
||||
assert!(!browser_switches_tabs("s", true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, kind: Default::default(), 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, kind: Default::default(), 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);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,8 @@
|
||||
// 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.
|
||||
// the kept build answering when that fails. Each page then gets its files from the build it
|
||||
// started (remembered in PAGES), never from another build with a file of the same name.
|
||||
//
|
||||
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
|
||||
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
|
||||
@@ -160,14 +158,9 @@ function within(ms, promise) {
|
||||
/** 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). */
|
||||
/** The deployed build's version and files, GONE, 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;
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache" });
|
||||
if (response.status === 404 || response.status === 410) return GONE;
|
||||
const build = response.ok ? await response.json() : null;
|
||||
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
|
||||
@@ -189,7 +182,6 @@ async function launch(event) {
|
||||
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];
|
||||
|
||||
@@ -7,80 +7,24 @@ 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"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[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]
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# 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.
|
||||
# Finding 3D mice through Plug and Play without asking other devices anything (src/space_mouse/windows_hid.rs).
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Devices_DeviceAndDriverInstallation",
|
||||
"Win32_Devices_HumanInterfaceDevice",
|
||||
"Win32_Devices_Properties",
|
||||
"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]
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
# 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", "NSProcessInfo", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
|
||||
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 std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once, so a flood must not be able to spawn threads without end.
|
||||
@@ -20,9 +18,8 @@ const MAX_CONNECTIONS: usize = 16;
|
||||
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());
|
||||
/// The token file this launch wrote, removed when the app ends.
|
||||
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// A new random token: 32 bytes from the operating system, as hex.
|
||||
fn new_token() -> Option<String> {
|
||||
@@ -33,83 +30,30 @@ fn new_token() -> Option<String> {
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) {
|
||||
control_auth::remove_current_port(port);
|
||||
if let Some(p) = TOKEN_FILE.get() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// The port the app's own channel listens on, for removing the discovery file at exit.
|
||||
static APP_PORT: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
|
||||
|
||||
pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
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) => {
|
||||
let listener = match listen_on {
|
||||
control_auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
|
||||
control_auth::Listen::Default => control_auth::bind_with_fallback(control_auth::DEFAULT_PORT, control_auth::PORT_TRIES),
|
||||
};
|
||||
match listener {
|
||||
Ok(l) => {
|
||||
if let Some(port) = listen_on_listener(l, token, tx, ctx, false) {
|
||||
if matches!(listen_on, control_auth::Listen::Default) {
|
||||
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
|
||||
}
|
||||
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
|
||||
match control_auth::write_current_port(port) {
|
||||
Ok(_) => {
|
||||
if let Ok(mut p) = APP_PORT.lock() {
|
||||
*p = Some(port);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
|
||||
}
|
||||
}
|
||||
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;
|
||||
let Some(token) = control_auth::env_token().or_else(new_token) else {
|
||||
eprintln!("solidcraft: control server not started: no random token available");
|
||||
return rx;
|
||||
};
|
||||
let listener = match TcpListener::bind(("127.0.0.1", 0)) {
|
||||
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: {e}");
|
||||
return None;
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
listen_on_listener(listener, token, tx, ctx, true)
|
||||
}
|
||||
|
||||
/// Serve `listener` (loopback) with `token`; the port it listens on.
|
||||
/// `patient`: every request may wait as long as a batch (a person decides on it).
|
||||
fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
|
||||
let port = listener.local_addr().ok()?.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);
|
||||
}
|
||||
let _ = TOKEN_FILE.set(path);
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
|
||||
@@ -134,12 +78,12 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &token, tx, ctx, patient);
|
||||
serve(stream, &token, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
Some(port)
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
@@ -190,192 +134,12 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
|
||||
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,
|
||||
/// The request in messages ("`engine.execute` (solid.fillet)").
|
||||
what: 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
|
||||
}
|
||||
|
||||
/// The reply text for a request that outlasts its wait. A request that only reads changes
|
||||
/// nothing when it finishes; an export or a save still writes its file, and any other request
|
||||
/// still applies, as it was not cancelled.
|
||||
fn timeout_message(what: &str, effect: control_pending::Effect) -> String {
|
||||
match effect {
|
||||
control_pending::Effect::ReadsOnly => {
|
||||
format!("timeout: {what} is still running in the app ({}); ask `control.pending` for its answer", effect.when_done())
|
||||
}
|
||||
control_pending::Effect::WritesFile => format!(
|
||||
"timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \
|
||||
don't send it again; the design does not change); ask `control.pending` for its result"
|
||||
),
|
||||
control_pending::Effect::Changes => format!(
|
||||
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
|
||||
again); ask `control.pending` for its result"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply text for a request refused because an earlier one on the connection still runs.
|
||||
fn busy_message(p: &Pending, method: &str) -> String {
|
||||
let method: String = method.chars().take(128).collect();
|
||||
format!(
|
||||
"busy: request {} ({}) is still running, so `{method}` was not run; ask `control.pending` for that request's result, then \
|
||||
send this one again",
|
||||
p.id, p.what
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
let mut authenticated = false;
|
||||
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(),
|
||||
@@ -403,52 +167,14 @@ fn serve_with(
|
||||
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": busy_message(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 what = control_pending::describe(&method, ¶ms);
|
||||
let effect = control_pending::effect(&method, ¶ms);
|
||||
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": timeout_message(&what, effect)});
|
||||
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) });
|
||||
r
|
||||
}
|
||||
}
|
||||
};
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(wait).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
@@ -476,188 +202,6 @@ fn serve_with(
|
||||
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}");
|
||||
}
|
||||
|
||||
/// A timed-out request that only reads is not said to "still apply", nor is one that only
|
||||
/// writes a file (an export, #1989); a change is, by name.
|
||||
#[test]
|
||||
fn timeout_text_tells_reads_from_changes() {
|
||||
let says = |method: &str, p: Value| timeout_message(&control_pending::describe(method, &p), control_pending::effect(method, &p));
|
||||
let read = says("ui.inspect", json!({}));
|
||||
assert!(read.starts_with("timeout") && read.contains("only reads") && !read.contains("will still apply"), "{read}");
|
||||
let read = says("engine.execute", json!({"command": "sketch.inspect"}));
|
||||
assert!(read.contains("only reads") && read.contains("sketch.inspect"), "{read}");
|
||||
let change = says("engine.execute", json!({"command": "solid.fillet"}));
|
||||
assert!(change.starts_with("timeout") && change.contains("will still apply") && change.contains("solid.fillet"), "{change}");
|
||||
let export = says("engine.execute", json!({"command": "file.export", "params": {"path": "/tmp/wall.step"}}));
|
||||
assert!(export.contains("will still write the file") && !export.contains("apply") && export.contains("file.export"), "{export}");
|
||||
let save = says("engine.execute", json!({"command": "file.save"}));
|
||||
assert!(save.contains("will still write the file") && !save.contains("apply"), "{save}");
|
||||
let render = says("ui.render", json!({"path": "/tmp/a.png"}));
|
||||
assert!(render.contains("will still write the file"), "{render}");
|
||||
}
|
||||
|
||||
/// #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.
|
||||
@@ -679,7 +223,7 @@ mod tests {
|
||||
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));
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
|
||||
}
|
||||
});
|
||||
let session = |lines: &[String]| {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! The graphics device was lost after the window opened: the driver was reset (on Windows a TDR,
|
||||
//! "Display driver stopped responding and has recovered") or the GPU went away (#4172). A lost
|
||||
//! device can't draw again, so the window can't show anything more, not even a message. The
|
||||
//! painter stops drawing (`egui_wgpu::DeviceLost`); the app then closes, keeping unsaved changes
|
||||
//! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what
|
||||
//! happened ([`note`]). A device lost again soon after such a restart starts the next one on the
|
||||
//! software renderer, which doesn't depend on the graphics driver.
|
||||
|
||||
use crate::startup;
|
||||
|
||||
/// Set on the start after a lost device: when the device was lost (seconds since 1970).
|
||||
pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST";
|
||||
|
||||
/// A loss this recent (seconds) counts for the start after it: its note, and a second loss
|
||||
/// within it starts on the software renderer. Inherited by later starts, an older stamp is
|
||||
/// ignored.
|
||||
const RECENT_SECS: u64 = 600;
|
||||
|
||||
/// Seconds since 1970.
|
||||
pub fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Whether `stamp` ([`LOST_ENV`]) names a loss at most [`RECENT_SECS`] before `now`.
|
||||
pub fn recent(stamp: Option<&str>, now: u64) -> bool {
|
||||
stamp.and_then(|s| s.trim().parse::<u64>().ok()).is_some_and(|at| at <= now && now - at <= RECENT_SECS)
|
||||
}
|
||||
|
||||
/// Whether the start after a lost device uses the software renderer: when this launch already
|
||||
/// followed a recent loss (the driver keeps losing it), or ran on it anyway.
|
||||
pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool {
|
||||
after_recent_loss || cpu_renderer
|
||||
}
|
||||
|
||||
/// What the start after a lost device tells the user, `software` when it runs on the software
|
||||
/// renderer.
|
||||
pub fn note(software: bool) -> String {
|
||||
let mut note = String::from(
|
||||
"The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.",
|
||||
);
|
||||
if software {
|
||||
note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver.");
|
||||
}
|
||||
note
|
||||
}
|
||||
|
||||
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software
|
||||
/// renderer when `software`.
|
||||
pub fn restart(software: bool, lost_at: u64) {
|
||||
let Ok(exe) = std::env::current_exe() else { return };
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
|
||||
if software {
|
||||
command.env(startup::RENDERER_ENV, "cpu");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Not tied to this process: its own process group, no console.
|
||||
command.creation_flags(0x0000_0200 | 0x0000_0008);
|
||||
}
|
||||
if let Err(e) = command.spawn() {
|
||||
log::error!("graphics: starting again after the device was lost: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
|
||||
#[test]
|
||||
fn only_a_recent_loss_counts() {
|
||||
let now = 1_000_000;
|
||||
assert!(super::recent(Some("999990"), now));
|
||||
assert!(super::recent(Some(" 999400 "), now));
|
||||
assert!(!super::recent(Some("999399"), now), "older than ten minutes");
|
||||
assert!(!super::recent(Some("1000001"), now), "from the future");
|
||||
assert!(!super::recent(Some("soon"), now));
|
||||
assert!(!super::recent(None, now));
|
||||
}
|
||||
|
||||
/// The first loss starts again on the GPU; a second one soon after, on the software renderer.
|
||||
#[test]
|
||||
fn a_second_loss_in_a_row_starts_on_the_software_renderer() {
|
||||
assert!(!super::restart_on_software(false, false));
|
||||
assert!(super::restart_on_software(true, false));
|
||||
assert!(super::restart_on_software(false, true));
|
||||
assert!(super::note(true).contains("software rendering"));
|
||||
assert!(!super::note(false).contains("software rendering"));
|
||||
assert!(super::note(false).contains("kept for recovery"));
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,35 @@
|
||||
//! 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.
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
||||
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
|
||||
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
|
||||
//!
|
||||
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
||||
//! Without them the channel still opens, on the default port, unless Preferences turns it off.
|
||||
//! 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)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
mod device_lost;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
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,
|
||||
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
|
||||
gpu: Option<LostWatch>,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.watch_device();
|
||||
// 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
|
||||
@@ -124,34 +38,14 @@ impl eframe::App for App {
|
||||
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);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.native_menu {
|
||||
menu.tick(&mut self.solve, ctx);
|
||||
}
|
||||
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);
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.native_menu {
|
||||
menu.raw_input_hook(&mut self.solve, ctx, raw);
|
||||
}
|
||||
// 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);
|
||||
// The 3D mouse's buttons, before the app reads the frame: Esc and held modifiers join its input.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
solidcraft_ui_egui::space_mouse::buttons_hook(&mut self.solve, ctx, self.space_mouse.buttons(), raw);
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -161,121 +55,46 @@ impl eframe::App for App {
|
||||
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();
|
||||
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
|
||||
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
|
||||
}
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's graphics device, watched for its loss.
|
||||
struct LostWatch {
|
||||
lost: eframe::egui_wgpu::DeviceLost,
|
||||
/// Where the next start runs once this device is lost ([`device_lost::restart_on_software`]).
|
||||
restart_on_software: bool,
|
||||
/// When the loss was seen: the app is closing to start again.
|
||||
lost_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The graphics device was lost (#4172): the window can't draw again, so close, keeping
|
||||
/// unsaved changes for recovery as a forced quit does (the window can't ask about them), and
|
||||
/// start again once closed ([`device_lost::restart`]).
|
||||
fn watch_device(&mut self) {
|
||||
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
|
||||
let reason = gpu.lost.reason().unwrap_or_default();
|
||||
log::error!("graphics: the device was lost ({reason}); closing to start again");
|
||||
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
|
||||
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
|
||||
gpu.lost_at = Some(device_lost::now_secs());
|
||||
self.solve.quit_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux file dialogs match patterns by case, so each extension goes in as lower and upper
|
||||
/// case, plus FreeCAD's own mixed casing.
|
||||
fn case_variants(exts: &[&str]) -> Vec<String> {
|
||||
let mut all: Vec<String> = Vec::new();
|
||||
for e in exts {
|
||||
let mut forms = vec![e.to_ascii_lowercase(), e.to_ascii_uppercase()];
|
||||
if e.eq_ignore_ascii_case("fcstd") {
|
||||
forms.push("FCStd".into());
|
||||
}
|
||||
for v in forms {
|
||||
if !all.contains(&v) {
|
||||
all.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
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();
|
||||
fn services() -> Services {
|
||||
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(", ");
|
||||
dialog = dialog.add_filter(label, &case_variants(first));
|
||||
}
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
let future = dialog.add_filter("All files", &["*"]).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Designs, STEP, IGES and meshes",
|
||||
&[
|
||||
io::DESIGN_EXT,
|
||||
io::LEGACY_DESIGN_EXT,
|
||||
"json",
|
||||
"step",
|
||||
"stp",
|
||||
"STEP",
|
||||
"STP",
|
||||
"igs",
|
||||
"iges",
|
||||
"IGS",
|
||||
"IGES",
|
||||
"3mf",
|
||||
"3MF",
|
||||
"stl",
|
||||
"STL",
|
||||
],
|
||||
)
|
||||
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// 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);
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let dialog = rfd::FileDialog::new().set_file_name(name);
|
||||
let dialog = if exts == [io::DESIGN_EXT, "json"] {
|
||||
if name.ends_with(".json") {
|
||||
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
|
||||
@@ -285,21 +104,9 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
} 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()) })
|
||||
dialog.save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
window_system: window_system_now(),
|
||||
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") {
|
||||
@@ -320,144 +127,6 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
@@ -505,31 +174,11 @@ fn adopt_settings(
|
||||
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))
|
||||
}
|
||||
|
||||
/// The viewport scene's sample count: the app's anti-aliasing (the window's pass is 1x), clamped
|
||||
/// by the backend's policy (OpenGL renders 1x, see `effective_msaa_samples`). A count the device
|
||||
/// cannot render with falls back to the supported default.
|
||||
fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]) -> u32 {
|
||||
let requested = eframe::egui_wgpu::effective_msaa_samples(backend, samples.max(1));
|
||||
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
|
||||
requested
|
||||
} else {
|
||||
supported.iter().copied().filter(|&n| n <= solidcraft_ui_egui::prefs::Prefs::default().msaa).max().unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics()
|
||||
graphics_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
||||
.and_then(|m| u16::try_from(m).ok())
|
||||
.filter(|m| [1, 2, 4, 8].contains(m))
|
||||
@@ -541,10 +190,6 @@ fn msaa() -> u16 {
|
||||
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;
|
||||
@@ -573,11 +218,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
|
||||
mark("main");
|
||||
adopt_legacy_settings();
|
||||
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
||||
let samples = msaa();
|
||||
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
@@ -586,7 +228,6 @@ fn main() -> eframe::Result {
|
||||
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(());
|
||||
@@ -605,37 +246,12 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [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());
|
||||
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
|
||||
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
|
||||
if after_loss {
|
||||
notes.push(device_lost::note(cpu_renderer));
|
||||
}
|
||||
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| {
|
||||
@@ -647,127 +263,55 @@ fn main() -> eframe::Result {
|
||||
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()?,
|
||||
// The window's pass (egui's) has no depth and one sample: the viewport draws its scene
|
||||
// offscreen with its own depth and MSAA, then blits it, so the UI pass pays for neither.
|
||||
depth_buffer: 0,
|
||||
stencil_buffer: 0,
|
||||
multisampling: 1,
|
||||
// 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,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// Kept with the other settings, not in a folder named after the app id.
|
||||
persistence_path: settings_dir().map(|d| d.join("app.ron")),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let x11 = {
|
||||
// SOLIDCRAFT_WAYLAND wins over Preferences › Graphics › Window system (#3083).
|
||||
let environment = solidcraft_engine::geom::env::var("WAYLAND");
|
||||
let preference = window_system_preference();
|
||||
let wayland_override = environment.as_deref().or(window_system_override(preference.as_deref()));
|
||||
let wayland_display = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
let x11 = prefer_x11(
|
||||
std::env::var_os("DISPLAY").is_some(),
|
||||
wayland_display,
|
||||
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
||||
wayland_override,
|
||||
);
|
||||
// Without a Wayland socket winit opens on X11 anyway.
|
||||
let _ = WINDOW_SYSTEM.set(if x11 || !wayland_display { "x11" } else { "wayland" });
|
||||
x11
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if x11 {
|
||||
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);
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
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) {
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
mark("app state and prefs");
|
||||
let gpu = cc.wgpu_render_state.as_ref().map(|rs| LostWatch {
|
||||
lost: rs.device_lost.clone(),
|
||||
restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer),
|
||||
lost_at: None,
|
||||
});
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_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();
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
solidcraft_engine::crash::set_environment(&format!(
|
||||
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
|
||||
info.backend, info.name, info.driver
|
||||
"Renderer: {:?} {} ({}), MSAA {}x",
|
||||
info.backend, info.name, info.driver, rs.msaa_samples
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
// `--control`/the environment name a port; otherwise the preference (on by default)
|
||||
// opens the default one (docs/control-protocol.md).
|
||||
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
|
||||
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
|
||||
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);
|
||||
@@ -776,20 +320,9 @@ fn main() -> eframe::Result {
|
||||
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() {
|
||||
@@ -800,137 +333,20 @@ fn main() -> eframe::Result {
|
||||
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"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
|
||||
Ok(menu) => Some(menu),
|
||||
Err(error) => {
|
||||
app.set_status(format!("System menu: {error}"), true);
|
||||
None
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.native_file_menu = native_menu.is_some();
|
||||
}
|
||||
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(),
|
||||
gpu,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Linux: the window system this run opened with (`wayland` or `x11`), for Preferences (#3083).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
static WINDOW_SYSTEM: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
|
||||
|
||||
/// The window system this run uses, where there is a choice (Linux).
|
||||
fn window_system_now() -> Option<&'static str> {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
return WINDOW_SYSTEM.get().copied();
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
None
|
||||
}
|
||||
|
||||
/// Preferences › Graphics › Window system (`graphics.json`'s `window_system`), read before the
|
||||
/// window opens.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn window_system_preference() -> Option<String> {
|
||||
graphics().and_then(|v| v.get("window_system").and_then(serde_json::Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// The window system preference as the `SOLIDCRAFT_WAYLAND` value it stands for: `wayland` is
|
||||
/// `1`, `x11` is `0`, and `auto` (or anything else) leaves the choice to [`prefer_x11`].
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn window_system_override(preference: Option<&str>) -> Option<&'static str> {
|
||||
match preference.map(str::trim) {
|
||||
Some("wayland") => Some("1"),
|
||||
Some("x11") => Some("0"),
|
||||
_ => None,
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
@@ -970,19 +386,6 @@ mod backend_tests {
|
||||
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
||||
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
|
||||
}
|
||||
|
||||
/// Preferences › Graphics › Window system stands for the same values (#3083).
|
||||
#[test]
|
||||
fn the_window_system_preference_maps_to_the_override() {
|
||||
use super::window_system_override as over;
|
||||
assert_eq!(
|
||||
(over(Some("wayland")), over(Some("x11")), over(Some("auto")), over(None), over(Some("junk"))),
|
||||
(Some("1"), Some("0"), None, None, None)
|
||||
);
|
||||
// Wayland chosen on GNOME keeps native Wayland; X11 chosen on niri goes through XWayland.
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), over(Some("wayland"))));
|
||||
assert!(prefer_x11(true, true, Some("niri"), over(Some("x11"))));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1040,13 +443,4 @@ mod msaa_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scene_follows_the_backend_policy_too() {
|
||||
use eframe::wgpu::Backend;
|
||||
assert_eq!(super::scene_samples(Backend::Gl, 4, &[1, 4]), 1);
|
||||
assert_eq!(super::scene_samples(Backend::Gl, 4, &[]), 1);
|
||||
assert_eq!(super::scene_samples(Backend::Vulkan, 4, &[1, 4]), 4);
|
||||
assert_eq!(super::scene_samples(Backend::Dx12, 8, &[1, 2, 4]), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md).
|
||||
//!
|
||||
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
|
||||
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
|
||||
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
|
||||
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
|
||||
//! crash journal (`solidcraft_engine::crash::native_paths`). The next start puts both into the
|
||||
//! unclean exit's report, with the startup step the journal says the app was in.
|
||||
//!
|
||||
//! Installing an exception filter and asking the system which module holds an address have no
|
||||
//! safe API, hence the `unsafe_code` allowance on this module (the app crate denies it; see
|
||||
//! `open_documents.rs` for the other one). The filter runs on a thread that may have a broken
|
||||
//! heap or almost no stack left: it doesn't allocate, formats into a buffer on the stack, and
|
||||
//! skips the minidump after a stack overflow.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::{
|
||||
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
|
||||
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
|
||||
};
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
|
||||
|
||||
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
|
||||
struct Paths {
|
||||
info: Vec<u16>,
|
||||
dump: Vec<u16>,
|
||||
}
|
||||
|
||||
static PATHS: OnceLock<Paths> = OnceLock::new();
|
||||
/// The filter that was installed before ours (called after ours), as an address.
|
||||
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The filter has started writing (a second exception while it does is left alone).
|
||||
static BUSY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
|
||||
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
|
||||
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
|
||||
|
||||
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
|
||||
pub fn install() {
|
||||
let Some((info, dump)) = solidcraft_engine::crash::native_paths() else { return };
|
||||
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
|
||||
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
|
||||
set_filter();
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
|
||||
/// which is then called after ours.
|
||||
pub fn reinstall() {
|
||||
if PATHS.get().is_some() {
|
||||
set_filter();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_filter() {
|
||||
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
|
||||
// process's life.
|
||||
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
|
||||
if let Some(p) = previous
|
||||
&& p as usize != filter as *const () as usize
|
||||
{
|
||||
PREVIOUS.store(p as usize, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
|
||||
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
|
||||
if !BUSY.swap(true, Ordering::SeqCst) {
|
||||
// SAFETY: the system passes valid exception pointers for the duration of the call.
|
||||
unsafe { record(pointers) };
|
||||
}
|
||||
let previous = PREVIOUS.load(Ordering::SeqCst);
|
||||
if previous != 0 && previous != filter as *const () as usize {
|
||||
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
|
||||
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
|
||||
// SAFETY: as for this filter.
|
||||
return unsafe { previous(pointers) };
|
||||
}
|
||||
EXCEPTION_CONTINUE_SEARCH
|
||||
}
|
||||
|
||||
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
|
||||
struct Buf {
|
||||
bytes: [u8; 2048],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Write for Buf {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let room = self.bytes.len().saturating_sub(self.len);
|
||||
let n = s.len().min(room);
|
||||
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
|
||||
to.copy_from_slice(from);
|
||||
self.len += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The usual names of the exception codes a crash shows.
|
||||
fn code_name(code: u32) -> &'static str {
|
||||
match code {
|
||||
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
|
||||
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
|
||||
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
|
||||
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
|
||||
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
|
||||
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
|
||||
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
|
||||
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
|
||||
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
|
||||
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
|
||||
0x8000_0003 => "EXCEPTION_BREAKPOINT",
|
||||
0xE06D_7363 => "C++ exception",
|
||||
_ => "exception",
|
||||
}
|
||||
}
|
||||
|
||||
/// Write what `pointers` says into the info file, then the minidump.
|
||||
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
|
||||
let Some(paths) = PATHS.get() else { return };
|
||||
if pointers.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: checked non-null; the system keeps it valid during the filter.
|
||||
let rec = unsafe { (*pointers).ExceptionRecord };
|
||||
if rec.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as above.
|
||||
let rec = unsafe { &*rec };
|
||||
let code = rec.ExceptionCode as u32;
|
||||
let address = rec.ExceptionAddress as usize;
|
||||
let mut module: HMODULE = std::ptr::null_mut();
|
||||
let mut name = [0u16; 512];
|
||||
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
|
||||
// handle to release; `name` is as long as we say.
|
||||
let len = unsafe {
|
||||
if GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
address as *const u16,
|
||||
&mut module,
|
||||
) != 0
|
||||
{
|
||||
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
// SAFETY: no preconditions.
|
||||
let thread = unsafe { GetCurrentThreadId() };
|
||||
let mut b = Buf { bytes: [0; 2048], len: 0 };
|
||||
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
|
||||
if len > 0 {
|
||||
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
|
||||
for c in char::decode_utf16(name.iter().take(len).copied()) {
|
||||
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
|
||||
'"' => b.write_str("\\\""),
|
||||
'\\' => b.write_str("\\\\"),
|
||||
c if (c as u32) < 0x20 => b.write_str("?"),
|
||||
c => b.write_char(c),
|
||||
};
|
||||
}
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
|
||||
let what = match rec.ExceptionInformation[0] {
|
||||
0 => "read of",
|
||||
1 => "write to",
|
||||
8 => "execution at",
|
||||
_ => "access to",
|
||||
};
|
||||
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
|
||||
}
|
||||
let _ = b.write_str("}");
|
||||
let bytes = b.bytes.get(..b.len).unwrap_or_default();
|
||||
// SAFETY: NUL-terminated path; the handle is closed below.
|
||||
unsafe {
|
||||
let file = create(&paths.info);
|
||||
if !file.is_null() {
|
||||
let mut written = 0u32;
|
||||
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
|
||||
CloseHandle(file);
|
||||
}
|
||||
}
|
||||
// After a stack overflow there is no stack left for writing a minidump.
|
||||
if code == STATUS_STACK_OVERFLOW {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as above; the exception information points at this thread's exception.
|
||||
unsafe {
|
||||
let file = create(&paths.dump);
|
||||
if !file.is_null() {
|
||||
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
|
||||
MiniDumpWriteDump(
|
||||
GetCurrentProcess(),
|
||||
GetCurrentProcessId(),
|
||||
file,
|
||||
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
|
||||
&exception,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
CloseHandle(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new file at `path` (NUL-terminated) for writing, or null.
|
||||
unsafe fn create(path: &[u16]) -> HANDLE {
|
||||
// SAFETY: the caller passes a NUL-terminated path.
|
||||
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
|
||||
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The child half: install crash reporting and the filter, then fault in native code.
|
||||
#[test]
|
||||
fn child() {
|
||||
let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return };
|
||||
let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-14" };
|
||||
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
|
||||
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
|
||||
if std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT").is_none() {
|
||||
solidcraft_engine::crash::end_session();
|
||||
return;
|
||||
}
|
||||
super::install();
|
||||
solidcraft_engine::crash::phase("device creation");
|
||||
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
|
||||
// pointer's field would be).
|
||||
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
|
||||
println!("unreachable {v}");
|
||||
}
|
||||
|
||||
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
|
||||
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
|
||||
if fault {
|
||||
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", "1");
|
||||
}
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let out = run(&dir, true);
|
||||
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
|
||||
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
|
||||
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
|
||||
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
|
||||
assert_eq!(v["code"], "0xC0000005", "{v}");
|
||||
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
|
||||
assert!(v["module"].as_str().unwrap().to_lowercase().contains("solidcraft"), "the fault is in the test binary: {v}");
|
||||
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
|
||||
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
|
||||
// The next start reports it.
|
||||
let out = run(&dir, false);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let reports = solidcraft_engine::crash::list(&dir);
|
||||
assert_eq!(reports.len(), 1, "{reports:?}");
|
||||
let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
|
||||
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in solidcraft"), "{r}");
|
||||
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
|
||||
assert!(r.get("minidump_path").is_some(), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
//! Windows: the resize border of the window without OS decorations (#1381).
|
||||
//!
|
||||
//! The window keeps its native thick frame (`WS_THICKFRAME`, so Aero Snap, Win+arrows and the
|
||||
//! OS's own move and size loops work) but winit draws no frame: its `WM_NCCALCSIZE` makes the
|
||||
//! whole window client area, and every point then hit-tests as `HTCLIENT`. This module puts the
|
||||
//! resize border back by answering `WM_NCHITTEST` itself: within Windows' resize-border width
|
||||
//! for the window's current DPI from each edge (`solidcraft_ui_egui::titlebar::resize_hit`;
|
||||
//! 8 px at 100 %, corners win), it returns `HTLEFT`, `HTTOPRIGHT`, … So from the first message
|
||||
//! the OS shows its resize cursors and runs its own resize, whatever egui has laid out, whatever
|
||||
//! the scale; nothing waits for a first frame or a first size change.
|
||||
//!
|
||||
//! It also answers `WM_GETDPISCALEDSIZE` with the size the window will really have at the new
|
||||
//! DPI (the same logical size, as winit computes it), so the rect Windows suggests while the
|
||||
//! title bar is dragged onto a monitor with another scale is the rect winit then applies: the
|
||||
//! window rescales once at the crossing and stays under the pointer, instead of the OS's move
|
||||
//! loop and winit disagreeing about a frame the window doesn't have.
|
||||
//!
|
||||
//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this
|
||||
//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others).
|
||||
//! The procedure is installed once, on the thread that owns the window, for the main window only,
|
||||
//! and calls the previous one (winit's) for everything it doesn't answer.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use solidcraft_ui_egui::titlebar;
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
|
||||
use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow};
|
||||
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
|
||||
HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE,
|
||||
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
|
||||
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
|
||||
/// pixels: left, top, width, height.
|
||||
pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)> {
|
||||
let handle = window.window_handle().ok()?;
|
||||
let RawWindowHandle::Win32(w) = handle.as_raw() else { return None };
|
||||
let hwnd = w.hwnd.get() as HWND;
|
||||
// SAFETY: `hwnd` is the live main window; `info` is a MONITORINFO with its size set, as
|
||||
// GetMonitorInfoW requires, and the monitor handle comes from the call before it.
|
||||
let rc = unsafe {
|
||||
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
let mut info: MONITORINFO = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||
if monitor.is_null() || GetMonitorInfoW(monitor, &mut info) == 0 {
|
||||
return None;
|
||||
}
|
||||
info.rcWork
|
||||
};
|
||||
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
|
||||
}
|
||||
|
||||
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
|
||||
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
/// Answer the main window's hit test and DPI-size questions from now on. Call it once, on the
|
||||
/// thread that created the window (eframe's app creator). `false` if it isn't a Win32 window.
|
||||
pub fn install(window: &impl HasWindowHandle) -> bool {
|
||||
let Ok(handle) = window.window_handle() else { return false };
|
||||
let RawWindowHandle::Win32(w) = handle.as_raw() else { return false };
|
||||
let hwnd = w.hwnd.get() as HWND;
|
||||
if PREVIOUS.load(Ordering::SeqCst) != 0 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the
|
||||
// WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns
|
||||
// the previous procedure, which `frame_proc` calls for every other message.
|
||||
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize as _) };
|
||||
// `as _`: on 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
|
||||
// returns i32.
|
||||
if previous == 0 {
|
||||
return false;
|
||||
}
|
||||
PREVIOUS.store(previous as _, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
|
||||
/// The procedure ours replaced.
|
||||
fn previous() -> WNDPROC {
|
||||
let p = PREVIOUS.load(Ordering::SeqCst);
|
||||
// SAFETY: a nonzero value is the WNDPROC that SetWindowLongPtrW returned in `install`.
|
||||
(p != 0).then(|| unsafe { std::mem::transmute::<isize, unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT>(p) })
|
||||
}
|
||||
|
||||
unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
let prev = previous();
|
||||
// SAFETY: the arguments are the message as Windows delivered it to this window.
|
||||
let call_previous = || unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) };
|
||||
match msg {
|
||||
WM_NCHITTEST => {
|
||||
let default = call_previous();
|
||||
// Screen coordinates, signed (monitors left of or above the primary one).
|
||||
let (x, y) = (i32::from(lparam as u16 as i16), i32::from((lparam >> 16) as u16 as i16));
|
||||
// The border, else what winit and the system say (`HTCLIENT` everywhere else).
|
||||
border_hit(hwnd, x, y).map_or(default, |hit| hit as LRESULT)
|
||||
}
|
||||
WM_GETDPISCALEDSIZE => {
|
||||
let new_dpi = wparam as u32;
|
||||
// SAFETY: for this message lParam points to a SIZE the system reads back.
|
||||
match (resizable(hwnd), client_size(hwnd), unsafe { (lparam as *mut SIZE).as_mut() }) {
|
||||
(true, Some(size), Some(out)) => {
|
||||
// SAFETY: a window handle the system passed in.
|
||||
let old_dpi = unsafe { GetDpiForWindow(hwnd) };
|
||||
match titlebar::dpi_scaled_size(size, old_dpi, new_dpi) {
|
||||
Some((cx, cy)) => {
|
||||
*out = SIZE { cx, cy };
|
||||
1
|
||||
}
|
||||
None => call_previous(),
|
||||
}
|
||||
}
|
||||
_ => call_previous(),
|
||||
}
|
||||
}
|
||||
WM_NCDESTROY => {
|
||||
// Hand the window back to winit's procedure for its last message.
|
||||
if prev.is_some() {
|
||||
// SAFETY: restores the procedure this window had before `install`.
|
||||
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst) as _) };
|
||||
}
|
||||
call_previous()
|
||||
}
|
||||
_ => call_previous(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window can be resized by its border now: it has the thick frame (not fullscreen, which
|
||||
/// drops it) and is neither maximized nor minimized.
|
||||
fn resizable(hwnd: HWND) -> bool {
|
||||
// SAFETY: plain queries on a window handle the system passed in.
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32;
|
||||
style & WS_THICKFRAME != 0 && IsZoomed(hwnd) == 0 && IsIconic(hwnd) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The client area's size in pixels (the whole window: winit gives it no frame).
|
||||
fn client_size(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
// SAFETY: `r` is a valid RECT to fill.
|
||||
(unsafe { GetClientRect(hwnd, &mut r) } != 0).then_some((r.right - r.left, r.bottom - r.top))
|
||||
}
|
||||
|
||||
/// Windows' resize border at the window's DPI: the sizing frame plus its padding, as Windows
|
||||
/// itself sizes it, or 8 px scaled when the system has no answer.
|
||||
fn border_px(hwnd: HWND) -> f32 {
|
||||
// SAFETY: plain queries.
|
||||
let (dpi, metric) = unsafe {
|
||||
let dpi = GetDpiForWindow(hwnd);
|
||||
(dpi, GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi))
|
||||
};
|
||||
let scale = if dpi > 0 { dpi as f32 / 96.0 } else { 1.0 };
|
||||
if metric > 0 { metric as f32 } else { titlebar::resize_border_px(scale) }
|
||||
}
|
||||
|
||||
/// The `HT*` resize code at screen point (`x`, `y`), if it is on the border.
|
||||
fn border_hit(hwnd: HWND, x: i32, y: i32) -> Option<u32> {
|
||||
if !resizable(hwnd) {
|
||||
return None;
|
||||
}
|
||||
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
// SAFETY: `r` is a valid RECT to fill.
|
||||
if unsafe { GetWindowRect(hwnd, &mut r) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let w = egui::Rect::from_min_max(egui::pos2(r.left as f32, r.top as f32), egui::pos2(r.right as f32, r.bottom as f32));
|
||||
titlebar::resize_hit(w, egui::pos2(x as f32, y as f32), border_px(hwnd)).map(hit_code)
|
||||
}
|
||||
|
||||
fn hit_code(d: egui::ResizeDirection) -> u32 {
|
||||
use egui::ResizeDirection::*;
|
||||
match d {
|
||||
North => HTTOP,
|
||||
South => HTBOTTOM,
|
||||
West => HTLEFT,
|
||||
East => HTRIGHT,
|
||||
NorthWest => HTTOPLEFT,
|
||||
NorthEast => HTTOPRIGHT,
|
||||
SouthWest => HTBOTTOMLEFT,
|
||||
SouthEast => HTBOTTOMRIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_direction_has_its_own_hit_code() {
|
||||
use egui::ResizeDirection::*;
|
||||
let codes: Vec<u32> = [North, South, West, East, NorthWest, NorthEast, SouthWest, SouthEast].into_iter().map(hit_code).collect();
|
||||
assert_eq!(codes, vec![HTTOP, HTBOTTOM, HTLEFT, HTRIGHT, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT]);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
//! macOS system menus, owned and updated on eframe's main thread.
|
||||
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use muda::{
|
||||
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
accelerator::{Accelerator, Code, Modifiers},
|
||||
};
|
||||
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
|
||||
|
||||
pub struct NativeMenu {
|
||||
menu: Menu,
|
||||
actions: Vec<(MenuItem, FileAction)>,
|
||||
help_actions: Vec<(MenuItem, &'static str)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
recent: Submenu,
|
||||
recent_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
|
||||
let menu = Menu::new();
|
||||
let application = Submenu::new("SolidCraft", true);
|
||||
let file = Submenu::new(app.tr("ui.file"), true);
|
||||
let edit = Submenu::new(app.tr_label("Edit"), true);
|
||||
let recent = Submenu::new(app.tr("ui.open_recent"), true);
|
||||
let window = Submenu::new("Window", true);
|
||||
let help = Submenu::new(app.tr("ui.help"), true);
|
||||
let mut help_actions = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
|
||||
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
|
||||
parent.append(&item)?;
|
||||
actions.push((item, action));
|
||||
Ok(())
|
||||
};
|
||||
application.append(&PredefinedMenuItem::about(
|
||||
Some("About SolidCraft"),
|
||||
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
|
||||
))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
add(
|
||||
&application,
|
||||
&format!("{}\u{2026}", app.tr("ui.preferences")),
|
||||
FileAction::Settings,
|
||||
Some(Accelerator::new(Modifiers::META, Code::Comma)),
|
||||
)?;
|
||||
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append(&PredefinedMenuItem::services(None))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append_items(&[
|
||||
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
])?;
|
||||
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
|
||||
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
|
||||
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
|
||||
file.append(&recent)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, command) in
|
||||
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
|
||||
{
|
||||
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
|
||||
add(
|
||||
&file,
|
||||
&format!("{}\u{2026}", app.tr("ui.save_as")),
|
||||
FileAction::SaveAs,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
|
||||
)?;
|
||||
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, action) in [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
] {
|
||||
add(&file, app.tr_label(label), action, None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
|
||||
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
|
||||
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
|
||||
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
|
||||
window.append(&PredefinedMenuItem::separator())?;
|
||||
window.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::fullscreen(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
])?;
|
||||
for (action, label) in [
|
||||
("docs", "Documentation"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("messages", "Messages\u{2026}"),
|
||||
("report", "Report an Issue\u{2026}"),
|
||||
("crashes", "Crash Reports\u{2026}"),
|
||||
] {
|
||||
let item = MenuItem::new(app.tr_label(label), true, None);
|
||||
help.append(&item)?;
|
||||
help_actions.push((item, action));
|
||||
}
|
||||
menu.append_items(&[&application, &file, &edit, &window, &help])?;
|
||||
let (sender, events) = sync_channel(64);
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.try_send(event);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
menu.init_for_nsapp();
|
||||
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
|
||||
native.update_recent(app)?;
|
||||
Ok(native)
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
for event in self.events.try_iter().take(64) {
|
||||
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
solidcraft_ui_egui::help::run(app, ctx, action);
|
||||
}
|
||||
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
|
||||
raw.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
|
||||
});
|
||||
} else {
|
||||
action.run(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
|
||||
action.enabled(app)
|
||||
|| (matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() && FileAction::Settings.enabled(app))
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
for (item, action) in &self.actions {
|
||||
let enabled = Self::action_enabled(action, app, ctx);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
|
||||
&& let Err(error) = self.update_recent(app)
|
||||
{
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
|
||||
while self.recent.remove_at(0).is_some() {}
|
||||
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
|
||||
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
|
||||
for path in &self.recent_paths {
|
||||
let action = FileAction::OpenRecent(path.clone());
|
||||
let item = MenuItem::new(path, action.enabled(app), None);
|
||||
self.recent.append(&item)?;
|
||||
self.actions.push((item, action));
|
||||
}
|
||||
self.recent.append(&PredefinedMenuItem::separator())?;
|
||||
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
|
||||
self.recent.append(&clear)?;
|
||||
self.actions.push((clear, FileAction::ClearRecent));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeMenu {
|
||||
fn drop(&mut self) {
|
||||
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
|
||||
self.menu.remove_for_nsapp();
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
|
||||
//! `open -a SolidCraft file` (#997).
|
||||
//!
|
||||
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
|
||||
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
|
||||
//! which fails with "SolidCraft cannot open files in the … format" because SolidCraft isn't an
|
||||
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
|
||||
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
|
||||
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
|
||||
//! wakes the UI, which opens them with [`take`] like files named on the command line.
|
||||
//!
|
||||
//! Adding a method to another crate's Objective-C class has no safe API, hence the `unsafe_code`
|
||||
//! allowance on [`install`] and [`add_open_urls_method`] (winit overrides `sendEvent:` on
|
||||
//! `NSApplication` similarly).
|
||||
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::runtime::{AnyObject, Imp, Sel};
|
||||
use objc2::sel;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
|
||||
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
|
||||
|
||||
/// Paths that arrived and haven't been opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
/// Handle open-document events from now on. Call before the event loop runs.
|
||||
#[allow(unsafe_code)]
|
||||
pub fn install() {
|
||||
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
|
||||
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
|
||||
// runs on the posting thread (AppKit posts it on the main thread). The block captures nothing,
|
||||
// so it is sendable. The notification center keeps the registration (and the block) for the
|
||||
// life of the app.
|
||||
unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(NSApplicationWillFinishLaunchingNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive while the app runs.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
let _ = UI.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The paths that arrived since the last call.
|
||||
pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// `application:openURLs:` on the application delegate's class.
|
||||
#[allow(unsafe_code)]
|
||||
fn add_open_urls_method() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
|
||||
log::warn!("no application delegate; files opened from Finder won't open");
|
||||
return;
|
||||
};
|
||||
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
|
||||
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
|
||||
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
|
||||
// it with the arguments the type encoding declares, which match `open_urls`: no return value
|
||||
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
|
||||
let added = unsafe {
|
||||
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
|
||||
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
|
||||
};
|
||||
if !added.as_bool() {
|
||||
log::warn!("the application delegate already handles open-document events");
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit calls this; a panic stops here instead of unwinding through AppKit's frames.
|
||||
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| queue(urls))).is_err() {
|
||||
log::warn!("files opened from Finder were dropped after an internal error");
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue the files among `urls` and wake the UI.
|
||||
fn queue(urls: &NSArray<NSURL>) {
|
||||
// Only file URLs: macOS 27 turns an https URL's path into a file path too.
|
||||
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
||||
if let Some(ctx) = UI.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2::runtime::NSObject;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Files queue until the UI takes them; URLs that aren't files are left out.
|
||||
#[test]
|
||||
fn opened_files_wait_for_the_ui() {
|
||||
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.solidcraft")).unwrap();
|
||||
let urls = NSArray::from_retained_slice(&[
|
||||
NSURL::from_file_path("/tmp/a b.solidcraft").unwrap(),
|
||||
web,
|
||||
NSURL::from_file_path("/tmp/c.step").unwrap(),
|
||||
]);
|
||||
let any = NSObject::new();
|
||||
open_urls(&any, sel!(application:openURLs:), &any, &urls);
|
||||
assert_eq!(take(), ["/tmp/a b.solidcraft", "/tmp/c.step"]);
|
||||
assert!(take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
|
||||
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
|
||||
//! it simply yields less information.
|
||||
|
||||
/// Generic Desktop usage page, and the usages in it that matter here.
|
||||
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
|
||||
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
|
||||
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
|
||||
const AXIS_COUNT: u32 = 6;
|
||||
|
||||
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
|
||||
const MOTION_REPORT_ID: u8 = 1;
|
||||
|
||||
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
|
||||
const MAX_DESCRIPTOR_BYTES: usize = 4096;
|
||||
/// Bound on the usages one main item can declare through a usage range.
|
||||
const MAX_USAGES_PER_ITEM: u32 = 256;
|
||||
|
||||
/// How a device spreads its six motion axes over input reports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
|
||||
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
|
||||
#[default]
|
||||
Split,
|
||||
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
|
||||
Combined,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Item {
|
||||
/// 0 main, 1 global, 2 local (3 is reserved).
|
||||
kind: u8,
|
||||
tag: u8,
|
||||
value: u32,
|
||||
}
|
||||
|
||||
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
|
||||
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
|
||||
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
|
||||
let mut pos = 0_usize;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let prefix = *descriptor.get(pos)?;
|
||||
pos += 1;
|
||||
if prefix == 0xFE {
|
||||
// Long item: a data length byte, a tag byte, then the data.
|
||||
let length = usize::from(*descriptor.get(pos)?);
|
||||
pos = pos.checked_add(2 + length)?;
|
||||
continue;
|
||||
}
|
||||
let size = match prefix & 0b11 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let data = descriptor.get(pos..pos + size)?;
|
||||
pos += size;
|
||||
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
|
||||
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Descriptor {
|
||||
multi_axis_controller: bool,
|
||||
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
|
||||
motion_report_axes: u8,
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
pub fn parse(descriptor: &[u8]) -> Self {
|
||||
let mut summary = Self::default();
|
||||
let mut page = 0_u32;
|
||||
let mut report_id = 0_u8;
|
||||
let mut depth = 0_u32;
|
||||
// Local items belong to the next main item only.
|
||||
let mut usages: Vec<u32> = Vec::new();
|
||||
let mut usage_min = None;
|
||||
for item in items(descriptor) {
|
||||
match (item.kind, item.tag) {
|
||||
// Global: usage page, report id.
|
||||
(1, 0) => page = item.value,
|
||||
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
|
||||
// Local: usage, usage minimum, usage maximum.
|
||||
(2, 0) => usages.push(Self::full_usage(page, item)),
|
||||
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
|
||||
(2, 2) => {
|
||||
if let Some(min) = usage_min.take() {
|
||||
let max = Self::full_usage(page, item);
|
||||
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
|
||||
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
|
||||
}
|
||||
}
|
||||
// Main: input, collection, end collection.
|
||||
(0, 8) => {
|
||||
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
|
||||
summary.note_axes(&usages);
|
||||
}
|
||||
}
|
||||
(0, 0xA) => {
|
||||
// An application collection (1) at the top level names the device.
|
||||
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
|
||||
summary.multi_axis_controller = true;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
(0, 0xC) => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if item.kind == 0 {
|
||||
usages.clear();
|
||||
usage_min = None;
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
|
||||
fn full_usage(page: u32, item: Item) -> u32 {
|
||||
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
|
||||
}
|
||||
|
||||
fn note_axes(&mut self, usages: &[u32]) {
|
||||
for usage in usages {
|
||||
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
|
||||
if axis < AXIS_COUNT {
|
||||
self.motion_report_axes |= 1 << axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
|
||||
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn is_multi_axis_controller(&self) -> bool {
|
||||
self.multi_axis_controller
|
||||
}
|
||||
|
||||
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
|
||||
pub fn layout(&self) -> Layout {
|
||||
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
|
||||
const SPLIT: &[u8] = &[
|
||||
0x05, 0x01, // usage page: generic desktop
|
||||
0x09, 0x08, // usage: multi-axis controller
|
||||
0xA1, 0x01, // collection: application
|
||||
0x85, 0x01, // report id 1
|
||||
0x16, 0xA2, 0xFE, // logical minimum -350
|
||||
0x26, 0x5E, 0x01, // logical maximum 350
|
||||
0x75, 0x10, // report size 16
|
||||
0x95, 0x03, // report count 3
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
|
||||
0x81, 0x06, // input: data, variable, relative
|
||||
0x85, 0x02, // report id 2
|
||||
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
|
||||
0x81, 0x06, // input
|
||||
0xC0, // end collection
|
||||
];
|
||||
|
||||
/// Report 1 = X Y Z Rx Ry Rz.
|
||||
const COMBINED: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
|
||||
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
|
||||
const COMBINED_RANGE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
|
||||
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
|
||||
const KEYBOARD: &[u8] =
|
||||
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
|
||||
|
||||
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
|
||||
const MOUSE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
|
||||
0x06, 0xC0, 0xC0,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
|
||||
let descriptor = Descriptor::parse(SPLIT);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0b000_111);
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_in_report_one_are_a_combined_layout() {
|
||||
for bytes in [COMBINED, COMBINED_RANGE] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Combined);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_interfaces_are_not_3d_mice() {
|
||||
for bytes in [KEYBOARD, MOUSE, &[][..]] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(!descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_byte_usages_carry_their_own_page() {
|
||||
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
|
||||
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_hostile_descriptors_never_panic() {
|
||||
for end in 0..=COMBINED.len() {
|
||||
let _ = Descriptor::parse(&COMBINED[..end]);
|
||||
}
|
||||
// Long item with a length running past the end, huge usage ranges, deep nesting.
|
||||
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
|
||||
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
|
||||
let _ = Descriptor::parse(&[0xC0; 64]);
|
||||
let _ = Descriptor::parse(&[0xA1; 64]);
|
||||
let mut state = 0x2545_F491_4F6C_DD1D_u64;
|
||||
for _ in 0..200 {
|
||||
let bytes: Vec<u8> = (0..64)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state.to_le_bytes()[0]
|
||||
})
|
||||
.collect();
|
||||
let _ = Descriptor::parse(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +1,57 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its report descriptor and ids under `/sys/class/hidraw/hidrawN/device/`. Reading them is plain
|
||||
//! file I/O, so no udev or hidapi library is needed.
|
||||
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
|
||||
//! plain file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use solidcraft_space_mouse::{Descriptor, Layout};
|
||||
use super::hid_descriptor::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
/// `O_NONBLOCK` on Linux's common architectures (x86, ARM, RISC-V). The device is opened with it so
|
||||
/// a read with nothing to read returns at once and the reader can see whether it should stop.
|
||||
pub const O_NONBLOCK: i32 = 0o4000;
|
||||
|
||||
/// Opens a hidraw node the way the Direct reader reads it: read-only and non-blocking.
|
||||
pub fn open(path: &Path) -> std::io::Result<std::fs::File> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new().read(true).custom_flags(O_NONBLOCK).open(path)
|
||||
}
|
||||
|
||||
/// How long the reader waits when the device has nothing to say, which is also the most a report
|
||||
/// can be delayed.
|
||||
const IDLE_POLL: Duration = Duration::from_millis(5);
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
/// Its vendor and product ids, when the kernel gives them.
|
||||
pub ids: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it.
|
||||
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
|
||||
fn vendor_id(uevent: &str) -> Option<u16> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let vendor = id.split(':').nth(1)?;
|
||||
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
|
||||
pub fn find() -> Option<Device> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
|
||||
names.sort();
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
/// Hands every report to `publish` until the device is gone (end of file or an error) or `stop`
|
||||
/// says so. `source` is non-blocking: when it has nothing, `stop` is asked and the reader waits
|
||||
/// [`IDLE_POLL`]. Reports are read and handed over exactly as they were with blocking reads.
|
||||
pub fn read_reports(mut source: impl Read, mut publish: impl FnMut(&[u8]), mut stop: impl FnMut() -> bool) {
|
||||
let mut report = [0_u8; super::REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match source.read(&mut report) {
|
||||
Ok(0) => return,
|
||||
Ok(size) => publish(report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(IDLE_POLL);
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
|
||||
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
// A controller that declares no motion axis (buttons only, say) isn't read.
|
||||
let layout = descriptor.is_multi_axis_controller().then(|| descriptor.layout()).flatten()?;
|
||||
let ids = std::fs::read_to_string(sysfs.join("device/uevent")).ok().as_deref().and_then(ids);
|
||||
Some(Device { path, layout, ids })
|
||||
}
|
||||
|
||||
/// The vendor and product ids in a HID device's uevent: `HID_ID=bus:vendor:product`, in hex.
|
||||
fn ids(uevent: &str) -> Option<(u16, u16)> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let mut hex = id.split(':').skip(1).map(|part| u32::from_str_radix(part, 16).ok().and_then(|v| u16::try_from(v).ok()));
|
||||
Some((hex.next()??, hex.next()??))
|
||||
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real EACCES, as a user without the udev rule gets: a file of the user's own with no permission
|
||||
/// bits. Skipped for root, which can open anything.
|
||||
#[test]
|
||||
fn a_device_without_permission_is_denied_and_the_daemon_takes_over_within_the_backoff() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
if std::fs::metadata("/proc/self").is_ok_and(|meta| meta.uid() == 0) {
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("sc-hidraw-denied-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let node = dir.join("hidraw0");
|
||||
std::fs::write(&node, [0_u8; 4]).unwrap();
|
||||
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let denied = open(&node);
|
||||
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(open(&node).is_ok(), "the same node opens once it is readable");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(denied.err().map(|error| error.kind()), Some(std::io::ErrorKind::PermissionDenied));
|
||||
// The failure is the choice's `since_failed`, with an injected clock: inside the backoff the
|
||||
// daemon is read, whichever driver was asked for; after it the device is tried again.
|
||||
use super::super::{Pick, SpaceMouseDriver, UNREADABLE_RETRY, choose, direct_usable};
|
||||
for wanted in [SpaceMouseDriver::Automatic, SpaceMouseDriver::Spacenavd, SpaceMouseDriver::Direct] {
|
||||
for ago in [Duration::ZERO, Duration::from_secs(29)] {
|
||||
assert_eq!(choose(wanted, true, direct_usable(true, Some(ago), UNREADABLE_RETRY)), Some(Pick::Spacenavd), "{wanted:?} {ago:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(choose(SpaceMouseDriver::Direct, true, direct_usable(true, Some(UNREADABLE_RETRY), UNREADABLE_RETRY)), Some(Pick::Direct));
|
||||
// And the daemon's own reader delivers over a fake connection.
|
||||
let (mut daemon, client) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let mut record = [0_u8; 32];
|
||||
record[4..8].copy_from_slice(&100_i32.to_ne_bytes());
|
||||
std::io::Write::write_all(&mut daemon, &record).unwrap();
|
||||
drop(daemon);
|
||||
let mut seen = 0;
|
||||
super::super::spacenavd::read(client, |_| seen += 1, |_, _| {}, || false, Duration::from_secs(3600));
|
||||
assert_eq!(seen, 1);
|
||||
fn reads_the_vendor_from_the_hid_id() {
|
||||
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
|
||||
assert_eq!(vendor_id(uevent), Some(0x256f));
|
||||
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_come_through_idle_gaps_until_the_device_ends_or_a_stop() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
let mut script = Script::new();
|
||||
script.timeout(std::io::ErrorKind::WouldBlock).data(&[1, 2, 3]).timeout(std::io::ErrorKind::WouldBlock).data(&[4]).end();
|
||||
let (mut seen, mut asked) = (Vec::new(), 0);
|
||||
read_reports(
|
||||
script,
|
||||
|report| seen.push(report.to_vec()),
|
||||
|| {
|
||||
asked += 1;
|
||||
false
|
||||
},
|
||||
);
|
||||
assert_eq!(seen, [vec![1, 2, 3], vec![4]]);
|
||||
assert_eq!(asked, 2);
|
||||
let mut script = Script::new();
|
||||
script.data(&[1]).timeout(std::io::ErrorKind::WouldBlock).data(&[2]);
|
||||
let mut seen = Vec::new();
|
||||
read_reports(script, |report| seen.push(report.to_vec()), || true);
|
||||
assert_eq!(seen, [vec![1]], "a stop at an idle moment ends the read");
|
||||
// An unplugged device is an error, which ends the read as before.
|
||||
let mut script = Script::new();
|
||||
script.data(&[1]).fail(std::io::ErrorKind::BrokenPipe).data(&[2]);
|
||||
let mut seen = Vec::new();
|
||||
read_reports(script, |report| seen.push(report.to_vec()), || false);
|
||||
assert_eq!(seen, [vec![1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_come_from_the_uevent() {
|
||||
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
|
||||
assert_eq!(ids(wireless), Some((0x256f, 0xc63a)));
|
||||
assert_eq!(ids("HID_ID=0003:0000046D\n"), None);
|
||||
assert_eq!(ids("HID_ID=0003:0001046D:0000C62B\n"), None, "a vendor past 16 bits isn't a USB id");
|
||||
assert_eq!(ids("HID_NAME=keyboard\n"), None);
|
||||
fn malformed_ids_are_rejected() {
|
||||
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
|
||||
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
//! Reading a 3D mouse through `spacenavd` on Linux. The daemon owns the device and applies what
|
||||
//! `spnavcfg` sets (swapped and inverted axes, sensitivity, dead zones) before it hands the motion
|
||||
//! to its clients, so a program that reads the HID device itself bypasses that setup. Over its
|
||||
//! socket the daemon sends fixed 32-byte records: eight native-endian `i32`, the first the kind of
|
||||
//! record (0 for motion, then x y z rx ry rz and the milliseconds since the last record).
|
||||
//! Button records (1 pressed, 2 released, then the button's number from 0) are read too; other
|
||||
//! records (from newer daemons also configuration changes) are ignored.
|
||||
//!
|
||||
//! The socket is plain file I/O, so no library is needed, and it needs no udev rule: the daemon
|
||||
//! is what has access to the device.
|
||||
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
|
||||
/// Where the daemon listens. `/var/run` is a link to `/run` on current systems.
|
||||
const SOCKETS: [&str; 2] = ["/var/run/spnav.sock", "/run/spnav.sock"];
|
||||
|
||||
const RECORD_BYTES: usize = 32;
|
||||
|
||||
/// What the daemon reports at full deflection (seen with a SpaceMouse Compact and a default
|
||||
/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`].
|
||||
const DAEMON_FULL_SCALE: i64 = 500;
|
||||
|
||||
/// Whether the daemon's socket is there, without connecting to it.
|
||||
pub fn exists() -> bool {
|
||||
SOCKETS.iter().any(|path| Path::new(path).exists())
|
||||
}
|
||||
|
||||
pub fn connect() -> Option<UnixStream> {
|
||||
SOCKETS.iter().find_map(|path| connect_to(Path::new(path)))
|
||||
}
|
||||
|
||||
fn connect_to(path: &Path) -> Option<UnixStream> {
|
||||
UnixStream::connect(path).ok()
|
||||
}
|
||||
|
||||
/// The axes of one record, or `None` for a record that is not motion.
|
||||
///
|
||||
/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is
|
||||
/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations
|
||||
/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what
|
||||
/// the user swaps or inverts there then carries over.
|
||||
fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
|
||||
let mut data = [0_i32; 8];
|
||||
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
|
||||
*slot = i32::from_ne_bytes(*bytes);
|
||||
}
|
||||
let [kind, x, y, z, rx, ry, rz, _period] = data;
|
||||
(kind == 0).then(|| Axes {
|
||||
translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))],
|
||||
rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))],
|
||||
})
|
||||
}
|
||||
|
||||
/// The button a button record is about, from 0, and whether it went down; `None` for any other
|
||||
/// record, or a button past the 32 a bitmask holds.
|
||||
fn button(record: &[u8; RECORD_BYTES]) -> Option<(u32, bool)> {
|
||||
let mut data = [0_i32; 2];
|
||||
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
|
||||
*slot = i32::from_ne_bytes(*bytes);
|
||||
}
|
||||
let [kind, number] = data;
|
||||
let number = u32::try_from(number).ok().filter(|&n| n < 32)?;
|
||||
match kind {
|
||||
1 => Some((number, true)),
|
||||
2 => Some((number, false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A daemon value in our raw units. Out-of-range values stay in `i16`: they are input from another
|
||||
/// process.
|
||||
fn unit(value: impl Into<i64>) -> i16 {
|
||||
let scaled = value.into() * i64::from(FULL_SCALE) / DAEMON_FULL_SCALE;
|
||||
i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Hands every motion record to `publish` and every button record to `pressed` until the daemon
|
||||
/// closes the connection or fails, or `stop` says so. A stream with a read timeout
|
||||
/// (`set_read_timeout`) times out when the daemon is quiet, which is no record and no disconnect:
|
||||
/// `stop` is asked, and the read goes on, with what of a record had arrived kept. `stop` is also asked
|
||||
/// at least every `check_every` while records keep arriving, so a daemon that never pauses for that
|
||||
/// long (a moving cap sends every few milliseconds) does not keep the reader from noticing that the
|
||||
/// device it prefers is back. A partial record at the end is dropped.
|
||||
pub fn read(
|
||||
mut stream: impl Read,
|
||||
mut publish: impl FnMut(Axes),
|
||||
mut pressed: impl FnMut(u32, bool),
|
||||
mut stop: impl FnMut() -> bool,
|
||||
check_every: Duration,
|
||||
) {
|
||||
let mut record = [0_u8; RECORD_BYTES];
|
||||
let mut filled = 0;
|
||||
let mut asked = Instant::now();
|
||||
loop {
|
||||
if asked.elapsed() >= check_every {
|
||||
asked = Instant::now();
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
match stream.read(record.get_mut(filled..).unwrap_or_default()) {
|
||||
Ok(0) => {
|
||||
log::info!("3D mouse: spacenavd connection ended");
|
||||
return;
|
||||
}
|
||||
Ok(size) => {
|
||||
filled += size;
|
||||
if filled == RECORD_BYTES {
|
||||
filled = 0;
|
||||
if let Some(axes) = decode(&record) {
|
||||
publish(axes);
|
||||
} else if let Some((number, down)) = button(&record) {
|
||||
pressed(number, down);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
||||
asked = Instant::now();
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: spacenavd connection ended: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn record(data: [i32; 8]) -> [u8; RECORD_BYTES] {
|
||||
let mut bytes = [0_u8; RECORD_BYTES];
|
||||
for (chunk, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(data) {
|
||||
*chunk = value.to_ne_bytes();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_records_become_axes_in_the_devices_raw_frame() {
|
||||
// Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip.
|
||||
assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] }));
|
||||
// Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do.
|
||||
assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] }));
|
||||
// Rest is a motion record of zeros.
|
||||
assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_spnavcfg_swaps_reaches_the_view() {
|
||||
// The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push
|
||||
// as z, and the view gets a lift (raw z) instead of the zoom (raw y).
|
||||
let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation);
|
||||
let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation);
|
||||
assert_eq!(before, Some([0, -350, 0]));
|
||||
assert_eq!(after, Some([0, 0, -350]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_records_and_hostile_values_are_harmless() {
|
||||
// A button record (kind 1, press) and one of an unknown kind are not motion.
|
||||
assert_eq!(decode(&record([1, 0, 1, 0, 0, 0, 0, 0])), None);
|
||||
// Nor are hostile button numbers a button.
|
||||
assert_eq!(button(&record([1, -1, 0, 0, 0, 0, 0, 0])), None);
|
||||
assert_eq!(button(&record([1, i32::MAX, 0, 0, 0, 0, 0, 0])), None);
|
||||
assert_eq!(button(&record([7, 3, 0, 0, 0, 0, 0, 0])), None);
|
||||
assert_eq!(decode(&record([7, 500, 500, 500, 500, 500, 500, 0])), None);
|
||||
// Nothing overflows on the extremes of `i32`.
|
||||
let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1]));
|
||||
assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN));
|
||||
assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX));
|
||||
assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_is_read_record_by_record_until_it_ends() {
|
||||
let (mut daemon, client) = UnixStream::pair().expect("a socket pair");
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend(record([0, 100, 0, 0, 0, 0, 0, 8]));
|
||||
bytes.extend(record([1, 4, 0, 0, 0, 0, 0, 0])); // button 4 down
|
||||
bytes.extend(record([2, 4, 0, 0, 0, 0, 0, 0])); // and up
|
||||
bytes.extend(record([1, 40, 0, 0, 0, 0, 0, 0])); // past the 32 a bitmask holds: skipped
|
||||
bytes.extend(record([0, 0, 0, 0, 0, 0, 0, 900]));
|
||||
bytes.extend([1, 2, 3]); // the daemon went away mid-record
|
||||
daemon.write_all(&bytes).expect("written");
|
||||
drop(daemon);
|
||||
let (mut seen, mut buttons) = (Vec::new(), Vec::new());
|
||||
read(client, |axes| seen.push(axes.translation), |number, down| buttons.push((number, down)), || false, Duration::from_secs(3600));
|
||||
assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]);
|
||||
assert_eq!(buttons, [(4, true), (4, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quiet_daemon_times_out_without_losing_the_record_it_was_sending() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
use std::io::ErrorKind::{TimedOut, WouldBlock};
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let mut script = Script::new();
|
||||
script.timeout(WouldBlock).data(&bytes[..10]).timeout(TimedOut).timeout(WouldBlock).data(&bytes[10..]).timeout(WouldBlock).end();
|
||||
let (mut seen, mut asked) = (Vec::new(), 0);
|
||||
read(
|
||||
script,
|
||||
|axes| seen.push(axes.translation),
|
||||
|_, _| {},
|
||||
|| {
|
||||
asked += 1;
|
||||
false
|
||||
},
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
assert_eq!(seen, [[70, 0, 0]]);
|
||||
assert_eq!(asked, 4, "each timeout asks whether to stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_ends_the_read_at_the_next_timeout_only() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let mut script = Script::new();
|
||||
script.data(&bytes).timeout(std::io::ErrorKind::WouldBlock).data(&bytes);
|
||||
let mut seen = 0;
|
||||
read(script, |_| seen += 1, |_, _| {}, || true, Duration::from_secs(3600));
|
||||
assert_eq!(seen, 1, "the record before the timeout arrived; the one after never was read");
|
||||
}
|
||||
|
||||
/// A daemon that never goes quiet (a moving cap sends every few milliseconds) is still asked whether to
|
||||
/// stop, at the interval given, so the reader can go back to a device that returned.
|
||||
#[test]
|
||||
fn a_busy_daemon_is_asked_whether_to_stop_on_a_timer() {
|
||||
// Ends by itself after half a second, so a read that never asks fails the assertion below
|
||||
// instead of hanging the test.
|
||||
struct Busy(Instant);
|
||||
impl Read for Busy {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.0.elapsed() > Duration::from_millis(500) {
|
||||
return Ok(0);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let size = bytes.len().min(buf.len());
|
||||
buf[..size].copy_from_slice(&bytes[..size]);
|
||||
Ok(size)
|
||||
}
|
||||
}
|
||||
let (mut seen, mut asked) = (0, 0);
|
||||
read(
|
||||
Busy(Instant::now()),
|
||||
|_| seen += 1,
|
||||
|_, _| {},
|
||||
|| {
|
||||
asked += 1;
|
||||
asked == 3
|
||||
},
|
||||
Duration::from_millis(10),
|
||||
);
|
||||
assert_eq!(asked, 3, "asked on the timer although no read ever timed out");
|
||||
assert!(seen > 3, "records kept coming meanwhile: {seen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_daemon_means_no_connection() {
|
||||
let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id()));
|
||||
assert!(connect_to(&missing).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
//! Windows: finding 3D mice without asking any other device anything (#2614).
|
||||
//!
|
||||
//! hidapi's enumeration opens every HID interface and reads its serial number, manufacturer and
|
||||
//! product strings: on USB, three control requests to every keyboard, mouse and touchscreen. The
|
||||
//! reader repeated it every two seconds while no 3D mouse was plugged in, and on some machines the
|
||||
//! keyboard then kept dropping off the bus and the cursor jumped. Plug and Play already names each
|
||||
//! interface's top-level usage in its hardware IDs (`HID_DEVICE_UP:0001_U:0008`: Generic Desktop,
|
||||
//! Multi-Axis Controller), so the reader asks it, which sends nothing to any device, and opens only
|
||||
//! the interfaces that are 3D mice.
|
||||
//!
|
||||
//! The configuration manager has no safe API, hence the `unsafe_code` allowance on this module
|
||||
//! (the app crate denies it; see `native_crash.rs`).
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::ptr::{null, null_mut};
|
||||
|
||||
use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_Get_DevNode_PropertyW, CM_Get_Device_Interface_List_SizeW, CM_Get_Device_Interface_ListW,
|
||||
CM_Get_Device_Interface_PropertyW, CM_LOCATE_DEVNODE_NORMAL, CM_Locate_DevNodeW, CONFIGRET, CR_BUFFER_SMALL, CR_SUCCESS,
|
||||
};
|
||||
use windows_sys::Win32::Devices::HumanInterfaceDevice::GUID_DEVINTERFACE_HID;
|
||||
use windows_sys::Win32::Devices::Properties::{
|
||||
DEVPKEY_Device_HardwareIds, DEVPKEY_Device_InstanceId, DEVPROP_TYPE_STRING, DEVPROP_TYPE_STRING_LIST, DEVPROPTYPE,
|
||||
};
|
||||
use windows_sys::Win32::Foundation::DEVPROPKEY;
|
||||
|
||||
/// The hardware ID Windows gives a top-level collection with the Generic Desktop page's
|
||||
/// Multi-Axis Controller usage.
|
||||
const MULTI_AXIS_CONTROLLER: &str = "HID_DEVICE_UP:0001_U:0008";
|
||||
/// Largest property read, in bytes: hardware IDs are a few hundred.
|
||||
const MAX_PROPERTY_BYTES: u32 = 64 * 1024;
|
||||
/// Tries at reading the interface list, which can grow between asking its size and reading it.
|
||||
const LIST_TRIES: usize = 4;
|
||||
|
||||
/// The paths of the present HID interfaces whose top-level collection is a multi-axis controller.
|
||||
pub fn multi_axis_controllers() -> Vec<CString> {
|
||||
interfaces()
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
let Some(instance) = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()) else {
|
||||
return false;
|
||||
};
|
||||
devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids))
|
||||
})
|
||||
.filter_map(|path| CString::new(path).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a device with these hardware IDs is a multi-axis controller.
|
||||
fn is_multi_axis(hardware_ids: &[String]) -> bool {
|
||||
hardware_ids.iter().any(|id| id.eq_ignore_ascii_case(MULTI_AXIS_CONTROLLER))
|
||||
}
|
||||
|
||||
/// The strings of a nul-separated list (`REG_MULTI_SZ`, or one string), without empty ones.
|
||||
fn split_multi_sz(buffer: &[u16]) -> Vec<String> {
|
||||
buffer.split(|c| *c == 0).filter(|s| !s.is_empty()).map(String::from_utf16_lossy).collect()
|
||||
}
|
||||
|
||||
/// `s` as a nul-terminated wide string.
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// The paths of the present HID interfaces.
|
||||
fn interfaces() -> Vec<String> {
|
||||
for _ in 0..LIST_TRIES {
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `len` is a valid place for the size; the class GUID is a static; no device filter.
|
||||
let sized = unsafe { CM_Get_Device_Interface_List_SizeW(&mut len, &GUID_DEVINTERFACE_HID, null(), CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
|
||||
if sized != CR_SUCCESS || len == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut buffer = vec![0u16; len as usize];
|
||||
// SAFETY: `buffer` holds `len` wide characters, the length the call is given.
|
||||
let listed =
|
||||
unsafe { CM_Get_Device_Interface_ListW(&GUID_DEVINTERFACE_HID, null(), buffer.as_mut_ptr(), len, CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
|
||||
match listed {
|
||||
CR_SUCCESS => return split_multi_sz(&buffer),
|
||||
CR_BUFFER_SMALL => continue,
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A string or string-list property, read by `get` (property type, buffer, its size in bytes) in
|
||||
/// two calls: the size, then the value.
|
||||
fn property_strings(get: impl Fn(*mut DEVPROPTYPE, *mut u8, *mut u32) -> CONFIGRET) -> Option<Vec<String>> {
|
||||
let mut kind: DEVPROPTYPE = 0;
|
||||
let mut size = 0u32;
|
||||
if get(&mut kind, null_mut(), &mut size) != CR_BUFFER_SMALL || size == 0 || size > MAX_PROPERTY_BYTES {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u16; (size as usize).div_ceil(2)];
|
||||
let mut size = (buffer.len() * 2) as u32;
|
||||
if get(&mut kind, buffer.as_mut_ptr().cast(), &mut size) != CR_SUCCESS || (kind != DEVPROP_TYPE_STRING && kind != DEVPROP_TYPE_STRING_LIST) {
|
||||
return None;
|
||||
}
|
||||
Some(split_multi_sz(&buffer))
|
||||
}
|
||||
|
||||
/// A string property of the device interface at `path`.
|
||||
fn interface_strings(path: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
|
||||
let path = wide(path);
|
||||
property_strings(|kind, buffer, size| {
|
||||
// SAFETY: `path` is nul-terminated and outlives the call; `buffer` is null (asking the size)
|
||||
// or holds `*size` bytes, as `property_strings` sets up.
|
||||
unsafe { CM_Get_Device_Interface_PropertyW(path.as_ptr(), key, kind, buffer, size, 0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// A string property of the device with instance ID `instance`.
|
||||
fn devnode_strings(instance: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
|
||||
let instance = wide(instance);
|
||||
let mut devnode = 0u32;
|
||||
// SAFETY: `instance` is nul-terminated and outlives the call; `devnode` is a valid place for the result.
|
||||
if unsafe { CM_Locate_DevNodeW(&mut devnode, instance.as_ptr(), CM_LOCATE_DEVNODE_NORMAL) } != CR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
property_strings(|kind, buffer, size| {
|
||||
// SAFETY: `buffer` is null (asking the size) or holds `*size` bytes, as `property_strings` sets up.
|
||||
unsafe { CM_Get_DevNode_PropertyW(devnode, key, kind, buffer, size, 0) }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The hardware IDs Windows gives a wired 3Dconnexion device's motion interface, and a
|
||||
/// keyboard's: only the first is a multi-axis controller.
|
||||
#[test]
|
||||
fn only_the_multi_axis_usage_is_a_3d_mouse() {
|
||||
let ids = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let space_mouse = ids(&["HID\\VID_256F&PID_C635&REV_0437&MI_00", "HID\\VID_256F&UP:0001_U:0008", "HID_DEVICE_UP:0001_U:0008", "HID_DEVICE"]);
|
||||
let keyboard = ids(&[
|
||||
"HID\\VID_046D&PID_C31C&REV_6400&MI_00",
|
||||
"HID\\VID_046D&UP:0001_U:0006",
|
||||
"HID_DEVICE_SYSTEM_KEYBOARD",
|
||||
"HID_DEVICE_UP:0001_U:0006",
|
||||
"HID_DEVICE",
|
||||
]);
|
||||
let vendor = ids(&["HID\\VID_256F&UP:FF00_U:0001", "HID_DEVICE_UP:FF00_U:0001", "HID_DEVICE_UPR:FF00-FFFF", "HID_DEVICE"]);
|
||||
assert!(is_multi_axis(&space_mouse));
|
||||
assert!(is_multi_axis(&ids(&["hid_device_up:0001_u:0008"])), "IDs compare without regard to case");
|
||||
assert!(!is_multi_axis(&keyboard) && !is_multi_axis(&vendor) && !is_multi_axis(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nul_separated_lists_split_into_their_strings() {
|
||||
let buffer: Vec<u16> = "a\\b\0HID_DEVICE\0\0".encode_utf16().collect();
|
||||
assert_eq!(split_multi_sz(&buffer), ["a\\b", "HID_DEVICE"]);
|
||||
assert_eq!(split_multi_sz(&wide("one")), ["one"]);
|
||||
assert!(split_multi_sz(&[0, 0]).is_empty() && split_multi_sz(&[]).is_empty());
|
||||
}
|
||||
|
||||
/// The scan asks Plug and Play only: on any machine it returns, and every path it returns is a
|
||||
/// present HID interface whose hardware IDs name the multi-axis usage.
|
||||
#[test]
|
||||
fn the_scan_returns_only_multi_axis_interfaces() {
|
||||
let found = multi_axis_controllers();
|
||||
let all = interfaces();
|
||||
for path in &found {
|
||||
let path = path.to_str().unwrap_or_default();
|
||||
assert!(all.iter().any(|p| p == path), "{path}");
|
||||
let instance = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()).unwrap_or_default();
|
||||
assert!(devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids)), "{path}");
|
||||
}
|
||||
eprintln!("{} HID interfaces present, {} multi-axis controllers", all.len(), found.len());
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
//! Getting the window open, whatever state the settings folder or the graphics device is in
|
||||
//! (docs/book/src/guide/troubleshooting.md).
|
||||
//!
|
||||
//! - **Settings that don't read** (`app.ron`, `graphics.json`): moved aside as
|
||||
//! `<name>.bad-<time>` and the app starts with defaults, instead of a newer or damaged file
|
||||
//! being dropped silently or stopping the start.
|
||||
//! - **Launches that die before the window is up**: a marker file counts them; after two in a
|
||||
//! row the next launch starts in safe mode by itself.
|
||||
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
|
||||
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
|
||||
//! - **No usable graphics device**: one retry on the software renderer
|
||||
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The environment variable that starts safe mode (any value but `0` or empty).
|
||||
pub const SAFE_MODE_ENV: &str = "SOLIDCRAFT_SAFE_MODE";
|
||||
/// `SOLIDCRAFT_RENDERER=cpu` picks the software renderer (WARP, lavapipe, llvmpipe).
|
||||
pub const RENDERER_ENV: &str = "SOLIDCRAFT_RENDERER";
|
||||
/// Consecutive launches that died before the window was up, after which safe mode starts.
|
||||
pub const FAILED_STARTS_FOR_SAFE_MODE: u32 = 2;
|
||||
const MARKER: &str = "starting";
|
||||
|
||||
/// Whether an environment value switches something on.
|
||||
pub fn env_on(v: Option<&str>) -> bool {
|
||||
v.map(str::trim).is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
|
||||
}
|
||||
|
||||
/// Whether `SOLIDCRAFT_RENDERER` asks for the software renderer.
|
||||
pub fn cpu_renderer_env(v: Option<&str>) -> bool {
|
||||
v.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("cpu") || v.eq_ignore_ascii_case("software"))
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Move `path` aside as `<name>.bad-<time>` (kept for a bug report); returns where it went.
|
||||
pub fn back_up(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "settings".into());
|
||||
let mut to = path.with_file_name(format!("{name}.bad-{}", unix_now()));
|
||||
let mut n = 1;
|
||||
while to.exists() && n < 100 {
|
||||
to = path.with_file_name(format!("{name}.bad-{}-{n}", unix_now()));
|
||||
n += 1;
|
||||
}
|
||||
std::fs::rename(path, &to)?;
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// Whether eframe's `app.ron` reads, with the preferences inside it.
|
||||
fn app_ron_reads(text: &str, prefs_keys: &[&str]) -> bool {
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
|
||||
prefs_keys.iter().filter_map(|k| kv.get(*k)).all(|p| serde_json::from_str::<serde_json::Value>(p).is_ok_and(|v| v.is_object()))
|
||||
}
|
||||
|
||||
/// Check the settings files in `dir` before anything reads them: one that doesn't read is moved
|
||||
/// aside (the app then starts with defaults for it). Returns a line per file moved.
|
||||
pub fn check_settings(dir: &Path, prefs_keys: &[&str]) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
let mut check = |name: &str, reads: &dyn Fn(&str) -> bool| {
|
||||
let path = dir.join(name);
|
||||
let Ok(bytes) = std::fs::read(&path) else { return };
|
||||
let ok = std::str::from_utf8(&bytes).is_ok_and(|t| t.trim().is_empty() || reads(t));
|
||||
if ok {
|
||||
return;
|
||||
}
|
||||
match back_up(&path) {
|
||||
Ok(to) => {
|
||||
log::warn!("settings: {} did not read; moved to {}", path.display(), to.display());
|
||||
notes.push(format!("{name} could not be read and was moved to {}; defaults are used.", to.display()));
|
||||
}
|
||||
Err(e) => log::warn!("settings: {} did not read and could not be moved: {e}", path.display()),
|
||||
}
|
||||
};
|
||||
check("app.ron", &|t| app_ron_reads(t, prefs_keys));
|
||||
check("graphics.json", &|t| serde_json::from_str::<serde_json::Value>(t).is_ok_and(|v| v.is_object()));
|
||||
notes
|
||||
}
|
||||
|
||||
/// A launch begins: count it in the marker in `dir`. Returns how many launches before this one
|
||||
/// died before their window was up.
|
||||
pub fn begin(dir: &Path) -> u32 {
|
||||
let marker = dir.join(MARKER);
|
||||
let before = std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::<u32>().ok()).unwrap_or(0).min(1000);
|
||||
if std::fs::create_dir_all(dir).is_ok() {
|
||||
let _ = std::fs::write(&marker, (before + 1).to_string());
|
||||
}
|
||||
before
|
||||
}
|
||||
|
||||
/// The window is up: this launch did not fail to start.
|
||||
pub fn started(dir: &Path) {
|
||||
let _ = std::fs::remove_file(dir.join(MARKER));
|
||||
}
|
||||
|
||||
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
|
||||
/// the first one that can draw to `surface`.
|
||||
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
|
||||
let fits = |a: &&eframe::wgpu::Adapter| surface.is_none_or(|s| a.is_surface_supported(s));
|
||||
adapters
|
||||
.iter()
|
||||
.filter(fits)
|
||||
.find(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu)
|
||||
.or_else(|| adapters.iter().find(fits))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend)).collect();
|
||||
format!("no graphics adapter can draw the window (found: {})", if names.is_empty() { "none".into() } else { names.join(", ") })
|
||||
})
|
||||
}
|
||||
|
||||
/// `WGPU_BACKEND`: when set, wgpu picks the backends from it and the order below is not used.
|
||||
pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
|
||||
|
||||
/// The graphics backends to start with, or `None` to leave it to wgpu (`WGPU_BACKEND`, or all
|
||||
/// the backends it was built with).
|
||||
///
|
||||
/// On Windows, D3D12 comes first, then Vulkan, then OpenGL, and only the first one with a
|
||||
/// hardware adapter is started at all. wgpu would otherwise start Vulkan, D3D12 and OpenGL
|
||||
/// together and prefer Vulkan, and starting Vulkan loads every installed Vulkan driver: Intel's
|
||||
/// (`igvk64.dll`) crashed SolidCraft at startup that way, inside the driver, before anything
|
||||
/// could be reported. D3D12 is Windows' own graphics API, the one its drivers are tested with.
|
||||
/// `has_hardware(backends)` says whether those backends have an adapter that isn't software
|
||||
/// (it is only asked about the backends being tried, one at a time).
|
||||
///
|
||||
/// - `WGPU_BACKEND` set (`env_set`): `None`, whatever it says wins.
|
||||
/// - The software renderer (`cpu`, safe mode): D3D12 alone on Windows (WARP); `None` elsewhere.
|
||||
/// - `preference` (`graphics.json`'s `backend`: `dx12`, `vulkan`, `gl` or a comma list; `auto`
|
||||
/// or empty for the order above): those backends, on any system.
|
||||
/// - Elsewhere than Windows: `None`.
|
||||
/// - No hardware adapter at all: D3D12 (WARP, Windows' software renderer).
|
||||
pub fn choose_backends(
|
||||
windows: bool,
|
||||
env_set: bool,
|
||||
cpu: bool,
|
||||
preference: Option<&str>,
|
||||
mut has_hardware: impl FnMut(eframe::wgpu::Backends) -> bool,
|
||||
) -> Option<eframe::wgpu::Backends> {
|
||||
use eframe::wgpu::Backends;
|
||||
if env_set {
|
||||
return None;
|
||||
}
|
||||
if cpu {
|
||||
return windows.then_some(Backends::DX12);
|
||||
}
|
||||
if let Some(p) = preference.map(str::trim).filter(|p| !p.is_empty() && !p.eq_ignore_ascii_case("auto")) {
|
||||
let chosen = Backends::from_comma_list(p);
|
||||
if !chosen.is_empty() {
|
||||
return Some(chosen);
|
||||
}
|
||||
log::warn!("graphics: unknown backend preference `{p}`; using the default order");
|
||||
}
|
||||
if !windows {
|
||||
return None;
|
||||
}
|
||||
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
|
||||
}
|
||||
|
||||
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
|
||||
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
|
||||
|
||||
/// Whether every adapter `options` could start offers a float depth buffer with a stencil. eframe
|
||||
/// makes the window's depth buffer from the depth bits before it picks the adapter, so the window
|
||||
/// asks for a float one only when whichever it picks has it.
|
||||
pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) -> bool {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { return false };
|
||||
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env();
|
||||
desc.backends = setup.instance_descriptor.backends;
|
||||
let instance = eframe::wgpu::Instance::new(desc);
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(setup.instance_descriptor.backends));
|
||||
let all = !adapters.is_empty() && adapters.iter().all(|a| a.features().contains(FLOAT_DEPTH_STENCIL));
|
||||
log::info!("graphics: float depth with stencil on every adapter: {all}");
|
||||
all
|
||||
}
|
||||
|
||||
/// The order backends are tried in on Windows.
|
||||
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
|
||||
|
||||
/// Whether `backends` (started on their own, nothing else) have a hardware adapter.
|
||||
pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
|
||||
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = backends;
|
||||
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
|
||||
let instance = eframe::wgpu::Instance::new(desc);
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
|
||||
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
|
||||
log::info!("graphics: {backends:?} adapters: {found:?}");
|
||||
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// The message shown when no window could be opened at all.
|
||||
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
|
||||
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
|
||||
format!(
|
||||
"SolidCraft could not open its window.\n\n{error}\n\n\
|
||||
The software renderer was tried too. Things to try:\n\
|
||||
- Install or update the graphics driver (on Windows, from the GPU maker's site rather than Windows Update).\n\
|
||||
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
|
||||
- Start it once with `solidcraft --safe-mode`.\n\
|
||||
- Rename the settings folder: {folder}\n\n\
|
||||
More: https://docs.solidcraft.io/guide/troubleshooting.html"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_read_from_the_environment() {
|
||||
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
|
||||
assert!(cpu_renderer_env(Some("cpu")) && cpu_renderer_env(Some("Software")) && !cpu_renderer_env(Some("gpu")) && !cpu_renderer_env(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_that_do_not_read_are_moved_aside_and_good_ones_stay() {
|
||||
let keys = ["solidcraft.prefs", "solvecraft.prefs"];
|
||||
let d = scratch("check");
|
||||
// Good files (and an empty one) stay where they are.
|
||||
std::fs::write(d.join("app.ron"), r#"{"solidcraft.prefs": "{\"grid\":true}", "egui": "(anything)"}"#).unwrap();
|
||||
std::fs::write(d.join("graphics.json"), r#"{"msaa":4}"#).unwrap();
|
||||
assert!(check_settings(&d, &keys).is_empty());
|
||||
assert!(d.join("app.ron").is_file() && d.join("graphics.json").is_file());
|
||||
// Damaged RON, preferences that aren't JSON, a truncated graphics.json, non-UTF-8 bytes.
|
||||
for (name, text) in [
|
||||
("app.ron", b"{\"solidcraft.prefs\": \"{\\\"gr".as_slice()),
|
||||
("app.ron", br#"{"solvecraft.prefs": "not json"}"#.as_slice()),
|
||||
("graphics.json", br#"{"msaa":"#.as_slice()),
|
||||
("graphics.json", b"\xff\xfe\x00".as_slice()),
|
||||
] {
|
||||
std::fs::write(d.join(name), text).unwrap();
|
||||
let notes = check_settings(&d, &keys);
|
||||
assert_eq!(notes.len(), 1, "{name}: {notes:?}");
|
||||
assert!(!d.join(name).exists(), "{name} moved");
|
||||
let kept: Vec<_> =
|
||||
std::fs::read_dir(&d).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(&format!("{name}.bad-"))).collect();
|
||||
assert!(kept.iter().any(|e| std::fs::read(e.path()).unwrap() == text), "{name}: the bad file is kept");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launches_that_never_got_a_window_are_counted_until_one_does() {
|
||||
let d = scratch("marker");
|
||||
assert_eq!(begin(&d), 0);
|
||||
assert_eq!(begin(&d), 1);
|
||||
assert_eq!(begin(&d), FAILED_STARTS_FOR_SAFE_MODE);
|
||||
started(&d);
|
||||
assert_eq!(begin(&d), 0);
|
||||
// A damaged marker counts as none.
|
||||
std::fs::write(d.join(MARKER), "zz").unwrap();
|
||||
assert_eq!(begin(&d), 0);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_starts_d3d12_first_and_vulkan_only_when_d3d12_has_no_gpu() {
|
||||
use eframe::wgpu::Backends;
|
||||
let probe = |with: &'static [Backends]| {
|
||||
move |b: Backends| {
|
||||
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
|
||||
with.contains(&b)
|
||||
}
|
||||
};
|
||||
// D3D12 works: Vulkan and OpenGL are never even asked about (starting Vulkan loads
|
||||
// its drivers, which is what crashed).
|
||||
let mut asked = Vec::new();
|
||||
let chosen = choose_backends(true, false, false, None, |b| {
|
||||
asked.push(b);
|
||||
b == Backends::DX12
|
||||
});
|
||||
assert_eq!(chosen, Some(Backends::DX12));
|
||||
assert_eq!(asked, [Backends::DX12]);
|
||||
// Everything works: still D3D12 alone.
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::DX12, Backends::VULKAN, Backends::GL])), Some(Backends::DX12));
|
||||
// No D3D12 hardware adapter: Vulkan, else OpenGL, else D3D12's software adapter (WARP).
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::VULKAN, Backends::GL])), Some(Backends::VULKAN));
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::GL])), Some(Backends::GL));
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[])), Some(Backends::DX12));
|
||||
// `auto` and blanks are the default order.
|
||||
assert_eq!(choose_backends(true, false, false, Some(" auto "), probe(&[Backends::DX12])), Some(Backends::DX12));
|
||||
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
|
||||
use eframe::wgpu::Backends;
|
||||
let never = |b: Backends| -> bool { panic!("no probing expected, asked about {b:?}") };
|
||||
// WGPU_BACKEND: wgpu reads it itself, nothing is probed.
|
||||
assert_eq!(choose_backends(true, true, false, Some("gl"), never), None);
|
||||
assert_eq!(choose_backends(true, true, true, None, never), None);
|
||||
// The software renderer (safe mode, the retry): WARP through D3D12 alone, never Vulkan.
|
||||
assert_eq!(choose_backends(true, false, true, Some("vulkan"), never), Some(Backends::DX12));
|
||||
assert_eq!(choose_backends(false, false, true, None, never), None);
|
||||
// The preference, on any system, without probing.
|
||||
assert_eq!(choose_backends(true, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
|
||||
assert_eq!(choose_backends(true, false, false, Some("GL"), never), Some(Backends::GL));
|
||||
assert_eq!(choose_backends(true, false, false, Some("dx12,vulkan"), never), Some(Backends::DX12 | Backends::VULKAN));
|
||||
assert_eq!(choose_backends(false, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
|
||||
// An unknown preference is the default order.
|
||||
assert_eq!(choose_backends(true, false, false, Some("glide"), |b| b == Backends::DX12), Some(Backends::DX12));
|
||||
// Elsewhere: wgpu's own choice.
|
||||
assert_eq!(choose_backends(false, false, false, None, never), None);
|
||||
assert_eq!(choose_backends(false, false, false, Some("auto"), never), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probing_a_backend_alone_never_panics() {
|
||||
// Whatever this machine has (often nothing): an answer, not a crash.
|
||||
for b in WINDOWS_ORDER {
|
||||
let _ = has_hardware_adapter(b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_names_the_settings_folder_and_safe_mode() {
|
||||
let m = no_window_message("no adapter", Some(Path::new("/x/SolidCraft")));
|
||||
assert!(m.contains("no adapter") && m.contains("/x/SolidCraft") && m.contains("--safe-mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
|
||||
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
|
||||
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
|
||||
if adapters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let picked = pick_cpu_adapter(&adapters, None).unwrap();
|
||||
if adapters.iter().any(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu) {
|
||||
assert_eq!(picked.get_info().device_type, eframe::wgpu::DeviceType::Cpu);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//! Keeping the restored window reachable.
|
||||
//!
|
||||
//! The window comes back where it was left. After a monitor was unplugged, the scale changed or
|
||||
//! the settings came from a larger screen, that can put its title bar above the top edge or
|
||||
//! under the taskbar, out of reach of the mouse. [`fit`] pulls a window back into the monitor's
|
||||
//! work area (the screen without the taskbar), in whatever unit the caller measures both, as
|
||||
//! long as it is the same one.
|
||||
|
||||
/// A rectangle: left, top, width, height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Area {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Area {
|
||||
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Area { x, y, w, h }
|
||||
}
|
||||
fn right(self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
fn bottom(self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
fn sane(self) -> bool {
|
||||
[self.x, self.y, self.w, self.h].iter().all(|v| v.is_finite()) && self.w > 0.0 && self.h > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the title bar must stay over the work area at least, to grab it.
|
||||
pub const REACHABLE: f32 = 100.0;
|
||||
/// The size a window gets when its saved geometry is unusable, before it is fitted to the screen.
|
||||
pub const DEFAULT_SIZE: (f32, f32) = (1600.0, 1000.0);
|
||||
|
||||
/// `window` moved and shrunk so that it can be reached on `work`: no larger than the work area
|
||||
/// (but not below `min`), the title bar not above the work area's top, the window not below its
|
||||
/// bottom, and at least [`REACHABLE`] of it over the work area horizontally. A window that is
|
||||
/// fine is returned unchanged. Geometry that makes no sense (not finite, empty), or a work
|
||||
/// area that does not, gives the default size centred on the work area.
|
||||
pub fn fit(window: Area, work: Area, min: (f32, f32)) -> Area {
|
||||
if !work.sane() {
|
||||
return window;
|
||||
}
|
||||
let min = (min.0.min(work.w), min.1.min(work.h));
|
||||
if !window.sane() {
|
||||
let w = DEFAULT_SIZE.0.min(work.w * 0.9).max(min.0);
|
||||
let h = DEFAULT_SIZE.1.min(work.h * 0.9).max(min.1);
|
||||
return Area::new(work.x + (work.w - w) / 2.0, work.y + (work.h - h) / 2.0, w, h);
|
||||
}
|
||||
let w = window.w.clamp(min.0, work.w);
|
||||
let h = window.h.clamp(min.1, work.h);
|
||||
let reach = REACHABLE.min(w);
|
||||
// A window that had to shrink goes fully onto the screen; one that fits may keep straddling two.
|
||||
let x = if w < window.w { window.x.max(work.x).min(work.right() - w) } else { window.x.max(work.x + reach - w).min(work.right() - reach) };
|
||||
let y = window.y.min(work.bottom() - h).max(work.y);
|
||||
Area::new(x, y, w, h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WORK: Area = Area::new(0.0, 0.0, 2560.0, 1400.0);
|
||||
const MIN: (f32, f32) = (960.0, 600.0);
|
||||
|
||||
#[test]
|
||||
fn a_window_on_screen_is_left_alone() {
|
||||
let w = Area::new(300.0, 120.0, 1600.0, 1000.0);
|
||||
assert_eq!(fit(w, WORK, MIN), w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_bar_above_the_top_or_under_the_taskbar_comes_back() {
|
||||
let fitted = fit(Area::new(300.0, -400.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!((fitted.y, fitted.h), (0.0, 1000.0));
|
||||
let fitted = fit(Area::new(300.0, 1300.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(fitted.y + fitted.h, 1400.0);
|
||||
// A taskbar at the top moves the work area down.
|
||||
let below_bar = Area::new(0.0, 48.0, 2560.0, 1352.0);
|
||||
assert_eq!(fit(Area::new(300.0, 0.0, 1600.0, 1000.0), below_bar, MIN).y, 48.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_from_a_monitor_that_is_gone_keeps_a_grabbable_part_on_screen() {
|
||||
let right = fit(Area::new(5000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(right.x, 2560.0 - REACHABLE);
|
||||
let left = fit(Area::new(-4000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(left.x + left.w, REACHABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_larger_than_the_screen_shrinks_to_it() {
|
||||
let small = Area::new(0.0, 0.0, 1280.0, 720.0);
|
||||
let fitted = fit(Area::new(-20.0, -30.0, 3200.0, 2000.0), small, MIN);
|
||||
assert_eq!(fitted, small);
|
||||
// Never below the minimum, unless the screen itself is smaller.
|
||||
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), WORK, MIN).w, 960.0);
|
||||
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), Area::new(0.0, 0.0, 800.0, 500.0), MIN).h, 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_geometry_is_centred_at_a_sensible_size() {
|
||||
for bad in [Area::new(f32::NAN, 0.0, 1600.0, 1000.0), Area::new(0.0, 0.0, 0.0, 0.0), Area::new(0.0, 0.0, f32::INFINITY, 10.0)] {
|
||||
let fitted = fit(bad, WORK, MIN);
|
||||
assert_eq!(fitted, Area::new(480.0, 200.0, 1600.0, 1000.0));
|
||||
}
|
||||
let small = Area::new(100.0, 50.0, 1366.0, 728.0);
|
||||
let fitted = fit(Area::new(f32::NAN, 0.0, 1.0, 1.0), small, MIN);
|
||||
assert!(fitted.w <= small.w && fitted.h <= small.h && fitted.x >= small.x && fitted.y >= small.y);
|
||||
// No work area to go by: nothing to change.
|
||||
let w = Area::new(1.0, 2.0, 3.0, 4.0);
|
||||
assert_eq!(fit(w, Area::new(0.0, 0.0, 0.0, 0.0), MIN), w);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
# Resource work — mandatory
|
||||
|
||||
Read `assets/README.md` before changing media, fonts, artwork or their consumers.
|
||||
For **any UI icon, icon assignment or review-tool change**, also read
|
||||
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md` before editing.
|
||||
These files are the specification; chat history and old previews are not required.
|
||||
|
||||
- Keep one editable authority per resource. UI SVGs are masters, not exports of a
|
||||
second competing drawing library. `source/` retains genuine native source and
|
||||
the separately compiled Rust viewport glyphs.
|
||||
- Keep only production inputs, necessary shipped exports, authoring tools and
|
||||
provenance here. Put screenshots, contact sheets, audits, caches, test output,
|
||||
review inbox files and package staging under ignored `target/`.
|
||||
- Do not add historical alternatives, duplicate resolutions that packaging does
|
||||
not consume, copied screenshots, `node_modules`, Python bytecode or generated
|
||||
inventory CSVs to this collection. Preserve native editable source documents.
|
||||
- Add an exact `ATTRIBUTION.md` row for every non-Markdown asset, including tool
|
||||
code, web UI sources and metadata. Remove rows for intentionally retired files.
|
||||
- Never copy or derive iconography from commercial CAD products. Use original
|
||||
SolidCraft artwork, analytic geometry and the approved local references.
|
||||
- Run `cargo xtask assets` and `cargo xtask icons`. Full repository gates still
|
||||
apply before a commit. Never refresh acceptance hashes to bypass a failed check.
|
||||
@@ -13,9 +13,7 @@ assets/
|
||||
macos/exports/ # paired Assets.car and icon.icns
|
||||
windows/exports/ # executable DIB ICO and installer PNG ICO
|
||||
linux/exports/hicolor/ # Linux/FreeBSD package images, 16–512 px
|
||||
ui/design/svg/ # authoritative original UI icon masters
|
||||
ui/design/tools/ # authoring bases, checks and included review app
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
ui/source/ # original drawing library and other UI glyphs
|
||||
branding/web/source/og.png # shared social preview card
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
@@ -27,15 +25,14 @@ assets/
|
||||
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
|
||||
archive/icons/macos-v1/
|
||||
source/icon.vectorcraft # earlier editable work, retained
|
||||
# redundant historical exports are retired
|
||||
exports/ # all historical SVG, PNG and iconset exports
|
||||
```
|
||||
|
||||
## Editing and regeneration
|
||||
|
||||
[UI artwork](icons/ui/README.md) identifies the drawing masters and their consumers.
|
||||
Read the mandatory [UI style guide](icons/ui/STYLE_GUIDE.md) before editing an icon.
|
||||
Edit SVG geometry and its command/UI assignments together; inspect both themes
|
||||
and small sizes. Temporary media and generated reports belong in ignored target/. Palettes and model presets are also editable Rust data.
|
||||
Edit geometry and IDs together in the original Rust source and review changes in
|
||||
both application themes. Palettes and model presets are also editable Rust data.
|
||||
|
||||
The current macOS master is `icons/app/macos/source/SolidCraft.icon/`, including
|
||||
its linked SVG layers and appearance settings. The earlier VectorCraft document
|
||||
@@ -78,13 +75,13 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
|
||||
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
|
||||
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
|
||||
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
|
||||
| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
|
||||
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
|
||||
|
||||
## Code and provenance
|
||||
|
||||
Resources remain compiled by their owning layer through existing APIs:
|
||||
`ui-egui::icons::paint_icon` and `Tokens` at L5, model presets in L2 `doc`, and sketch
|
||||
`ui-egui::icons::paint` and `Tokens` at L5, model presets in L2 `doc`, and sketch
|
||||
fonts at L1. State, projection, interaction, commands and GPU bindings stay in
|
||||
their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
|
||||
needs no runtime resource registry or repository lookup.
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
//! Original starter visual data, MIT OR Apache-2.0. No external material library.
|
||||
use crate::appearance_pack::{Category, GlassBoundary, GlassRecipe, OpaqueRecipe, TextureAxis, TextureRecipe, VisualPreset};
|
||||
|
||||
const WOOD: &str = "Uniform opaque preview only; grain resources/mapping are not applied";
|
||||
const SOLAR: &str = "Uniform opaque preview only; no cell grid, busbars, electricity or efficiency claim";
|
||||
const REGOLITH: &str = "Uniform opaque preview only; no granular displacement or measured lunar optical data";
|
||||
const OPAQUE: &str = "Illustrative opaque isotropic PBR; no measured alloy, polymer or engineering properties";
|
||||
|
||||
const fn look(color: [u8; 3], metallic: f64, roughness: f64, dielectric_ior: f64) -> OpaqueRecipe {
|
||||
OpaqueRecipe { color, metallic, roughness, dielectric_ior }
|
||||
}
|
||||
const fn maps(id: &'static str, repeat_mm: [f64; 2], grain: bool) -> Option<TextureRecipe> {
|
||||
Some(TextureRecipe { id, repeat_mm, grain_axis: if grain { Some(TextureAxis::V) } else { None } })
|
||||
}
|
||||
const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f64, boundary: GlassBoundary) -> GlassRecipe {
|
||||
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
|
||||
}
|
||||
|
||||
pub const STARTER_PACK: [VisualPreset; 40] = [
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.oak-honey",
|
||||
"Wood - Honey Oak Uniform Preview",
|
||||
Category::Wood,
|
||||
look([190, 145, 88], 0.0, 0.64, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.oak-honey.maps-v1", [120.0, 240.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.oak-smoked",
|
||||
"Wood - Smoked Oak Uniform Preview",
|
||||
Category::Wood,
|
||||
look([91, 72, 55], 0.0, 0.68, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.oak-smoked.maps-v1", [120.0, 240.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.walnut-dark",
|
||||
"Wood - Dark Walnut Uniform Preview",
|
||||
Category::Wood,
|
||||
look([99, 66, 43], 0.0, 0.60, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.walnut-dark.maps-v1", [100.0, 220.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.maple-light",
|
||||
"Wood - Light Maple Uniform Preview",
|
||||
Category::Wood,
|
||||
look([221, 192, 141], 0.0, 0.57, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.maple-light.maps-v1", [100.0, 200.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.cherry-warm",
|
||||
"Wood - Warm Cherry Uniform Preview",
|
||||
Category::Wood,
|
||||
look([164, 99, 65], 0.0, 0.58, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.cherry-warm.maps-v1", [110.0, 220.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.ash-pale",
|
||||
"Wood - Pale Ash Uniform Preview",
|
||||
Category::Wood,
|
||||
look([209, 186, 146], 0.0, 0.70, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.ash-pale.maps-v1", [120.0, 260.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.birch-cream",
|
||||
"Wood - Cream Birch Uniform Preview",
|
||||
Category::Wood,
|
||||
look([228, 206, 163], 0.0, 0.63, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.birch-cream.maps-v1", [100.0, 200.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.teak-golden",
|
||||
"Wood - Golden Teak Uniform Preview",
|
||||
Category::Wood,
|
||||
look([167, 122, 66], 0.0, 0.61, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.teak-golden.maps-v1", [110.0, 250.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.carbon-steel-polished",
|
||||
"Metal - Polished Carbon Steel",
|
||||
Category::Metal,
|
||||
look([161, 166, 173], 1.0, 0.16, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.stainless-soft-satin",
|
||||
"Metal - Soft Satin Stainless",
|
||||
Category::Metal,
|
||||
look([187, 193, 201], 1.0, 0.36, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.aluminum-bead-blast",
|
||||
"Metal - Bead Blast Aluminum",
|
||||
Category::Metal,
|
||||
look([195, 201, 210], 1.0, 0.58, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.aluminum-silver-polish",
|
||||
"Metal - Silver Polish Aluminum",
|
||||
Category::Metal,
|
||||
look([218, 224, 232], 1.0, 0.13, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.copper-aged-warm",
|
||||
"Metal - Warm Aged Copper",
|
||||
Category::Metal,
|
||||
look([171, 95, 57], 1.0, 0.48, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.brass-champagne",
|
||||
"Metal - Champagne Satin Brass",
|
||||
Category::Metal,
|
||||
look([205, 173, 102], 1.0, 0.32, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.bronze-dark-cast",
|
||||
"Metal - Dark Cast Bronze",
|
||||
Category::Metal,
|
||||
look([133, 95, 58], 1.0, 0.66, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.titanium-matte",
|
||||
"Metal - Matte Gray Titanium",
|
||||
Category::Metal,
|
||||
look([147, 149, 157], 1.0, 0.53, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.gold-warm",
|
||||
"Metal - Warm Reflective Gold",
|
||||
Category::Metal,
|
||||
look([235, 190, 82], 1.0, 0.18, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.silver-cool",
|
||||
"Metal - Cool Reflective Silver",
|
||||
Category::Metal,
|
||||
look([225, 230, 237], 1.0, 0.12, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.abs-charcoal",
|
||||
"Plastic - Charcoal ABS",
|
||||
Category::Plastic,
|
||||
look([43, 45, 48], 0.0, 0.44, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.abs-warm-white",
|
||||
"Plastic - Warm White ABS",
|
||||
Category::Plastic,
|
||||
look([232, 229, 220], 0.0, 0.40, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque("starter.plastic.pla-coral", "Plastic - Coral PLA", Category::Plastic, look([218, 99, 83], 0.0, 0.43, 1.5), OPAQUE, None),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.petg-cyan-opaque",
|
||||
"Plastic - Opaque Cyan PETG",
|
||||
Category::Plastic,
|
||||
look([36, 148, 167], 0.0, 0.29, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.nylon-ivory",
|
||||
"Plastic - Ivory Nylon",
|
||||
Category::Plastic,
|
||||
look([220, 213, 190], 0.0, 0.62, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.pc-graphite-opaque",
|
||||
"Plastic - Opaque Graphite PC",
|
||||
Category::Plastic,
|
||||
look([70, 76, 84], 0.0, 0.27, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.hdpe-natural-opaque",
|
||||
"Plastic - Opaque Natural HDPE",
|
||||
Category::Plastic,
|
||||
look([221, 222, 207], 0.0, 0.48, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque("starter.plastic.pom-black", "Plastic - Black POM", Category::Plastic, look([29, 31, 35], 0.0, 0.32, 1.5), OPAQUE, None),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.tpu-matte-black",
|
||||
"Plastic - Matte Black TPU",
|
||||
Category::Plastic,
|
||||
look([31, 30, 32], 0.0, 0.83, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.peek-amber-opaque",
|
||||
"Plastic - Opaque Amber PEEK",
|
||||
Category::Plastic,
|
||||
look([157, 119, 62], 0.0, 0.51, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::glass(
|
||||
"starter.glass.clear-soda-lime",
|
||||
"Glass Recipe - Clear Soda Lime",
|
||||
glass([0.92, 0.98, 0.96], 1.52, 3.0, GlassBoundary::Closed),
|
||||
),
|
||||
VisualPreset::glass("starter.glass.low-iron-clear", "Glass Recipe - Low Iron Clear", glass([0.98, 0.99, 1.0], 1.50, 4.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass("starter.glass.smoky-neutral", "Glass Recipe - Smoky Neutral", glass([0.35, 0.38, 0.42], 1.52, 3.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass("starter.glass.amber-tint", "Glass Recipe - Amber Tint", glass([0.84, 0.38, 0.08], 1.51, 3.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass("starter.glass.cobalt-blue", "Glass Recipe - Cobalt Blue", glass([0.06, 0.14, 0.76], 1.53, 3.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass(
|
||||
"starter.glass.thin-clear",
|
||||
"Glass Recipe - Thin Clear Sheet",
|
||||
glass([0.95, 0.98, 0.99], 1.50, 2.0, GlassBoundary::Thin { thickness_mm: 2.0 }),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.solar.mono-blue",
|
||||
"Solar Panel - Mono Blue Uniform Preview",
|
||||
Category::Solar,
|
||||
look([20, 41, 85], 0.0, 0.20, 1.5),
|
||||
SOLAR,
|
||||
maps("starter.solar.mono-blue.maps-v1", [160.0, 160.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.solar.thin-film-dark",
|
||||
"Solar Panel - Dark Thin Film Uniform Preview",
|
||||
Category::Solar,
|
||||
look([28, 33, 42], 0.0, 0.28, 1.5),
|
||||
SOLAR,
|
||||
maps("starter.solar.thin-film-dark.maps-v1", [100.0, 200.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.mare-charcoal",
|
||||
"Regolith - Mare Charcoal Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([56, 56, 55], 0.0, 0.97, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.mare-charcoal.maps-v1", [200.0, 200.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.highland-pale",
|
||||
"Regolith - Highland Pale Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([116, 113, 106], 0.0, 0.93, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.highland-pale.maps-v1", [200.0, 200.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.dust-neutral",
|
||||
"Regolith - Neutral Dust Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([86, 85, 81], 0.0, 1.0, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.dust-neutral.maps-v1", [150.0, 150.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.agglutinate-dark",
|
||||
"Regolith - Dark Agglutinate Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([66, 63, 60], 0.0, 0.89, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.agglutinate-dark.maps-v1", [200.0, 200.0], false),
|
||||
),
|
||||
];
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<filter id="filter-1" filterUnits="userSpaceOnUse" x="64.5" y="64.5" width="895" height="895" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="6.5"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#243047" flood-opacity="0.16"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<linearGradient id="linear-gradient-1" x1="512" y1="96" x2="512" y2="928" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fcfdfe"/>
|
||||
<stop offset="1" stop-color="#dce3ed"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M320 96 L704 96 C858 96 928 166 928 320 L928 704 C928 858 858 928 704 928 L320 928 C166 928 96 858 96 704 L96 320 C96 166 166 96 320 96 Z"/>
|
||||
</clipPath>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="568.512" gradientTransform="matrix(1 0 0 0.5 512 697.379)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#507eaa"/>
|
||||
<stop offset="0.3" stop-color="#507eaa" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#507eaa" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#507eaa" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="409.324" y1="139.358" x2="520.928" y2="511" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8dc4fc"/>
|
||||
<stop offset="1" stop-color="#5d9feb"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="264.239" y1="342.478" x2="442.805" y2="882.642" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#438feb"/>
|
||||
<stop offset="1" stop-color="#2b72cf"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-4" x1="610.212" y1="420.601" x2="732.976" y2="882.642" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#2869bd"/>
|
||||
<stop offset="1" stop-color="#1e4d98"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-5" x1="599.051" y1="431.761" x2="738.557" y2="588.007" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#83baf6"/>
|
||||
<stop offset="1" stop-color="#5597e6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-6" x1="599.051" y1="242.034" x2="660.434" y2="418.369" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#306cbc"/>
|
||||
<stop offset="1" stop-color="#22529c"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-2">
|
||||
<path d="M512 139.358 L672.71 231.99 L672.71 394.877 L694.445 406.957 L693.395 430.291 L833.42 511 L833.42 697.379 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 L190.58 325.737 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>SolidCraft macOS application icon</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<g filter="url(#filter-1)">
|
||||
<path id="Porcelain___legacy_macOS_silhouette" data-name="Porcelain • legacy macOS silhouette" d="M320 96 L704 96 C858 96 928 166 928 320 L928 704 C928 858 858 928 704 928 L320 928 C166 928 96 858 96 704 L96 320 C96 166 166 96 320 96 Z" fill="url(#linear-gradient-1)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Experiment___faint_origin_grid" data-name="Experiment • faint origin grid" clip-path="url(#clip-path-1)">
|
||||
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M80 -200.041 L944 297.959 M80 297.959 L944 -200.041 M80 -107.41 L944 390.59 M80 390.59 L944 -107.41 M80 -14.778 L944 483.222 M80 483.222 L944 -14.778 M80 77.853 L944 575.853 M80 575.853 L944 77.853 M80 170.485 L944 668.485 M80 668.485 L944 170.485 M80 263.116 L944 761.116 M80 761.116 L944 263.116 M80 355.748 L944 853.748 M80 853.748 L944 355.748 M80 541.01 L944 1039.01 M80 1039.01 L944 541.01 M80 633.642 L944 1131.642 M80 1131.642 L944 633.642 M80 726.273 L944 1224.273 M80 1224.273 L944 726.273 M80 818.905 L944 1316.905 M80 1316.905 L944 818.905 M80 911.536 L944 1409.536 M80 1409.536 L944 911.536 M80 1004.168 L944 1502.168 M80 1502.168 L944 1004.168" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.2" stroke-miterlimit="10" opacity="0.28"/>
|
||||
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M80 448.379 L944 946.379 M80 946.379 L944 448.379" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.6" stroke-miterlimit="10" opacity="0.28"/>
|
||||
</g>
|
||||
<g id="SolidCraft___machined_solid" data-name="SolidCraft • machined solid">
|
||||
<path id="Solid_silhouette" data-name="Solid silhouette" d="M512 139.358 L672.71 231.99 L672.71 418.369 L833.42 511 L833.42 697.379 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 L190.58 325.737 Z" fill="#2864b8"/>
|
||||
<path id="Top___extruded_profile" data-name="Top • extruded profile" d="M190.58 325.737 L512 139.358 L672.71 231.99 L512 325.737 L672.71 418.369 L512 511 Z" fill="url(#linear-gradient-2)"/>
|
||||
<path id="Left___stepped_face" data-name="Left • stepped face" d="M190.58 325.737 L512 511 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 Z" fill="url(#linear-gradient-3)"/>
|
||||
<path id="Right___milled_recess" data-name="Right • milled recess" d="M512 511 L672.71 418.369 L672.71 603.631 L833.42 511 L833.42 697.379 L512 882.642 Z" fill="url(#linear-gradient-4)"/>
|
||||
<path id="Top___lower_step" data-name="Top • lower step" d="M672.71 418.369 L833.42 511 L672.71 603.631 Z" fill="url(#linear-gradient-5)"/>
|
||||
<path id="Inner___upper_step" data-name="Inner • upper step" d="M672.71 231.99 L672.71 418.369 L512 325.737 Z" fill="url(#linear-gradient-6)"/>
|
||||
<g clip-path="url(#clip-path-2)">
|
||||
<path id="Cut_edges___original_SolidCraft_topology" data-name="Cut edges • original SolidCraft topology" d="M190.58 325.737 L512 511 L512 882.642 M512 511 L672.71 418.369 M672.71 231.99 L512 325.737 L672.71 418.369 L672.71 603.631 L833.42 511" fill="none" stroke="#edf5ff" stroke-width="23" stroke-miterlimit="10" opacity="0.88"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -1,93 +0,0 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||