mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
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",
|
||||
]
|
||||
+4
-103
@@ -1,26 +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),
|
||||
# except the GPU path tracer's tests (the gpu-path group below), which run in four shards of their own.
|
||||
# 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))
|
||||
)
|
||||
"""
|
||||
@@ -33,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.
|
||||
# CI runs these in their own job (GPU path tests in ci.yml), split into count partitions.
|
||||
[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,6 +0,0 @@
|
||||
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
|
||||
.gitattributes text eol=lf
|
||||
/assets/icons/ui/design/requested_icons.txt text eol=lf
|
||||
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
|
||||
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
|
||||
*.sc -linguist-detectable
|
||||
@@ -1,43 +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.
|
||||
Before reporting a boolean or modelling failure, please try to reproduce it on the latest nightly or a build from current `main`, and say which build you used.
|
||||
- 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.sc --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.
|
||||
Using an AI agent with SolidCraft's MCP server? The `solidcraft-reporting` skill in [`skills/`](https://github.com/tangency-labs/solidcraft/tree/main/skills) has it reproduce the problem in a fresh design and write these steps for you.
|
||||
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
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
Describe the problem and resulting behavior. Link the issue when applicable.
|
||||
|
||||
Changes a default: no
|
||||
|
||||
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
|
||||
Changes a default: yes — <what>, old behaviour kept behind preference <name>
|
||||
Docs-only and bot PRs are exempt. -->
|
||||
|
||||
## Validation
|
||||
|
||||
What did you run, and what were the results? Include expected and measured values for geometry changes.
|
||||
|
||||
+97
-20
@@ -1,18 +1,99 @@
|
||||
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.
|
||||
|
||||
**New app ID.** The app ID is now `io.tangencylabs.solidcraft` (it was `ai.storyteller.solidcraft`,
|
||||
and `ai.storyteller.solvecraft` up to 0.7): `flatpak update` offers to replace the old app, the
|
||||
`.deb`, `.rpm` and AppImage remove the old menu entry, and settings stay. On macOS the bundle ID
|
||||
changed too, so macOS asks once more for the permissions it had granted.
|
||||
**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
|
||||
menu entry.
|
||||
|
||||
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
|
||||
container the default save format. `.solvecraft` designs, old settings folders and
|
||||
@@ -42,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 install --user solidcraft io.tangencylabs.solidcraft
|
||||
flatpak run io.tangencylabs.solidcraft
|
||||
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 under an old ID (`ai.storyteller.solidcraft`, or `ai.storyteller.solvecraft` from 0.7 or
|
||||
earlier): `flatpak update --user` offers to replace it with `io.tangencylabs.solidcraft`, and settings move along.
|
||||
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`).
|
||||
|
||||
+75
-904
File diff suppressed because it is too large
Load Diff
@@ -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,42 +0,0 @@
|
||||
# Nightly import checks on real-world STEP files (#1397): the external corpus in
|
||||
# tests/external-corpus.toml is downloaded (hash-verified), imported headless, and checked
|
||||
# against tolerance-based expectations — body counts, total volume and area, time and memory
|
||||
# budgets. It is not part of `cargo xtask ci`: it downloads from the internet.
|
||||
#
|
||||
# Import results should improve over time (healing, edge merging, meshing). When a check fails
|
||||
# because the numbers moved for the better (or the source changed upstream), re-record with
|
||||
# `cargo xtask external-corpus --bless "why"` in a pull request, never by editing numbers.
|
||||
name: External corpus
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '40 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
only:
|
||||
description: "Run only this corpus entry name (empty = every entry)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
corpus:
|
||||
name: Import the external STEP corpus
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build the CLI
|
||||
run: cargo build --profile ci -p solidcraft-cli
|
||||
- name: Import and check every entry
|
||||
env:
|
||||
ONLY: ${{ inputs.only }}
|
||||
run: |
|
||||
if [ -z "$ONLY" ]; then
|
||||
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli
|
||||
else
|
||||
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli --only "$ONLY"
|
||||
fi
|
||||
@@ -1,68 +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`).
|
||||
# Pending files (assets/locales/pending, attribution/pending) are folded into the catalogs and
|
||||
# ATTRIBUTION.md by `cargo xtask fold-pending`.
|
||||
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/**"
|
||||
- "assets/locales/pending/*.json"
|
||||
- "attribution/pending/*.md"
|
||||
- ".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: Pending locale strings and attribution rows
|
||||
run: cargo xtask fold-pending
|
||||
- name: Commit if changed
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; 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 -A THIRD-PARTY-LICENSES.txt docs/features.md assets/locales ATTRIBUTION.md attribution
|
||||
git commit -m "Regenerate generated files and fold pending files [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: write # to list a PR's changed paths and add its label
|
||||
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:
|
||||
@@ -39,8 +39,6 @@ jobs:
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: Windows executable icon validation regression
|
||||
run: python3 packaging/tests/windows_icon_test.py
|
||||
- name: AppImage CLI wrapper regression
|
||||
run: python3 packaging/tests/apprun_test.py
|
||||
- name: Flatpak desktop smoke validation regression
|
||||
run: python3 packaging/tests/flatpak_test.py
|
||||
- name: PowerShell
|
||||
@@ -55,15 +53,13 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout assets/platforms/windows/solidcraft.wxs assets/platforms/windows/solidcraft.en-us.wxl assets/platforms/macos/*.plist* assets/platforms/linux/*.xml*
|
||||
desktop-file-validate assets/platforms/linux/io.tangencylabs.solidcraft.desktop
|
||||
# The window's X11 class is its app id; the old ids stay only as the rename shims.
|
||||
grep -qx 'StartupWMClass=io.tangencylabs.solidcraft' assets/platforms/linux/io.tangencylabs.solidcraft.desktop
|
||||
grep -q '<id>ai.storyteller.solidcraft</id>' assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in
|
||||
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in
|
||||
grep -qx 'X-Flatpak-RenamedFrom=ai.storyteller.solidcraft.desktop;ai.storyteller.solvecraft.desktop;' assets/platforms/linux/io.tangencylabs.solidcraft.desktop
|
||||
desktop-file-validate assets/platforms/linux/ai.storyteller.solidcraft.desktop
|
||||
# The window's X11 class is its app id; the old id stays only as the rename shims.
|
||||
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' assets/platforms/linux/ai.storyteller.solidcraft.desktop
|
||||
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
|
||||
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/io.tangencylabs.solidcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/io.tangencylabs.solidcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/io.tangencylabs.solidcraft.bundle.yml')); assert m['id'] == 'io.tangencylabs.solidcraft' and m['command'] == 'solidcraft'"
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
|
||||
python3 -m py_compile packaging/linux/snap-smoke.py
|
||||
|
||||
@@ -1,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,25 +45,17 @@ 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
|
||||
run: |
|
||||
mkdir -p site
|
||||
cp -r target/book/. site/
|
||||
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
|
||||
cp -r dist/web site/app
|
||||
- uses: actions/configure-pages@v5
|
||||
id: pages
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Two quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
|
||||
# for an issue a lower-numbered one already closes, and the "Changes a default:" line is filled in.
|
||||
# Neither is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
|
||||
name: PR checks
|
||||
|
||||
on:
|
||||
# pull_request, never pull_request_target: nothing here checks out or runs the PR's code, and the
|
||||
# token is read-only for fork PRs. Only scripts/pr-checks from the PR's own merge commit is run.
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
duplicate:
|
||||
name: Duplicate PR check
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/pr-checks
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const checks = require('./scripts/pr-checks/pr-checks.js');
|
||||
const r = await checks.checkDuplicate({ github, context, core });
|
||||
core.info(JSON.stringify(r));
|
||||
|
||||
defaults:
|
||||
name: Defaults line check
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/pr-checks
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const checks = require('./scripts/pr-checks/pr-checks.js');
|
||||
const r = await checks.checkDefaults({ github, context, core });
|
||||
core.info(JSON.stringify(r));
|
||||
|
||||
# Labelling needs write access, which a fork PR's token never has, so it is its own job and best
|
||||
# effort: a fork PR just doesn't get the `changes-default` label from here.
|
||||
label:
|
||||
name: Label changes-default
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/pr-checks
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const checks = require('./scripts/pr-checks/pr-checks.js');
|
||||
await checks.syncDefaultLabel({ github, context, core });
|
||||
+11
-258
@@ -4,15 +4,11 @@
|
||||
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
|
||||
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release.
|
||||
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
|
||||
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
|
||||
# (see the windows job). Nothing else is signed yet.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
@@ -21,8 +17,6 @@
|
||||
# releases/latest/download/<name> links in the README keep working across releases.
|
||||
#
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
# A dry run from any branch also test-signs the Windows files when SIGNPATH_API_TOKEN exists:
|
||||
# gh workflow run release.yml --ref <branch> -f draft=false [-f signpath_signing_policy=test-signing]
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -37,10 +31,6 @@ on:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
signpath_signing_policy:
|
||||
description: "SignPath signing policy for the Windows files (empty: the repository variable, else test-signing)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -207,53 +197,12 @@ jobs:
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
arch:
|
||||
name: Arch Linux ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x86_64, aarch64]
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
# The Craft apps' shared Arch packaging (storytold/craft-common-actions, MIT OR Apache-2.0):
|
||||
# makepkg in an archlinux:base-devel container. The package is named after the tarball's
|
||||
# .desktop file (io.tangencylabs.solidcraft) and written as
|
||||
# dist/release/solidcraft-<version>-linux-<arch>.pkg.tar.zst.
|
||||
- uses: storytold/craft-common-actions/actions/arch-package@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 # main
|
||||
with:
|
||||
tarball: dist/linux/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.tar.gz
|
||||
arch: ${{ matrix.arch }}
|
||||
- name: Check the package
|
||||
env:
|
||||
PKG: dist/release/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.pkg.tar.zst
|
||||
run: |
|
||||
tar --zstd -tf "$PKG" | grep -E '^usr/bin/solidcraft$|^usr/bin/solidcraft-cli$|^usr/share/applications/io\.tangencylabs\.solidcraft\.desktop$'
|
||||
tar --zstd -xOf "$PKG" .PKGINFO | grep -E '^(pkgname|pkgver|arch) = '
|
||||
tar --zstd -xOf "$PKG" .PKGINFO | grep -qx 'pkgname = io.tangencylabs.solidcraft'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-arch-${{ matrix.arch }}
|
||||
path: dist/release/*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
|
||||
snap:
|
||||
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 }}
|
||||
@@ -337,26 +286,18 @@ jobs:
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# The two halves of the universal binary build in parallel on their own runners (each alone
|
||||
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
|
||||
macos-build:
|
||||
name: macOS build ${{ matrix.arch }}
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: aarch64, target: aarch64-apple-darwin }
|
||||
- { arch: x86_64, target: x86_64-apple-darwin }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -364,58 +305,12 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-${{ matrix.arch }}
|
||||
- name: Build
|
||||
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
|
||||
# Not named release-*: the release job collects every release-* artifact as an asset.
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: macos-bin-${{ matrix.arch }}
|
||||
path: |
|
||||
target/${{ matrix.target }}/release/solidcraft
|
||||
target/${{ matrix.target }}/release/solidcraft-cli
|
||||
target/${{ matrix.target }}/release/solvecraft-cli
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: [version, macos-build]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: macos-bin-aarch64
|
||||
path: target/aarch64-apple-darwin/release
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: macos-bin-x86_64
|
||||
path: target/x86_64-apple-darwin/release
|
||||
- name: Set up Developer ID signing and notarization
|
||||
# Optional until the APPLE_* secrets exist (docs/releasing.md, "macOS signing"). Without
|
||||
# them (forks, dry runs) the app keeps its ad-hoc signature and is not notarized.
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
run: packaging/macos/signing.sh --setup
|
||||
- name: Join, sign, package and check (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: |
|
||||
# Artifacts don't keep the executable bit.
|
||||
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
|
||||
packaging/macos/package.sh --arch universal --skip-build ${MACOS_SIGN_IDENTITY:+--sign}
|
||||
- name: Remove the signing keychain and API key
|
||||
if: always()
|
||||
run: packaging/macos/signing.sh --cleanup
|
||||
key: release-macos-universal
|
||||
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
@@ -426,14 +321,7 @@ jobs:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
|
||||
# only signs builds whose jobs all ran on GitHub-hosted runners.
|
||||
runs-on: windows-latest
|
||||
# actions: read lets the SignPath action read this job's details and fetch the uploaded
|
||||
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -444,16 +332,6 @@ jobs:
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
# Authenticode signing through SignPath Foundation (docs/releasing.md, "Windows signing").
|
||||
# Only when the API token secret exists: forks and repositories without it build unsigned,
|
||||
# exactly as before. `if:` can't read secrets, so the check goes through this variable.
|
||||
SIGN_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
|
||||
# The one switch between the self-signed test certificate (test-signing) and the real one
|
||||
# (release-signing): a dispatch input, else the SIGNPATH_SIGNING_POLICY repository variable,
|
||||
# else test-signing.
|
||||
SIGNPATH_SIGNING_POLICY: ${{ inputs.signpath_signing_policy || vars.SIGNPATH_SIGNING_POLICY || 'test-signing' }}
|
||||
SIGNPATH_ORGANIZATION_ID: fa603ff4-c702-404b-8588-a524371c7a4e
|
||||
SIGNPATH_PROJECT_SLUG: solidcraft
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -472,109 +350,10 @@ jobs:
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS != 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
|
||||
# ---- signed path ---------------------------------------------------------------------------
|
||||
# SignPath's GitHub integration (docs.signpath.io/trusted-build-systems/github): the files go
|
||||
# up as a workflow artifact, SignPath fetches that artifact itself (so it can verify it came
|
||||
# from this workflow run), signs it, and the action downloads the signed copy. GitHub stores
|
||||
# artifacts as zips, so packaging/windows/signpath-artifact-configuration.xml (pasted into the
|
||||
# SignPath project) describes a zip holding the files at its top level.
|
||||
# Two rounds: the .exe files first, so the MSI, the portable zip and the plain .exe all carry
|
||||
# signed binaries; then the MSI built from them.
|
||||
- name: Build and stage the binaries for signing
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
|
||||
- name: Upload the unsigned binaries
|
||||
id: unsigned-exes
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signpath-unsigned-exes-windows-${{ matrix.arch }}
|
||||
path: target/windows-package/${{ matrix.arch }}/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Sign the binaries (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
github-artifact-id: ${{ steps.unsigned-exes.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
# release-signing requests wait for a person to approve them in SignPath; give that an
|
||||
# hour instead of the default 10 minutes. test-signing requests finish in seconds.
|
||||
wait-for-completion-timeout-in-seconds: 3600
|
||||
output-artifact-directory: target/windows-package/signed-exes-${{ matrix.arch }}
|
||||
- name: Put the signed binaries in place
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$stage = "target/windows-package/${{ matrix.arch }}"
|
||||
foreach ($exe in 'solidcraft.exe', 'solidcraft-cli.exe', 'solvecraft-cli.exe') {
|
||||
$signed = "target/windows-package/signed-exes-${{ matrix.arch }}/$exe"
|
||||
# The artifact configuration matches each file optionally, so check that every file
|
||||
# actually came back signed rather than trusting the request's success.
|
||||
$sig = Get-AuthenticodeSignature $signed
|
||||
if (-not $sig.SignerCertificate) { throw "$exe came back from SignPath without a signature ($($sig.Status))" }
|
||||
# The test certificate is self-signed, so Status is not Valid until release-signing.
|
||||
Write-Output "$exe signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
|
||||
Copy-Item -Force $signed "$stage/$exe"
|
||||
}
|
||||
- name: Package the signed binaries (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
|
||||
- name: Upload the unsigned MSI
|
||||
id: unsigned-msi
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signpath-unsigned-msi-windows-${{ matrix.arch }}
|
||||
path: dist/release/*.msi
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Sign the MSI (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
wait-for-completion-timeout-in-seconds: 3600
|
||||
output-artifact-directory: target/windows-package/signed-msi-${{ matrix.arch }}
|
||||
- name: Put the signed MSI in place
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = Get-Item "dist/release/*.msi"
|
||||
$signed = "target/windows-package/signed-msi-${{ matrix.arch }}/$($msi.Name)"
|
||||
$sig = Get-AuthenticodeSignature $signed
|
||||
if (-not $sig.SignerCertificate) { throw "$($msi.Name) came back from SignPath without a signature ($($sig.Status))" }
|
||||
Write-Output "$($msi.Name) signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
|
||||
Copy-Item -Force $signed $msi.FullName
|
||||
"Windows ${{ matrix.arch }}: Authenticode-signed through SignPath (policy $env:SIGNPATH_SIGNING_POLICY, $($sig.SignerCertificate.Subject))." >> $env:GITHUB_STEP_SUMMARY
|
||||
- name: Note the unsigned build
|
||||
if: env.SIGN_WINDOWS != 'true'
|
||||
shell: pwsh
|
||||
run: '"Windows ${{ matrix.arch }}: unsigned (no SIGNPATH_API_TOKEN secret)." >> $env:GITHUB_STEP_SUMMARY'
|
||||
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
@@ -611,7 +390,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
|
||||
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -637,7 +416,7 @@ jobs:
|
||||
shopt -s nullglob
|
||||
for f in "solidcraft-$VERSION-"*; do
|
||||
case "$f" in
|
||||
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap | *.pkg.tar.zst)
|
||||
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
|
||||
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
|
||||
esac
|
||||
done
|
||||
@@ -650,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,11 +7,3 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
.vscode/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
# external corpus downloads (cargo xtask external-corpus, #1397): fetched at test time,
|
||||
# hash-pinned in tests/external-corpus.toml; never commit case data
|
||||
/corpus/
|
||||
|
||||
@@ -1,47 +1,147 @@
|
||||
# SolidCraft: instructions for agents
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
|
||||
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.
|
||||
|
||||
## Start here
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
|
||||
## 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.
|
||||
|
||||
## Hard rules (what enforces each)
|
||||
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
|
||||
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
|
||||
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
|
||||
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
|
||||
- Sorted lists (message catalogs, `mod` lines, languages) stay sorted: `cargo xtask lists`. Never edit generated files (`docs/features.md`, `THIRD-PARTY-LICENSES.txt`) in a PR.
|
||||
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
|
||||
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
|
||||
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
|
||||
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
|
||||
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
|
||||
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
|
||||
- Show observable changes with real screenshots or a recording, captured yourself.
|
||||
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
|
||||
## 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.
|
||||
|
||||
## Working on X: read the skill first
|
||||
| Working on | Read |
|
||||
|---|---|
|
||||
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
|
||||
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
|
||||
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
|
||||
| A dialog, panel, camera, icon, colour | `skills/solidcraft-ui/SKILL.md` |
|
||||
| Labels, messages, translations | `skills/solidcraft-ui-text/SKILL.md` |
|
||||
| Saving something new in a design, import, export | `skills/solidcraft-file-formats/SKILL.md` |
|
||||
| Preferences, a changed default | `skills/solidcraft-preferences/SKILL.md` |
|
||||
| Writing or running tests | `skills/solidcraft-tests/SKILL.md` |
|
||||
| Slow work, the window thread, wasm | `skills/solidcraft-performance/SKILL.md` |
|
||||
| Releases, installers, packages | `skills/solidcraft-packaging/SKILL.md` |
|
||||
| Running the app, control channel, MCP tools | `skills/solidcraft-agent-tools/SKILL.md` |
|
||||
| Building a part over MCP | `skills/solidcraft-modeling/SKILL.md` |
|
||||
| Changing an existing design | `skills/solidcraft-editing/SKILL.md` |
|
||||
| A bug report, a crash, a wrong result | `skills/solidcraft-reporting/SKILL.md` |
|
||||
| Checking, porting, designs in git | `skills/solidcraft-checking/SKILL.md` |
|
||||
| Assemblies, joints, motion | `skills/solidcraft-mechanisms/SKILL.md` |
|
||||
`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 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/*`
|
||||
(`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`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
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.
|
||||
|
||||
+33
-1033
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
+43
-242
@@ -1,10 +1,8 @@
|
||||
# Contributing to SolidCraft
|
||||
|
||||
Agents: start from the router in [AGENTS.md](AGENTS.md), which points to a short skill in [`skills/`](skills/README.md) for each kind of work. This page stays the full human guide.
|
||||
|
||||
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
|
||||
You sketch on a plane, constrain the sketch, turn profiles into solids, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, macOS, Linux), in
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
|
||||
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
|
||||
model with.
|
||||
|
||||
@@ -12,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](docs/roadmap-detail.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)
|
||||
@@ -83,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.
|
||||
@@ -115,39 +98,22 @@ 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`.
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny, and a PR that
|
||||
changes Cargo.lock or a Cargo.toml checks the advisories; see
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
4. **Never crash.** People trust a CAD app with their work.
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
|
||||
`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/` with input and output schemas (see
|
||||
[Adding a command](docs/book/src/dev/commands.md#adding-a-command); a test warns about a
|
||||
command without them, and will fail it once required), engine tests that measure the result (volume, area,
|
||||
`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).
|
||||
A command on the toolbar also ships with its help tooltip (step 6 of Adding a command).
|
||||
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.
|
||||
@@ -160,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.
|
||||
|
||||
@@ -192,42 +152,21 @@ no LTO, and debug assertions off (truck has debug-only assertions that reject va
|
||||
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
|
||||
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
|
||||
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
|
||||
`cargo xtask test -- shell`. Set `SOLIDCRAFT_TEST_JOBS` to a positive integer to opt into a
|
||||
shared bound on top-level test/side commands for `cargo xtask test` and `cargo xtask ci`.
|
||||
For example, `SOLIDCRAFT_TEST_JOBS=2` allows two commands at once; the sequential wasm/doc
|
||||
worker consumes one of those slots. Every binary and side check still runs after failures.
|
||||
Without the variable the existing concurrency is unchanged. With it set, each test binary
|
||||
inherits `RUST_TEST_THREADS` and `ACTION_MATRIX_JOBS` of `max(1, cpus / jobs)` — the machine's
|
||||
parallelism shared between the binaries in flight — unless you set either one explicitly;
|
||||
explicit caller overrides always win. Cargo's build-job setting remains an independent caller
|
||||
override; this limit does not change test filters, timeouts or baselines.
|
||||
Tests run beside each other on a busy machine, so a test must not depend on wall-clock timing.
|
||||
`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
|
||||
@@ -235,117 +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. Drawings
|
||||
follow the same rules with their own numbers, from 1, in `crates/doc/src/drawing/format/` (see its
|
||||
`pending/README.md`), and the same script folds them.
|
||||
|
||||
## 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:
|
||||
|
||||
- **Growing Rust lists**: use slices (`const ITEMS: &[Item] = &[...];`) without a written
|
||||
length. `cargo xtask lists` rejects non-test const/static arrays with more than ten lines
|
||||
of entries, except fixed standard or algorithm tables listed with reasons in `xtask/src/lists.rs`.
|
||||
Built-in themes need only a JSON file in `assets/styles/ui/themes/`, with a unique nonnegative
|
||||
integer `order` (the picker order) and its own `provenance` line; keep existing order values.
|
||||
- **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.
|
||||
- **Commands without schemas** (`crates/engine/src/schema/unconverted.txt`): sorted, one id per
|
||||
line, and it only shrinks. Converting a command removes its line; a new command is never added
|
||||
to it.
|
||||
- **Toolbar commands without help** (`BACKLOG` in `crates/ui-egui/src/command_help.rs`): sorted,
|
||||
one id per line, and it only shrinks. Writing a command's help removes its line; a new command
|
||||
is never added to it.
|
||||
- **`ui.prefs` settings** (`docs/control-protocol.md`): one line per setting, sorted by name, in
|
||||
the list under the protocol table; add a new setting on its own line rather than editing a shared
|
||||
row.
|
||||
- **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).
|
||||
- **New UI strings and attribution rows**: one new file per change, `assets/locales/pending/<slug>.json`
|
||||
and `attribution/pending/<slug>.md`, instead of editing the catalogs or the ATTRIBUTION.md table;
|
||||
`cargo xtask fold-pending` folds them on `main` (see the README in each folder). A new English
|
||||
message also needs every catalog delivered complete (`COMPLETE` in `crates/ui-egui/src/i18n.rs`:
|
||||
today ar, he, ja, vi, and zh) in the same pending file; `cargo xtask i18n --check` reports any gap.
|
||||
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,
|
||||
@@ -360,62 +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.
|
||||
|
||||
To show a picture without committing it, attach the media to the PR (it goes to the PR's own
|
||||
attachment storage and renders inline in the comment). A gist is the better home for a log or
|
||||
another text file, and you link it; a gist does not render a picture inline. The review artifacts
|
||||
stay out of the diff that way, and nothing has to be pushed to a branch of its own.
|
||||
|
||||
Attaching works in the browser for anyone who can comment: drag the file into a comment on the
|
||||
pull request. A command-line agent without a browser session usually cannot attach a file (the
|
||||
upload needs write access to this repository). Say so in the pull request, and the person running
|
||||
the agent or a maintainer can attach it.
|
||||
|
||||
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.
|
||||
|
||||
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
|
||||
fails when a lower-numbered open PR by someone else already closes the same issue (the label
|
||||
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
|
||||
|
||||
## 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).
|
||||
@@ -424,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
|
||||
[ROADMAP.md](docs/roadmap-detail.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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
Generated
+71
-1338
File diff suppressed because it is too large
Load Diff
+3
-60
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.1"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,7 +13,6 @@ authors = ["SolidCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solidcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
robust = "1.2"
|
||||
# internal
|
||||
solidcraft-geom = { path = "crates/geom" }
|
||||
solidcraft-sketch = { path = "crates/sketch" }
|
||||
@@ -24,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"
|
||||
@@ -57,43 +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"] }
|
||||
# The computer's name in a design's "open elsewhere" lock file (MIT OR Apache-2.0; already in the
|
||||
# tree through x11rb).
|
||||
gethostname = "1"
|
||||
|
||||
# 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", "variable-fonts"] }
|
||||
|
||||
# Ends a process group with the system call (MIT; already in the tree through zip).
|
||||
nix = { version = "0.31", default-features = false, features = ["signal"] }
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||
png = "0.18.1"
|
||||
# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff).
|
||||
weezl = "0.1.12"
|
||||
# rasterises the UI icon SVGs (BSD-3-Clause, pure Rust, builds for wasm)
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }
|
||||
# build scripts: reads the icon palette (MIT OR Apache-2.0)
|
||||
toml = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
@@ -103,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"
|
||||
|
||||
@@ -138,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
|
||||
@@ -152,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" }
|
||||
@@ -160,13 +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" }
|
||||
|
||||
# Deliver trackpad pinch and rotation gestures on Wayland (zwp_pointer_gestures_v1), which
|
||||
# winit 0.30 never binds and eframe 0.36 still requires winit 0.30.
|
||||
winit = { path = "vendor/winit" }
|
||||
|
||||
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
|
||||
# builds, since every mutant rebuilds the crates above the one it changes.
|
||||
[profile.mutants]
|
||||
inherits = "ci"
|
||||
incremental = true
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Disclaimer
|
||||
|
||||
This disclaimer covers the software, models, data, documentation and every other file in this
|
||||
repository and in the releases built from it (together, "the files").
|
||||
|
||||
## No warranty
|
||||
|
||||
The files are provided "as is", without warranty of any kind, express or implied, including
|
||||
warranties of accuracy, merchantability, fitness for a particular purpose and non-infringement.
|
||||
Results produced with the files, such as geometry, meshes, analyses and simulations, can be
|
||||
wrong. A qualified person must check them before anyone relies on them for a design,
|
||||
manufacturing or safety decision.
|
||||
|
||||
## No liability
|
||||
|
||||
To the fullest extent permitted by law, Tangency Labs and the contributors are not liable for any
|
||||
claim, damage, loss or other liability arising from the files or from their use, modification or
|
||||
distribution, whether in contract, tort or otherwise.
|
||||
|
||||
## No indemnity
|
||||
|
||||
Neither Tangency Labs nor any contributor indemnifies, defends or holds harmless anyone who uses,
|
||||
modifies or distributes the files, including against claims that the files infringe someone
|
||||
else's rights.
|
||||
|
||||
## Harmful or unlawful use
|
||||
|
||||
Tangency Labs and the contributors do not authorise, endorse or accept any responsibility for use
|
||||
of the files to harm people, animals, property or the environment, or for any unlawful use.
|
||||
Whoever uses the files is solely responsible for how they use them, for what they make with them
|
||||
and for complying with the laws that apply to them. Tangency Labs and the contributors must not be
|
||||
named or held out as responsible for any such use.
|
||||
|
||||
## No endorsement
|
||||
|
||||
Using, modifying or distributing the files does not mean that Tangency Labs or any contributor
|
||||
endorses you, your product or your results. Do not use the name of Tangency Labs or of a
|
||||
contributor to suggest such an endorsement without written permission.
|
||||
|
||||
## Relation to the licence
|
||||
|
||||
This disclaimer adds to the warranty and liability terms of the licence the files are distributed
|
||||
under. It does not restrict any right that licence grants; where the two differ, the licence
|
||||
governs.
|
||||
@@ -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/io.tangencylabs.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,135 +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. 6× playback with the idle pauses cut. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
- **Windows:** the [installer][windows-msi].
|
||||
- **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 macOS app is signed with a Developer ID and notarized by
|
||||
Apple. The Windows downloads aren't signed yet: choose **More info**, then **Run anyway** in
|
||||
SmartScreen. 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, `.sc` designs (`.solidcraft` files from earlier releases still open).
|
||||
- **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`. If a terminal doesn't find
|
||||
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
|
||||
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). 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.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
The files in this repository are provided as is, without warranty, liability or indemnity, and
|
||||
Tangency Labs and the contributors accept no responsibility for any harmful or unlawful use of
|
||||
them: read [DISCLAIMER.md](DISCLAIMER.md).
|
||||
|
||||
## 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.
|
||||
|
||||
+21
-324
@@ -1,103 +1,19 @@
|
||||
# SolidCraft roadmap
|
||||
|
||||
The short version, for people who use SolidCraft. The detailed developer status (milestone
|
||||
tables, limitations, measurements) is in [docs/roadmap-detail.md](docs/roadmap-detail.md).
|
||||
|
||||
## What SolidCraft is
|
||||
|
||||
SolidCraft is open-source parametric 3D CAD written in Rust. You sketch, constrain, extrude, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app and in the browser
|
||||
([try it](https://solidcraft.io/app)), and it has a command-line tool and an MCP server so that
|
||||
scripts and AI agents can drive the same commands you use.
|
||||
|
||||
## Works today
|
||||
|
||||
- Sketching with a constraint solver, and the usual create and modify tools.
|
||||
- Extrude, revolve, loft, sweep, coil, fillet, chamfer, shell, draft, holes, threads,
|
||||
patterns, mirror and split.
|
||||
- Parameters with units and expressions, and configurations.
|
||||
- Faces and edges keep their names through edits, so references survive changes.
|
||||
- Components, assemblies, joints, motion studies and exploded views.
|
||||
- Sheet metal with flat patterns and DXF export, and a plastic-enclosure subset.
|
||||
- A Render workspace with GPU rendering, studio lighting, materials and image export
|
||||
(first release in 0.22.0).
|
||||
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, and FreeCAD and Rhino files.
|
||||
- Autosave and crash recovery, and the browser build of the same app.
|
||||
|
||||
The full list of commands is in [docs/features.md](docs/features.md). Release notes for each
|
||||
version are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
|
||||
## In progress now
|
||||
|
||||
Each feature below is a [milestone](https://github.com/tangency-labs/solidcraft/milestones) holding
|
||||
the issues for it, so the count shows how far along it is. Counts are as of 2026-10-25, one per
|
||||
feature or request. Work merged on a feature branch (`drawings`, `render`) counts as built but
|
||||
reaches the app only when that branch merges. They are rough, not a promise.
|
||||
|
||||
- **[Rendering & materials](https://github.com/tangency-labs/solidcraft/milestone/17)**: in progress, 9 of 34 pieces done, 3 of them built only on the `render` branch and in the app when it merges. The basic Render workspace is already in releases (0.22.0); the interface rework (#6281) and the material and light libraries are what is left.
|
||||
- **[Drawings](https://github.com/tangency-labs/solidcraft/milestone/8)**: in progress, 3 of 21 pieces built (2 in releases, 1 on the `drawings` branch), and 3 more partly built there (sheets and borders, bill-of-materials types, the paper scene). Not in the app yet: it lands when the `drawings` branch merges. Today you can export the current view as a DXF.
|
||||
- **[Sheet metal](https://github.com/tangency-labs/solidcraft/milestone/15)**: in progress, 1 of 30 requests done. Flanges, hems, unfold and flat patterns already work; the 30 are extensions, mostly the per-edge flange options (segments, miters, partial flanges, reliefs) and bend allowance.
|
||||
- **[Sketching](https://github.com/tangency-labs/solidcraft/milestone/14)**: in progress, 12 of 50 pieces done. Dimension types, selection and snapping options.
|
||||
- **[Solid modeling](https://github.com/tangency-labs/solidcraft/milestone/2)**: in progress, 9 of 50 pieces done. Blends, holes, threads and patterns.
|
||||
- **[Assemblies & joints](https://github.com/tangency-labs/solidcraft/milestone/3)**: in progress, 4 of 19 pieces done. Joints, motion studies and exploded views already work; placement rules, linked sub-assemblies, fastener stacks and weldment corners are the pieces left.
|
||||
- **[Import & export](https://github.com/tangency-labs/solidcraft/milestone/5)**: in progress, 7 of 39 pieces done. FreeCAD, SolidWorks and point-cloud import, and mesh export options.
|
||||
- **[AI agents & MCP](https://github.com/tangency-labs/solidcraft/milestone/18)**: in progress, 2 of 8 pieces done. Agent chat and the MCP server.
|
||||
- **[Interface, web app & plugins](https://github.com/tangency-labs/solidcraft/milestone/4)**: in progress, 31 of 96 pieces done. Ribbon redesign, navigation, themes, the web app and plugins.
|
||||
- **[SMLib kernel migration](https://github.com/tangency-labs/solidcraft/milestone/1)**: in progress, 1 tracking issue open. The kernel is moving from truck to NVIDIA's SMLib (usd-brep, Apache-2.0) on the `kernel-smlib` branch; it is not in releases yet. Tracked in [#4012](https://github.com/tangency-labs/solidcraft/issues/4012).
|
||||
|
||||
## Next
|
||||
|
||||
- **[Surfacing](https://github.com/tangency-labs/solidcraft/milestone/16)**: next, 6 of 14 pieces done. Patch, trim, blend and match exist; the rest of the surface toolset is planned.
|
||||
- **[Manufacturing / CAM](https://github.com/tangency-labs/solidcraft/milestone/9)**: next, 1 pieces tracked, none done. There is no CAM in SolidCraft yet. Send to Slicer and a first toolpath slice are in review; proposals are [#1153](https://github.com/tangency-labs/solidcraft/issues/1153) and [#1621](https://github.com/tangency-labs/solidcraft/issues/1621).
|
||||
|
||||
Features are not tied to a release or a date. Work lands in the daily releases as it is finished.
|
||||
Anything without a milestone, such as bug fixes, is not a feature and is not listed here.
|
||||
|
||||
## How releases work
|
||||
|
||||
- Releases ship daily, as numbered versions (v0.22.0, v0.22.1) with nightly pre-releases in
|
||||
between. They are delivered through the in-app updater.
|
||||
- SolidCraft tells you when a newer stable release exists and never updates without asking.
|
||||
Where it can replace itself safely (AppImage, Windows installer and portable builds) there is
|
||||
an **Update now** button; downloads are checked against the release's SHA-256 list. See
|
||||
[updates and privacy](docs/book/src/guide/updates.md).
|
||||
- Design files carry a format number. A new version opens designs from older versions, but an
|
||||
older version refuses a design saved by a newer one (0.22.0 writes format 35, and 0.21.0 and
|
||||
earlier will not open it). Each release's notes say when the format changed. Keep a copy of
|
||||
an important design before you move to a new version.
|
||||
|
||||
## Licence
|
||||
|
||||
SolidCraft is licensed MIT OR Apache-2.0, at your option; see [LICENSE-MIT](LICENSE-MIT) and
|
||||
[LICENSE-APACHE](LICENSE-APACHE). Versions already released stay available under those licences
|
||||
permanently.
|
||||
|
||||
## How to follow along
|
||||
|
||||
- Feature milestones: <https://github.com/tangency-labs/solidcraft/milestones>
|
||||
- Issues: <https://github.com/tangency-labs/solidcraft/issues> (area labels such as
|
||||
`area:kernel`, `area:sketch` and `area:ui` group them)
|
||||
- Discord: <https://discord.gg/FtU4NQNVDH>
|
||||
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
|
||||
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
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
|
||||
@@ -105,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).
|
||||
@@ -130,76 +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 |
|
||||
| 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), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M1 | Sketch depth: 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; 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, PLY/XYZ point clouds as a modelling reference and PLY meshes (done — [docs/point-clouds.md](docs/point-clouds.md), #5151) | in progress | 6 |
|
||||
| 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; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; 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, and exported as an animation coming apart and back together); 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.
|
||||
|
||||
Drawing UI scaffold: the native workspace now supplies the shared four-tab ribbon,
|
||||
source Browser, compact dockable sheet strip and resizable Sheet Settings/options
|
||||
panel. Planned tools remain unavailable; only existing source services execute.
|
||||
Drawing files and paper rendering are still deferred. See the
|
||||
[UI implementation roadmap](docs/drawing-workspace-ui.md).
|
||||
|
||||
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
|
||||
|
||||
@@ -209,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
|
||||
@@ -231,72 +79,18 @@ 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
|
||||
|
||||
- Operation length fields share design-unit defaults and input evaluation, including secondary
|
||||
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
|
||||
handles and sketch previews use the same lengths as committed geometry. Existing stored
|
||||
millimetre values retain their size when edited in a design using another unit (#3271).
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
@@ -337,12 +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 Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
|
||||
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
|
||||
they touch, how far they overlap, or the narrowest gap between nested circles).
|
||||
- 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.
|
||||
@@ -367,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
|
||||
|
||||
@@ -402,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.
|
||||
@@ -440,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.
|
||||
|
||||
@@ -478,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
|
||||
@@ -514,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;
|
||||
@@ -549,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.
|
||||
@@ -596,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
|
||||
@@ -689,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
|
||||
@@ -707,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
|
||||
@@ -761,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.
|
||||
|
||||
+606
-4056
File diff suppressed because it is too large
Load Diff
@@ -19,18 +19,7 @@ path = "src/main.rs"
|
||||
name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[features]
|
||||
default = ["raytrace-cpu"]
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
|
||||
|
||||
[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,151 +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.
|
||||
//! Accepts runtime requests as well as inline or named saved render jobs.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
doc::render_settings::{RenderJob, RenderSetup},
|
||||
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}"))
|
||||
}
|
||||
|
||||
// Only decide which optional host must be installed. The production command
|
||||
// still validates and resolves the actual request without a second wire format.
|
||||
fn needs_gpu(session: &Session, params: &Value) -> Result<bool, String> {
|
||||
let inline = params.get("settings").filter(|value| !value.is_null());
|
||||
let named = params.get("setup").filter(|value| !value.is_null());
|
||||
if inline.is_some() && named.is_some() {
|
||||
return Err("choose either setup or settings".into());
|
||||
}
|
||||
let inline_setup = inline
|
||||
.filter(|value| value.get("job").is_some_and(|job| !job.is_null()))
|
||||
.map(|value| serde_json::from_value::<RenderSetup>(value.clone()).map_err(|e| e.to_string()))
|
||||
.transpose()?;
|
||||
let setup = if let Some(name) = named.and_then(Value::as_str) {
|
||||
Some(session.doc.render.setups.get(name).ok_or("no saved render setup with that name")?)
|
||||
} else {
|
||||
inline_setup.as_ref()
|
||||
};
|
||||
if let Some(setup) = setup
|
||||
&& let Some(job) = &setup.job
|
||||
{
|
||||
if params.as_object().is_some_and(|object| object.keys().any(|key| !matches!(key.as_str(), "setup" | "settings" | "replace"))) {
|
||||
return Err("saved render jobs cannot be mixed with runtime controls".into());
|
||||
}
|
||||
setup.validate().map_err(|e| e.to_string())?;
|
||||
return Ok(matches!(job, RenderJob::GpuPath { .. }));
|
||||
}
|
||||
Ok(params.get("progressive").and_then(Value::as_bool) == Some(true))
|
||||
}
|
||||
|
||||
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, &mut |_, _| {}).map_err(|e| e.to_string())?;
|
||||
let generation_seconds = generation_start.elapsed().as_secs_f64();
|
||||
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
|
||||
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 cpu_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())?;
|
||||
let cpu_host_seconds = cpu_host_start.elapsed().as_secs_f64();
|
||||
// Match runtime and saved-job dispatch. CPU evidence must not require a
|
||||
// Vulkan device, parse GPU policy or initialize a GPU host.
|
||||
let (adapter_report, gpu_host_seconds) = if needs_gpu(&session, ¶ms)? {
|
||||
let gpu_host_start = Instant::now();
|
||||
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
|
||||
let (adapter, report) =
|
||||
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
|
||||
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
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 executor = started
|
||||
.get("resolved")
|
||||
.and_then(|resolved| resolved.get("executor"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("started job did not report the resolved image executor")?
|
||||
.to_owned();
|
||||
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 {executor} executor");
|
||||
if let Some(adapter) = &adapter_report {
|
||||
eprintln!("GPU adapter: {adapter:?}");
|
||||
}
|
||||
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,"executor":executor,"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,"cpu_host_setup_seconds":cpu_host_seconds,"gpu_host_setup_seconds":gpu_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, preflight and initial CPU/GPU host setup; GPU setup timing includes adapter selection and pipelines when requested",
|
||||
"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,243 +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.sc]
|
||||
//! ```
|
||||
//! 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-{}.{}", std::process::id(), solidcraft_engine::io::DESIGN_EXT))
|
||||
.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,701 +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)
|
||||
.map(|t| t.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" | "failed" | "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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +60,6 @@ fn replay(report: &Value) -> Result<(), String> {
|
||||
Some(d) => super::load(d)?,
|
||||
None => solidcraft_engine::Session::default(),
|
||||
};
|
||||
// The ids the session drew from there, so the commands give the same objects the same ids.
|
||||
if let Some(seed) = script["ids"].as_u64() {
|
||||
s.seed_ids(seed);
|
||||
}
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
|
||||
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+96
-639
@@ -1,43 +1,26 @@
|
||||
//! SolidCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--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 mcp setup [--dry-run] [--client NAME] [--live] (alias: mcp install) add it to OpenCode, Claude Desktop, Claude Code, Cursor
|
||||
//! 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)
|
||||
//! --max-file-size SIZE (with any command) read STEP and design files up to SIZE (2GB, 1500MB; at most 16GB)
|
||||
//! ```
|
||||
#![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;
|
||||
@@ -47,111 +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.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\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.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\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.sc|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.sc | part.step> [--parse-only] [--save OUT.sc] [--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.sc] [--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.sc|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.sc|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.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\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 --max-file-size SIZE, with any command: read STEP and import files and design files up to SIZE for this run\n (for example 2GB or 1500MB; at most 16GB; the defaults are 512 MB and 256 MB, and a smaller SIZE keeps them)\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);
|
||||
if let Err(e) = max_file_size(&mut args) {
|
||||
eprintln!("error: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
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();
|
||||
@@ -165,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)))
|
||||
}),
|
||||
@@ -186,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,
|
||||
@@ -211,125 +106,43 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take `--max-file-size SIZE` (or `--max-file-size=SIZE`), which any command takes, out of the
|
||||
/// arguments and raise the file size limits for this run (#4086). A bad size is an error.
|
||||
fn max_file_size(args: &mut Vec<String>) -> Result<(), String> {
|
||||
let mut size = None;
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
if arg == "--max-file-size" {
|
||||
let value = args.get(i + 1).cloned().ok_or("--max-file-size needs a size, for example --max-file-size 2GB")?;
|
||||
args.drain(i..i + 2);
|
||||
size = Some(value);
|
||||
} else if let Some(value) = arg.strip_prefix("--max-file-size=") {
|
||||
size = Some(value.to_string());
|
||||
args.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let Some(size) = size else { return Ok(()) };
|
||||
let bytes = solidcraft_engine::io::limits::parse_flag(&size).map_err(|e| format!("--max-file-size: {e}"))?;
|
||||
solidcraft_engine::io::limits::set_override(Some(bytes))
|
||||
}
|
||||
|
||||
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.
|
||||
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::limits::max_design_bytes())
|
||||
.map_err(|e| format!("{path}: {}", solidcraft_engine::io::limits::explain(solidcraft_engine::io::limits::Kind::Design, e)))?;
|
||||
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a point cloud, a command script, or an oracle recipe.
|
||||
/// 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) || solidcraft_engine::io::is_point_cloud_path(path) {
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
let bytes = solidcraft_engine::io::limits::read(path, solidcraft_engine::io::limits::Kind::Design).map_err(|e| format!("{path}: {e}"))?;
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
if solidcraft_engine::io::is_design_container(&bytes) {
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::opened_at(doc, None, Some(path));
|
||||
session.param_check = check;
|
||||
let mut session = Session::new(doc);
|
||||
session.path = Some(path.to_owned());
|
||||
return Ok(session);
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
||||
let script = recipe::to_script(&v)?;
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
|
||||
run_script_warning(&mut s, path, &script)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
|
||||
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
|
||||
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
|
||||
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
|
||||
fn print_param_warnings(path: &str, 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());
|
||||
}
|
||||
}
|
||||
|
||||
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
|
||||
/// treated (docs/mcp.md); off when the option isn't given.
|
||||
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
|
||||
match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
|
||||
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
@@ -343,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);
|
||||
@@ -355,77 +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)?;
|
||||
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
|
||||
let check = parse_param_check("run", args)?;
|
||||
known_args(
|
||||
"run",
|
||||
args,
|
||||
&["--in", "--out", "--save", "--json-out", "--param-check"],
|
||||
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
|
||||
true,
|
||||
)?;
|
||||
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_checked(b, check)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
s.param_check = check;
|
||||
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
|
||||
let script = path.ok_or(needs)?;
|
||||
let v = read_json(script)?;
|
||||
let out = if timings {
|
||||
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).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(run_script_warning(&mut s, script, &v)?)
|
||||
};
|
||||
(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, check)?, 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
|
||||
@@ -440,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`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
|
||||
/// positional script run on top of it, or either one alone, with scripts run at `check`.
|
||||
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
match (base, path) {
|
||||
(Some(b), Some(p)) => {
|
||||
let mut s = load_checked(b, check)?;
|
||||
run_script_warning(&mut s, p, &read_json(p)?)?;
|
||||
Ok(s)
|
||||
}
|
||||
(Some(b), None) => load_checked(b, check),
|
||||
(None, Some(p)) => load_checked(p, check),
|
||||
(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(())
|
||||
}
|
||||
@@ -500,9 +226,8 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
|
||||
let mut s = load(path)?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||||
let out = json!({
|
||||
@@ -513,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), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -671,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!({}),
|
||||
@@ -683,34 +312,16 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `mcp setup`: add the server to each installed AI client, with this program's own path.
|
||||
fn cmd_mcp_setup(args: &[String]) -> Result<(), String> {
|
||||
use solidcraft_engine::agent_setup as setup;
|
||||
let (lines, ok) = setup::setup(args, &setup::Env::from_process(), &setup::current_exe()?)?;
|
||||
for l in lines {
|
||||
println!("{l}");
|
||||
}
|
||||
if ok { Ok(()) } else { Err("mcp setup: some clients could not be configured".into()) }
|
||||
}
|
||||
|
||||
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
|
||||
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
|
||||
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
if matches!(args.first().map(String::as_str), Some("setup" | "install")) {
|
||||
return cmd_mcp_setup(&args[1..]);
|
||||
}
|
||||
let render_gpu = args.iter().any(|arg| arg == "--render-gpu");
|
||||
if render_gpu && flag(args, "--connect").is_some() {
|
||||
return Err("mcp: --render-gpu installs a headless image host; a connected application owns its own device".into());
|
||||
}
|
||||
install_renderer();
|
||||
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
|
||||
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
|
||||
if let Some(s) = &scope {
|
||||
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
|
||||
eprintln!(
|
||||
"solidcraft mcp: files limited to {} and the folders of designs opened or saved here (--allow-path DIR adds one, --allow-any-path lifts the limit)",
|
||||
"solidcraft mcp: files limited to {} and the open design's folder (--allow-path DIR adds one, --allow-any-path lifts the limit)",
|
||||
roots.join(", ")
|
||||
);
|
||||
let design_dir = flag(args, "--in")
|
||||
@@ -720,68 +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 calls that break a command's parameter contract are treated, headless or bridged.
|
||||
let param_check = parse_param_check("mcp", args)?;
|
||||
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")).with_param_check(param_check);
|
||||
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)) => {
|
||||
let mut headless = solidcraft_mcp::Headless::new(load_checked(path, param_check)?);
|
||||
if render_gpu {
|
||||
headless.session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Box::new(headless)
|
||||
}
|
||||
(None, None) => {
|
||||
let auto = !args.iter().any(|a| a == "--headless");
|
||||
if auto && 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)");
|
||||
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
|
||||
return serve_mcp(Box::new(remote), scope);
|
||||
}
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
if render_gpu {
|
||||
headless.session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if auto {
|
||||
// The app may start after the agent: look for it again until there is work here.
|
||||
Box::new(solidcraft_mcp::Auto::new(headless, Box::new(discover_app), flag(args, "--token")))
|
||||
} else {
|
||||
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> {
|
||||
@@ -834,153 +400,44 @@ 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> {
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.sc | part.step> [--parse-only]")?;
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solidcraft_engine::io::is_step_path(path);
|
||||
let kind = if step { solidcraft_engine::io::limits::Kind::Step } else { solidcraft_engine::io::limits::Kind::Design };
|
||||
let bytes = solidcraft_engine::io::limits::read(path, kind).map_err(|e| format!("{path}: {e}"))?;
|
||||
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
// The file as read, held until the timeline has evaluated it (as the import command does).
|
||||
let mut read = None;
|
||||
let (doc, saved) = if step {
|
||||
let 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_at(doc, saved.as_ref(), (!step).then_some(path.as_str()));
|
||||
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.sc) 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_engine::io::DESIGN_EXT);
|
||||
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,169 +0,0 @@
|
||||
//! Complete headless image-job workflow through the same engine commands as the UI/MCP.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
doc::render_settings::{RenderJob, RenderSetup},
|
||||
render::job::{CpuHost, JobId, Phase, ToneMap},
|
||||
};
|
||||
|
||||
/// Only explicit runtime CLI flags are stored here. A typed setup owns these
|
||||
/// values even when a supplied flag happens to match the saved value.
|
||||
#[derive(Default)]
|
||||
struct RuntimeOptions {
|
||||
progressive: bool,
|
||||
transparent: bool,
|
||||
samples: Option<u32>,
|
||||
max_depth: Option<u32>,
|
||||
seed: Option<u32>,
|
||||
lighting: Option<String>,
|
||||
tone_map: Option<ToneMap>,
|
||||
environment: Option<String>,
|
||||
}
|
||||
|
||||
impl RuntimeOptions {
|
||||
fn explicit_flag(&self) -> Option<&'static str> {
|
||||
[
|
||||
(self.progressive, "--progressive"),
|
||||
(self.transparent, "--transparent"),
|
||||
(self.samples.is_some(), "--samples"),
|
||||
(self.max_depth.is_some(), "--max-depth"),
|
||||
(self.seed.is_some(), "--seed"),
|
||||
(self.lighting.is_some(), "--lighting"),
|
||||
(self.tone_map.is_some(), "--tone-map"),
|
||||
(self.environment.is_some(), "--environment"),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(present, flag)| present.then_some(flag))
|
||||
}
|
||||
|
||||
fn request(&self, session: &Session, settings: RenderSetup) -> Result<(Value, bool), String> {
|
||||
if let Some(job) = &settings.job {
|
||||
if let Some(flag) = self.explicit_flag() {
|
||||
return Err(format!("{flag} cannot accompany a typed saved render setup; change the saved job options instead"));
|
||||
}
|
||||
settings.validate().map_err(|e| e.to_string())?;
|
||||
session.doc.render.validate_resources(&settings).map_err(|e| e.to_string())?;
|
||||
let needs_gpu = matches!(job, RenderJob::GpuPath { .. });
|
||||
return Ok((json!({"settings":settings}), needs_gpu));
|
||||
}
|
||||
// Legacy setups and unnamed requests retain their runtime wire contract.
|
||||
settings.validate().map_err(|e| e.to_string())?;
|
||||
let mut params = json!({"settings":settings,"transparent_background":self.transparent,
|
||||
"progressive":self.progressive,"samples":self.samples,"max_depth":self.max_depth,"seed":self.seed});
|
||||
if let Some(path) = &self.lighting {
|
||||
let text = solidcraft_engine::io::vfs::read_to_string(path, 65536).map_err(|e| format!("could not read lighting: {e}"))?;
|
||||
let lighting: solidcraft_engine::render::job::StudioLighting = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||
params["lighting"] = json!(lighting);
|
||||
}
|
||||
if let Some(tone_map) = self.tone_map {
|
||||
params["tone_map"] = json!(tone_map);
|
||||
}
|
||||
if let Some(path) = &self.environment {
|
||||
let text = solidcraft_engine::io::vfs::read_to_string(path, 65536).map_err(|e| format!("could not read environment: {e}"))?;
|
||||
params["environment"] = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok((params, self.progressive))
|
||||
}
|
||||
}
|
||||
|
||||
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 exposure = None;
|
||||
let mut runtime = RuntimeOptions::default();
|
||||
let mut overwrite = false;
|
||||
let mut timeout = 120u64;
|
||||
let mut i = 1;
|
||||
while let Some(arg) = args.get(i) {
|
||||
match arg.as_str() {
|
||||
"--progressive" => runtime.progressive = true,
|
||||
"--transparent" => runtime.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" => runtime.samples = Some(value.parse::<u32>().map_err(|_| "invalid samples")?),
|
||||
"--max-depth" => runtime.max_depth = Some(value.parse::<u32>().map_err(|_| "invalid depth")?),
|
||||
"--seed" => runtime.seed = Some(value.parse::<u32>().map_err(|_| "invalid seed")?),
|
||||
"--exposure" => exposure = Some(value.parse::<f64>().map_err(|_| "invalid exposure")?),
|
||||
// Defer file reads until a saved job has rejected conflicting controls.
|
||||
"--environment" => runtime.environment = Some(value.clone()),
|
||||
"--lighting" => runtime.lighting = Some(value.clone()),
|
||||
"--tone-map" => {
|
||||
runtime.tone_map = Some(match value.as_str() {
|
||||
"reinhard" => ToneMap::Reinhard,
|
||||
"aces_fitted" => 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)?;
|
||||
let mut settings = if let Some(name) = setup.as_deref() {
|
||||
session.doc.render.setups.get(name).cloned().ok_or("no saved render setup with that name")?
|
||||
} else {
|
||||
RenderSetup::default()
|
||||
};
|
||||
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;
|
||||
}
|
||||
// Resolve saved intent before opening a GPU or explicitly settling geometry.
|
||||
// Width/height/exposure only change this validated inline settings clone.
|
||||
let (mut params, needs_gpu) = runtime.request(&session, settings)?;
|
||||
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
|
||||
if needs_gpu {
|
||||
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())?;
|
||||
if setup.is_none() {
|
||||
let camera = if let Some(camera) = session.camera {
|
||||
camera
|
||||
} else {
|
||||
let mut camera = RenderSetup::default().camera;
|
||||
camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
|
||||
camera
|
||||
};
|
||||
params["settings"]["camera"] = json!(camera);
|
||||
}
|
||||
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",
|
||||
@@ -92,7 +86,7 @@ fn designs() -> Vec<(String, PathBuf)> {
|
||||
add("regressions", root.join("examples/regressions"), "json");
|
||||
add("demo", root.join("examples/demo"), "json");
|
||||
add("typical", root.join("tests/action_matrix/designs"), "json");
|
||||
add("demo", root.join("tests/action_matrix/designs"), solidcraft_engine::io::DESIGN_EXT);
|
||||
add("demo", root.join("tests/action_matrix/designs"), "solidcraft");
|
||||
let mut cases: Vec<PathBuf> =
|
||||
std::fs::read_dir(root.join("tests/oracle")).unwrap().flatten().map(|e| e.path()).filter(|p| p.join("recipe.json").is_file()).collect();
|
||||
cases.sort();
|
||||
@@ -105,7 +99,7 @@ fn designs() -> Vec<(String, PathBuf)> {
|
||||
/// Build a design from its file.
|
||||
fn load(path: &Path) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
if solidcraft_engine::io::is_design_container_path(path) {
|
||||
if path.extension().is_some_and(|x| x == "solidcraft") {
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -395,10 +389,8 @@ fn actions(s: &Session) -> Vec<Action> {
|
||||
let r = round3((shortest * 0.1).max(0.01));
|
||||
let mut add = |action, act| out.push(Action { entity: entity.clone(), action, act });
|
||||
if !smooth(e, mid) {
|
||||
// The midpoint is interior to this edge, but other bodies (including component
|
||||
// copies in their local frames) can have coincident edges. Retain the picked body.
|
||||
add("edge.fillet", Act::Run("solid.fillet", json!({"body": world, "edges": [pt(mid)], "radius": r})));
|
||||
add("edge.chamfer", Act::Run("solid.chamfer", json!({"body": world, "edges": [pt(mid)], "distance": r})));
|
||||
add("edge.fillet", Act::Run("solid.fillet", json!({"edges": [pt(mid)], "radius": r})));
|
||||
add("edge.chamfer", Act::Run("solid.chamfer", json!({"edges": [pt(mid)], "distance": r})));
|
||||
}
|
||||
let dir = straight(pts);
|
||||
if dir.is_some() {
|
||||
@@ -631,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()));
|
||||
}
|
||||
@@ -663,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) {
|
||||
@@ -712,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
|
||||
@@ -727,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();
|
||||
@@ -887,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()
|
||||
@@ -916,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:?})")}),
|
||||
@@ -936,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;
|
||||
}
|
||||
@@ -945,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"]});
|
||||
}
|
||||
}
|
||||
@@ -995,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.
|
||||
@@ -1029,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) => {
|
||||
@@ -1077,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{}",
|
||||
@@ -1103,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
|
||||
@@ -1280,41 +1017,6 @@ fn rectangle(plane: Value, p0: [f64; 2], p1: [f64; 2]) -> [Value; 3] {
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_round_actions_keep_the_picked_body() {
|
||||
for (corner, length) in [([10, 0, 20], 4), ([0, 0, 0], 20)] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Plate"})).unwrap();
|
||||
s.execute(
|
||||
"solid.box",
|
||||
&json!({"corner": corner, "length": length, "width": length, "height": length, "body_name": "Block", "operation": "new"}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut tried = 0;
|
||||
for a in actions(&s) {
|
||||
if !a.entity.starts_with("Plate/") || !matches!(a.action, "edge.chamfer" | "edge.fillet") {
|
||||
continue;
|
||||
}
|
||||
let Act::Run(id, ref p) = a.act else { continue };
|
||||
let q = &p["edges"][0];
|
||||
let x = q[0].as_f64().unwrap();
|
||||
if q[1] != 0.0 || q[2] != 20.0 || !(0.0..20.0).contains(&x) {
|
||||
continue;
|
||||
}
|
||||
// Block either meets this midpoint or coincides with the whole edge.
|
||||
assert_eq!(p["body"], "Plate", "{}: {p}", a.action);
|
||||
assert_eq!(q, &json!([10.0, 0.0, 20.0]));
|
||||
let mut unscoped = p.clone();
|
||||
unscoped.as_object_mut().unwrap().remove("body");
|
||||
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
|
||||
tried += 1;
|
||||
}
|
||||
assert_eq!(tried, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
|
||||
for (cmd, size) in [("solid.fillet", "radius"), ("solid.chamfer", "distance")] {
|
||||
@@ -1329,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();
|
||||
@@ -1449,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]}}),
|
||||
@@ -1556,9 +1158,10 @@ 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.sc"),
|
||||
Some("tests/action_matrix/designs/pi-case.solidcraft"),
|
||||
json!([{"command": "solid.press_pull", "params": {"face": [30, 20, 0], "distance": 1}}]),
|
||||
);
|
||||
}
|
||||
@@ -1596,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,
|
||||
@@ -1702,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,190 +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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--max-file-size` (#4086), with any command: the usage names it, a bad size stops the run
|
||||
/// with the reason, and a design over the default limit opens once the flag raises it.
|
||||
#[test]
|
||||
fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--help").output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("--max-file-size"));
|
||||
for size in ["17GB", "2", "lots"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["--max-file-size", size, "exec", "file.new"]).output().unwrap();
|
||||
assert!(!output.status.success(), "{size} was accepted");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.starts_with("error: --max-file-size"), "{size}: {stderr}");
|
||||
}
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["exec", "file.new", "--max-file-size=2GB"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
|
||||
// A sparse design file just over the default 256 MB: refused unread, then read with the flag
|
||||
// (and refused for what it holds, which is no design).
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-cli-4086-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.solidcraft");
|
||||
std::fs::File::create(&path).unwrap().set_len((256 << 20) + 1).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["eval", &path]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success() && stderr.contains("too large") && stderr.contains("--max-file-size"), "{stderr}");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["--max-file-size", "1GB", "eval", &path]).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,170 +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", "inspect"] {
|
||||
for trailing in [&[][..], &["--in"][..]] {
|
||||
let output = cli().arg(cmd).arg(&script).arg("--json-out").args(trailing).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,265 +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();
|
||||
}
|
||||
|
||||
/// `mcp setup` against a temporary home: the client folders it finds are the only ones it writes.
|
||||
mod setup {
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn home(name: &str) -> PathBuf {
|
||||
let h = std::env::temp_dir().join(format!("solidcraft-mcp-setup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&h);
|
||||
std::fs::create_dir_all(&h).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
fn run(home: &Path, args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "setup"])
|
||||
.args(args)
|
||||
.env_clear()
|
||||
.env("HOME", home)
|
||||
.env("USERPROFILE", home)
|
||||
.env("APPDATA", home.join("AppData/Roaming"))
|
||||
.env("LOCALAPPDATA", home.join("AppData/Local"))
|
||||
.env("XDG_CONFIG_HOME", home.join(".config"))
|
||||
.env("PATH", "")
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn json_of(p: &Path) -> Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn exe() -> String {
|
||||
let p = Path::new(env!("CARGO_BIN_EXE_solidcraft-cli")).canonicalize().unwrap();
|
||||
p.to_string_lossy().trim_start_matches(r"\\?\").to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_into_opencode_and_cursor_keeping_other_servers() {
|
||||
let h = home("merge");
|
||||
let oc = h.join(".config/opencode");
|
||||
std::fs::create_dir_all(&oc).unwrap();
|
||||
let old = json!({"$schema": "https://opencode.ai/config.json", "theme": "x", "mcp": {"other": {"type": "local", "command": ["o"]}}});
|
||||
std::fs::write(oc.join("opencode.json"), old.to_string()).unwrap();
|
||||
std::fs::create_dir_all(h.join(".cursor")).unwrap();
|
||||
std::fs::write(h.join(".cursor/mcp.json"), r#"{"mcpServers": {"other": {"command": "o"}}}"#).unwrap();
|
||||
|
||||
let out = run(&h, &["--live"]);
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v = json_of(&oc.join("opencode.json"));
|
||||
assert_eq!(v["theme"], "x");
|
||||
assert_eq!(v["mcp"]["other"], old["mcp"]["other"]);
|
||||
assert_eq!(v["mcp"]["solidcraft"], json!({"type": "local", "command": [exe(), "mcp"], "enabled": true}));
|
||||
assert_eq!(v["mcp"]["solidcraft-live"]["command"], json!([exe(), "mcp", "--connect", "127.0.0.1:7878"]));
|
||||
assert_eq!(json_of(&oc.join("opencode.json.bak")), old);
|
||||
let c = json_of(&h.join(".cursor/mcp.json"));
|
||||
assert_eq!(c["mcpServers"]["other"], json!({"command": "o"}));
|
||||
assert_eq!(c["mcpServers"]["solidcraft"], json!({"command": exe(), "args": ["mcp"]}));
|
||||
|
||||
// Codex (TOML) and Claude Code (a big file the CLI owns) are edited in place too.
|
||||
std::fs::create_dir_all(h.join(".codex")).unwrap();
|
||||
std::fs::write(h.join(".codex/config.toml"), "# mine\nmodel = \"x\"\n").unwrap();
|
||||
let cc = "{\n \"numStartups\": 3,\n \"projects\": {}\n}\n";
|
||||
std::fs::write(h.join(".claude.json"), cc).unwrap();
|
||||
assert!(run(&h, &[]).status.success());
|
||||
let toml = std::fs::read_to_string(h.join(".codex/config.toml")).unwrap();
|
||||
assert!(toml.starts_with("# mine\nmodel = \"x\"\n") && toml.contains("[mcp_servers.solidcraft]"), "{toml}");
|
||||
let c = json_of(&h.join(".claude.json"));
|
||||
assert_eq!(c["numStartups"], 3);
|
||||
assert_eq!(c["mcpServers"]["solidcraft"], json!({"type": "stdio", "command": exe(), "args": ["mcp"]}));
|
||||
assert_eq!(std::fs::read_to_string(h.join(".claude.json.bak")).unwrap(), cc);
|
||||
|
||||
// Running again updates the same entries and leaves one of each.
|
||||
assert!(run(&h, &[]).status.success());
|
||||
assert_eq!(json_of(&h.join(".cursor/mcp.json"))["mcpServers"].as_object().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_writes_nothing_and_missing_clients_are_skipped() {
|
||||
let h = home("dry");
|
||||
std::fs::create_dir_all(h.join(".cursor")).unwrap();
|
||||
let out = run(&h, &["--dry-run"]);
|
||||
assert!(out.status.success());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("would write") && text.contains("solidcraft"), "{text}");
|
||||
assert!(!h.join(".cursor/mcp.json").exists());
|
||||
assert!(!h.join(".config/opencode").exists(), "OpenCode was not installed, so nothing is created for it");
|
||||
let out = run(&h, &["--client", "claude-desktop"]);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_json_is_left_alone() {
|
||||
let h = home("bad");
|
||||
std::fs::create_dir_all(h.join(".cursor")).unwrap();
|
||||
std::fs::write(h.join(".cursor/mcp.json"), "{ // comment").unwrap();
|
||||
let out = run(&h, &[]);
|
||||
assert!(!out.status.success());
|
||||
assert_eq!(std::fs::read_to_string(h.join(".cursor/mcp.json")).unwrap(), "{ // comment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_msix_folder_is_found() {
|
||||
let h = home("msix");
|
||||
let pkg = h.join("AppData/Local/Packages/Claude_abc/LocalCache/Roaming/Claude");
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
// The MSIX folder is only read through LOCALAPPDATA, set for every platform here.
|
||||
assert!(run(&h, &["--client", "claude-desktop"]).status.success());
|
||||
assert!(json_of(&pkg.join("claude_desktop_config.json"))["mcpServers"]["solidcraft"].is_object());
|
||||
}
|
||||
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,9 +0,0 @@
|
||||
//! Explicit native GPU registration must not change bridged MCP ownership.
|
||||
use std::process::Command;
|
||||
#[test]
|
||||
fn connected_application_rejects_headless_gpu_installation_before_connecting() {
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--render-gpu", "--connect", "127.0.0.1:1"]).output().unwrap();
|
||||
assert!(!result.status.success());
|
||||
let message = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(message.contains("connected application owns its own device"), "{message}");
|
||||
}
|
||||
@@ -1,334 +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()));
|
||||
}
|
||||
|
||||
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
|
||||
/// command would, and opening it is no change. That command pinned them as part of itself, so
|
||||
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
|
||||
/// undo after opening from the command line evaluated every feature again (82-93 s in a
|
||||
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
|
||||
#[test]
|
||||
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
|
||||
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 mut doc = built.design().into_owned();
|
||||
for f in &mut doc.features {
|
||||
f.body_names.clear();
|
||||
}
|
||||
doc.reserved_body_names.clear();
|
||||
let unpinned = Session::new(doc);
|
||||
let design = unpinned.design();
|
||||
let saved = unpinned.model.saved(&design).unwrap();
|
||||
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
|
||||
.unwrap();
|
||||
let path = temp("unpinned");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let file = path.to_string_lossy().to_string();
|
||||
// As the command line opens a design (`load`), and as the app does (`doc.open`).
|
||||
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
|
||||
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
|
||||
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
|
||||
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
|
||||
assert!(!s.is_dirty(), "{how}: opening is no change");
|
||||
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();
|
||||
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
|
||||
// results in the model itself or evaluates them again.
|
||||
*s.model.cache.lock().unwrap() = Default::default();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
|
||||
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -75,15 +75,12 @@ fn designs() -> Vec<(String, Session)> {
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
(solidcraft_engine::io::is_design_container_path(p) || p.extension().is_some_and(|x| x == "json"))
|
||||
&& !p.to_string_lossy().ends_with(".expected.json")
|
||||
})
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = if solidcraft_engine::io::is_design_container_path(&p) {
|
||||
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
|
||||
open(&p)
|
||||
} else {
|
||||
let mut s = Session::default();
|
||||
@@ -153,7 +150,7 @@ fn pi_case_faces_per_step_at_both_heights() {
|
||||
const FACES: [(usize, usize); 13] =
|
||||
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
|
||||
let dir = scratch_dir("pi");
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.sc")).unwrap()).unwrap();
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
|
||||
let at = |height: &str, file: &str| {
|
||||
let mut d = design.clone();
|
||||
for p in d["params"].as_array_mut().unwrap() {
|
||||
|
||||
@@ -22,19 +22,6 @@ impl Scratch {
|
||||
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn saving_script(&self) -> (PathBuf, PathBuf) {
|
||||
let path = self.0.join("saving.json");
|
||||
let saved = self.0.join("script-ran.solidcraft");
|
||||
std::fs::write(
|
||||
&path,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
|
||||
{"command": "file.save_as", "params": {"path": saved}}]})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
(path, saved)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
@@ -56,96 +43,10 @@ fn body_count(path: &Path) -> Value {
|
||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_invalid_options_before_script_execution_or_output_writes() {
|
||||
let dir = Scratch::new();
|
||||
let (script, script_saved) = dir.saving_script();
|
||||
let cases = [
|
||||
(&["--out"][..], "--out needs a value"),
|
||||
(&["--save"][..], "--save needs a value"),
|
||||
(&["--in"][..], "--in needs a value"),
|
||||
(&["--out", "--quiet"][..], "--out needs a value"),
|
||||
(&["--ouut", "part.step"][..], "doesn't take --ouut"),
|
||||
(&["--unknown"][..], "doesn't take --unknown"),
|
||||
];
|
||||
for existing in [false, true] {
|
||||
let saved = dir.0.join(format!("{existing}.solidcraft"));
|
||||
let exported = dir.0.join(format!("{existing}.step"));
|
||||
let report = dir.0.join(format!("{existing}.json"));
|
||||
let outputs = [&saved, &exported, &report];
|
||||
if existing {
|
||||
for path in outputs {
|
||||
std::fs::write(path, b"existing output").unwrap();
|
||||
}
|
||||
}
|
||||
for (extra, message) in cases {
|
||||
let output = cli()
|
||||
.arg("run")
|
||||
.arg(&script)
|
||||
.arg("--save")
|
||||
.arg(&saved)
|
||||
.arg("--out")
|
||||
.arg(&exported)
|
||||
.arg("--json-out")
|
||||
.arg(&report)
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains(message), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
|
||||
for path in outputs {
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"existing output");
|
||||
} else {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_and_inspect_reject_invalid_options_before_script_execution_or_reporting() {
|
||||
let dir = Scratch::new();
|
||||
let (script, script_saved) = dir.saving_script();
|
||||
let cases = [
|
||||
(&["--in"][..], "--in needs a value"),
|
||||
(&["--in", "--quiet"][..], "--in needs a value"),
|
||||
(&["--out", "part.step"][..], "doesn't take --out"),
|
||||
(&["--unknown"][..], "doesn't take --unknown"),
|
||||
];
|
||||
for command in ["eval", "inspect"] {
|
||||
for existing in [false, true] {
|
||||
let report = dir.0.join(format!("{command}-{existing}.json"));
|
||||
if existing {
|
||||
std::fs::write(&report, b"existing report").unwrap();
|
||||
}
|
||||
for (extra, message) in cases {
|
||||
let output = cli().arg(command).arg(&script).arg("--json-out").arg(&report).args(extra).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{command} {extra:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains(message),
|
||||
"{command} {extra:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&report).unwrap(), b"existing report");
|
||||
} else {
|
||||
assert!(!report.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_loads_both_design_encodings_by_contents() {
|
||||
let dir = Scratch::new();
|
||||
for extension in ["sc", "solidcraft", "solvecraft", "json"] {
|
||||
for extension in ["solidcraft", "solvecraft", "json"] {
|
||||
let saved = dir.0.join(format!("box.{extension}"));
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
@@ -158,84 +59,6 @@ fn cli_loads_both_design_encodings_by_contents() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1369: a pending concept that did not land in the numbered release must not be silently
|
||||
/// dropped on open/resave, in either encoding or before replacing an existing destination.
|
||||
#[test]
|
||||
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
|
||||
use solidcraft_engine::{Session, doc, io};
|
||||
|
||||
let dir = Scratch::new();
|
||||
for version in [33, doc::format::FORMAT] {
|
||||
let mut experimental = doc::Document::new("Experimental");
|
||||
experimental.format = doc::format::format_string(version);
|
||||
experimental.format_pending.push("experimental-card".into());
|
||||
for (extension, bytes) in [("json", io::write_design(&experimental)), ("solidcraft", io::write_design_container(&experimental).unwrap())] {
|
||||
let source = dir.0.join(format!("experimental-{version}.{extension}"));
|
||||
std::fs::write(&source, &bytes).unwrap();
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
|
||||
let original = session.doc.clone();
|
||||
let original_path = session.path.clone();
|
||||
let undo = session.undo.len();
|
||||
let dirty = session.is_dirty();
|
||||
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
|
||||
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
|
||||
assert_eq!(session.doc, original);
|
||||
assert_eq!(session.path, original_path);
|
||||
assert_eq!(session.undo.len(), undo);
|
||||
assert_eq!(session.is_dirty(), dirty);
|
||||
|
||||
for existing in [false, true] {
|
||||
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
|
||||
let backup = io::backup_path(&destination);
|
||||
if existing {
|
||||
std::fs::write(&destination, b"existing design").unwrap();
|
||||
std::fs::write(&backup, b"existing backup").unwrap();
|
||||
}
|
||||
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
|
||||
} else {
|
||||
assert!(!destination.exists() && !backup.exists());
|
||||
}
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
|
||||
use solidcraft_engine::{doc, io};
|
||||
|
||||
let dir = Scratch::new();
|
||||
let current = doc::Document::new("Supported");
|
||||
let mut released = current.clone();
|
||||
released.format = doc::format::format_string(33);
|
||||
released.format_pending.clear();
|
||||
let mut experimental = released.clone();
|
||||
experimental.format_pending.push("reserved-body-names".into());
|
||||
for (index, mut document) in [current, released, experimental].into_iter().enumerate() {
|
||||
document.metadata.0.insert("com.example.tool".into(), json!({"format_pending": ["experimental-card"], "physical_card": {"density": 700}}));
|
||||
for (extension, bytes) in [("json", io::write_design(&document)), ("solidcraft", io::write_design_container(&document).unwrap())] {
|
||||
let source = dir.0.join(format!("supported-{index}.{extension}"));
|
||||
let destination = dir.0.join(format!("resaved-{index}.{extension}"));
|
||||
std::fs::write(&source, &bytes).unwrap();
|
||||
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let reopened = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
|
||||
assert_eq!(reopened.metadata, document.metadata);
|
||||
assert_eq!(reopened.name, document.name);
|
||||
assert_eq!(reopened.features, document.features);
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_still_save_and_export_the_script_once() {
|
||||
let dir = Scratch::new();
|
||||
@@ -267,110 +90,6 @@ fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
assert_eq!(body_count(&base), 1);
|
||||
}
|
||||
|
||||
/// `eval` and its alias must measure the script applied to the input design, rather than
|
||||
/// silently reporting the input alone (#6861).
|
||||
#[test]
|
||||
fn eval_runs_its_script_on_the_in_design() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
let script = dir.0.join("add.json");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let original = std::fs::read(&base).unwrap();
|
||||
std::fs::write(
|
||||
&script,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": 5, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
for command in ["eval", "inspect"] {
|
||||
// Both existing one-input forms still measure just the original body.
|
||||
for input in [&base, &dir.script()] {
|
||||
for named_input in [false, true] {
|
||||
let mut c = cli();
|
||||
c.arg(command);
|
||||
if named_input {
|
||||
c.arg("--in");
|
||||
}
|
||||
let measured = json_output(c.arg(input).output().unwrap());
|
||||
assert_eq!(measured["body_count"], 1);
|
||||
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
for input_first in [false, true] {
|
||||
for to_file in [false, true] {
|
||||
let mut c = cli();
|
||||
c.arg(command);
|
||||
if input_first {
|
||||
c.arg("--in").arg(&base).arg(&script);
|
||||
} else {
|
||||
c.arg(&script).arg("--in").arg(&base);
|
||||
}
|
||||
let report = dir.0.join("measurement.json");
|
||||
if to_file {
|
||||
c.arg("--json-out").arg(&report);
|
||||
}
|
||||
let output = c.output().unwrap();
|
||||
let measured = if to_file {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty());
|
||||
serde_json::from_slice(&std::fs::read(&report).unwrap()).unwrap()
|
||||
} else {
|
||||
json_output(output)
|
||||
};
|
||||
assert_eq!(measured["body_count"], 2, "{command}, input_first={input_first}, to_file={to_file}");
|
||||
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - 84.0).abs() < 1e-6);
|
||||
assert_eq!(measured["timeline"].as_array().unwrap().len(), 2, "the script runs exactly once");
|
||||
assert_eq!(std::fs::read(&base).unwrap(), original, "evaluation never saves its changes to the input");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_propagates_script_errors_before_writing_a_report() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.script();
|
||||
let script = dir.0.join("bad.json");
|
||||
for command in ["eval", "inspect"] {
|
||||
for contents in ["{", r#"{"commands":[{"command":"missing.command","params":{}}]}"#] {
|
||||
std::fs::write(&script, contents).unwrap();
|
||||
let report = dir.0.join("report.json");
|
||||
let output = cli().arg(command).arg(&script).arg("--in").arg(&base).arg("--json-out").arg(&report).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!output.stderr.is_empty());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!report.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_rejects_unknown_options_and_missing_input_values_before_writing_a_report() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let report = dir.0.join("report.json");
|
||||
for command in ["eval", "inspect"] {
|
||||
for (options, message) in [(&["--frobnicate", "1"][..], "doesn't take --frobnicate"), (&["--in"][..], "--in needs a value")] {
|
||||
for options_first in [false, true] {
|
||||
std::fs::write(&report, b"previous report").unwrap();
|
||||
let mut c = cli();
|
||||
c.arg(command);
|
||||
if options_first {
|
||||
c.args(options).arg("--json-out").arg(&report).arg(&script);
|
||||
} else {
|
||||
c.arg(&script).args(options).arg("--json-out").arg(&report);
|
||||
}
|
||||
// A following option also proves that --in cannot consume a flag as its value.
|
||||
let output = c.output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{command}, {options:?}");
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(error.contains(message), "{command}: {error}");
|
||||
assert!(output.stdout.is_empty());
|
||||
assert_eq!(std::fs::read(&report).unwrap(), b"previous report");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_propagate_output_errors() {
|
||||
let dir = Scratch::new();
|
||||
@@ -412,331 +131,4 @@ fn legacy_extension_and_format_tag_still_open() {
|
||||
assert_eq!(body_count(&resaved), 2);
|
||||
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
|
||||
assert!(reopened.to_json().contains("\"solidcraft/"));
|
||||
// `.solidcraft` (before `.sc`) opens as `--in` and as the design to evaluate; `.sc` too.
|
||||
let current = dir.0.join("current.sc");
|
||||
json_output(cli().args(["run", "--in"]).arg(&resaved).arg(dir.script()).arg("--save").arg(¤t).output().unwrap());
|
||||
assert_eq!(body_count(¤t), 3);
|
||||
assert!(solidcraft_engine::io::is_design_container(&std::fs::read(¤t).unwrap()), "a .sc file is a design container");
|
||||
}
|
||||
|
||||
/// `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 first; other close ids (such as
|
||||
/// `drawing.open`) may follow it.
|
||||
#[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"));
|
||||
}
|
||||
|
||||
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
|
||||
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
|
||||
/// `--profile`), and a warning reaches stderr even when a later step fails.
|
||||
#[test]
|
||||
fn param_check_reaches_every_script_run_takes() {
|
||||
let dir = Scratch::new();
|
||||
let typo = dir.0.join("typo.json");
|
||||
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
|
||||
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let saved = dir.0.join("saved.solidcraft");
|
||||
let start = dir.script();
|
||||
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
|
||||
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
|
||||
let run = |level: Option<&str>, extra: &[&str]| {
|
||||
std::fs::remove_file(&saved).ok();
|
||||
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
|
||||
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
|
||||
};
|
||||
|
||||
// Off, the default: the step runs as it always has, without a word.
|
||||
let off = run(None, &[]);
|
||||
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
|
||||
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
|
||||
for extra in paths {
|
||||
let output = run(Some("warn"), extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
|
||||
assert!(saved.exists(), "{extra:?}");
|
||||
if extra == ["--results"] {
|
||||
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
|
||||
}
|
||||
}
|
||||
|
||||
// Strict: on each path the run stops at the step, and nothing is written.
|
||||
for extra in paths {
|
||||
let output = run(Some("strict"), extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
|
||||
assert!(!saved.exists(), "{extra:?}");
|
||||
}
|
||||
|
||||
// Warn, with a later step that fails: the run fails, and the first step's warning is still
|
||||
// reported, timed or not.
|
||||
let failing = dir.0.join("failing.json");
|
||||
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
|
||||
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
|
||||
for extra in [&[][..], &["--timings"]] {
|
||||
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
|
||||
}
|
||||
|
||||
// A level it doesn't know and a missing value fail.
|
||||
for (bad, says) in [
|
||||
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
|
||||
(&["--param-check"], "run: --param-check wants off, warn or strict"),
|
||||
] {
|
||||
let output = cli().args(["run", typo]).args(bad).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
|
||||
assert!(stderr.contains(says), "{bad:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
|
||||
let path = dir.0.join("render.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
json!({"commands":[
|
||||
{"command":"solid.box","params":{"length":10,"width":10,"height":10}},
|
||||
{"command":"render.settings.set","params":{"name":"Portable setup","set":{"width":16,"height":16,"job":job}}}
|
||||
]})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[test]
|
||||
fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
|
||||
for options in [json!({}), json!({"progressive":{"samples_per_pixel":3,"seed":81},"lights":[]})] {
|
||||
let dir = Scratch::new();
|
||||
let script = render_setup_script(&dir, json!({"method":"cpu_raytrace","options":options}));
|
||||
let image = dir.0.join("script.png");
|
||||
let result = json_output(
|
||||
cli()
|
||||
.arg("render")
|
||||
.arg(&script)
|
||||
.args(["--setup", "Portable setup", "--out"])
|
||||
.arg(&image)
|
||||
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-cpu")
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(result["preflight"]["resolved"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(result["export"]["frame"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(result["preflight"]["request"]["integrator"], "raytrace");
|
||||
assert_eq!(result["preflight"]["request"]["raytrace"].get("progressive"), options.get("progressive"));
|
||||
let png = image::load_from_memory(&std::fs::read(&image).unwrap()).unwrap();
|
||||
assert_eq!((png.width(), png.height()), (16, 16));
|
||||
|
||||
let saved = dir.0.join("portable.solidcraft");
|
||||
json_output(cli().arg("run").arg(&script).arg("--save").arg(&saved).output().unwrap());
|
||||
let original = std::fs::read(&saved).unwrap();
|
||||
let overridden = dir.0.join("overridden.png");
|
||||
let result = json_output(
|
||||
cli()
|
||||
.arg("render")
|
||||
.arg(&saved)
|
||||
.args(["--setup", "Portable setup", "--out"])
|
||||
.arg(&overridden)
|
||||
.args(["--width", "12", "--height", "8", "--exposure", "1.25"])
|
||||
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-cpu")
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(result["preflight"]["request"]["width"], 12);
|
||||
assert_eq!(result["preflight"]["request"]["height"], 8);
|
||||
assert_eq!(result["preflight"]["request"]["raytrace"]["exposure_ev"], 1.25);
|
||||
assert_eq!(result["export"]["frame"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(result["preflight"]["request"]["raytrace"].get("progressive"), options.get("progressive"));
|
||||
let png = image::load_from_memory(&std::fs::read(overridden).unwrap()).unwrap();
|
||||
assert_eq!((png.width(), png.height()), (12, 8));
|
||||
assert_eq!(std::fs::read(saved).unwrap(), original, "CLI overrides must not rewrite saved intent");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_preserves_typed_raster_and_legacy_runtime_controls() {
|
||||
for job in [Value::Null, json!({"method":"raster","transparent_background":true})] {
|
||||
let dir = Scratch::new();
|
||||
let script = render_setup_script(&dir, job.clone());
|
||||
let out = dir.0.join("raster.png");
|
||||
let mut command = cli();
|
||||
command
|
||||
.arg("render")
|
||||
.arg(&script)
|
||||
.args(["--setup", "Portable setup", "--out"])
|
||||
.arg(&out)
|
||||
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-raster");
|
||||
if job.is_null() {
|
||||
command.arg("--transparent");
|
||||
}
|
||||
let result = json_output(command.output().unwrap());
|
||||
assert_eq!(result["preflight"]["resolved"]["executor"], "cpu-product");
|
||||
assert_eq!(result["export"]["frame"]["executor"], "cpu-product");
|
||||
assert_eq!(result["preflight"]["request"]["transparent_background"], true);
|
||||
assert!(std::fs::read(out).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_render_rejects_explicit_runtime_flags_before_gpu_or_option_file_reads() {
|
||||
let dir = Scratch::new();
|
||||
let script = render_setup_script(&dir, json!({"method":"gpu_path","options":{"samples":64,"seed":0}}));
|
||||
let out = dir.0.join("existing.png");
|
||||
std::fs::write(&out, b"previous completed image").unwrap();
|
||||
for flags in [
|
||||
&["--progressive"][..],
|
||||
&["--transparent"][..],
|
||||
&["--samples", "64"][..],
|
||||
&["--max-depth", "8"][..],
|
||||
&["--seed", "0"][..],
|
||||
&["--lighting", "deliberately-missing-lighting.json"][..],
|
||||
&["--tone-map", "reinhard"][..],
|
||||
&["--environment", "deliberately-missing-environment.json"][..],
|
||||
] {
|
||||
let output = cli()
|
||||
.arg("render")
|
||||
.arg(&script)
|
||||
.args(["--setup", "Portable setup", "--out"])
|
||||
.arg(&out)
|
||||
.arg("--overwrite")
|
||||
.args(flags)
|
||||
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-conflicts")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(error.contains(&format!("{} cannot accompany a typed saved render setup", flags[0])), "{error}");
|
||||
assert!(output.stdout.is_empty());
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"previous completed image");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_gpu_render_selects_its_host_without_a_progressive_runtime_flag() {
|
||||
// A deliberately invalid policy proves that dispatch reached the GPU host
|
||||
// without depending on an adapter or accepting a CPU fallback. The legacy
|
||||
// --progressive path must still reach that same host-selection boundary.
|
||||
for job in [json!({"method":"gpu_path","options":{"samples":2}}), Value::Null] {
|
||||
let dir = Scratch::new();
|
||||
let script = render_setup_script(&dir, job.clone());
|
||||
let out = dir.0.join("gpu.png");
|
||||
let mut command = cli();
|
||||
command.arg("render").arg(&script).args(["--setup", "Portable setup", "--out"]).arg(&out).env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
|
||||
if job.is_null() {
|
||||
command.arg("--progressive");
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(error.contains("SOLIDCRAFT_GPU must be auto, hardware, software or cpu"), "{error}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_setup_overrides_obey_shared_validation_before_host_initialization() {
|
||||
let dir = Scratch::new();
|
||||
let script = render_setup_script(&dir, json!({"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":64}}}));
|
||||
let out = dir.0.join("invalid.png");
|
||||
// 1024 * 1024 * 64 exceeds the unchanged default Compact camera-sample
|
||||
// budget. Admission must name that envelope before initializing any host.
|
||||
for (flags, message) in [
|
||||
(&["--width", "1024", "--height", "1024"][..], "CPU Compact exceeds its 16777216 camera-sample admission"),
|
||||
(&["--width", "0"][..], "render output must be at least"),
|
||||
(&["--exposure", "NaN"][..], "render exposure must be finite"),
|
||||
] {
|
||||
let output = cli()
|
||||
.arg("render")
|
||||
.arg(&script)
|
||||
.args(["--setup", "Portable setup", "--out"])
|
||||
.arg(&out)
|
||||
.args(flags)
|
||||
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-invalid-settings")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(error.contains(message), "{error}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.sc`, 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.sc")
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,11 @@ workspace = true
|
||||
name = "solidcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
@@ -34,8 +28,6 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"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/io.tangencylabs.solidcraft.png">
|
||||
<link rel="apple-touch-icon" href="icons/256x256/io.tangencylabs.solidcraft.png">
|
||||
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/32x32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/256x256" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/512x512" />
|
||||
</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/io.tangencylabs.solidcraft.png", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": "icons/256x256/io.tangencylabs.solidcraft.png", "sizes": "256x256", "type": "image/png" },
|
||||
{ "src": "icons/512x512/io.tangencylabs.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,734 +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))
|
||||
}
|
||||
|
||||
/// Where an Open for `extensions` (what the command reads; `&[]`: anything) is answered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum OpenRoute {
|
||||
/// The browser's open dialog for a design: the file keeps its handle, so Save writes back.
|
||||
Disk,
|
||||
/// The "Files in this browser" window, where the browser can't open a design from the disk.
|
||||
Panel,
|
||||
/// A plain file input that offers these types: the file is read into the engine's files and
|
||||
/// the command runs on it (Insert DXF or SVG, images, meshes, imported settings).
|
||||
Upload,
|
||||
}
|
||||
|
||||
/// Choose how an Open for `extensions` asks for its file; `api`: the browser can open a file
|
||||
/// from the disk and keep its handle.
|
||||
pub fn open_route(api: bool, extensions: &[&str]) -> OpenRoute {
|
||||
match (opens_design(extensions), api) {
|
||||
(true, true) => OpenRoute::Disk,
|
||||
(true, false) => OpenRoute::Panel,
|
||||
(false, _) => OpenRoute::Upload,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `accept` list of a file input for `extensions` (with or without their dot): ".dxf,.svg".
|
||||
pub fn accept_list(extensions: &[&str]) -> String {
|
||||
let list: Vec<String> = extensions
|
||||
.iter()
|
||||
.map(|e| e.trim().trim_start_matches('.'))
|
||||
.filter(|e| !e.is_empty())
|
||||
.map(|e| format!(".{}", e.to_ascii_lowercase()))
|
||||
.collect();
|
||||
list.join(",")
|
||||
}
|
||||
|
||||
/// The `accept` of a file input for `extensions` on this device (`apple_touch`: an iPhone or
|
||||
/// iPad). The picker there offers only files of a type the system knows from the list and greys
|
||||
/// out the rest, `.solidcraft` designs among them, since no system type has that extension
|
||||
/// (#6977). So it offers every file there, and the opener checks what it gets.
|
||||
pub fn input_accept(extensions: &[&str], apple_touch: bool) -> String {
|
||||
if apple_touch { String::new() } else { accept_list(extensions) }
|
||||
}
|
||||
|
||||
/// The browser runs on an iPhone or iPad (every browser there is WebKit), from its
|
||||
/// `navigator.userAgent` and `navigator.maxTouchPoints`. iPadOS presents itself as a Mac; only its
|
||||
/// touch screen tells them apart.
|
||||
pub fn apple_touch(user_agent: &str, touch_points: i32) -> bool {
|
||||
["iPhone", "iPad", "iPod"].iter().any(|d| user_agent.contains(d)) || (user_agent.contains("Macintosh") && touch_points > 1)
|
||||
}
|
||||
|
||||
/// The largest file an upload for `extensions` reads: images go into a canvas, which has a size
|
||||
/// limit, so a bigger one is refused before it is read. `None`: the importer checks the file.
|
||||
pub fn upload_limit(extensions: &[&str]) -> Option<usize> {
|
||||
let image = |e: &&str| ["png", "jpg", "jpeg"].contains(&e.trim_start_matches('.').to_ascii_lowercase().as_str());
|
||||
(!extensions.is_empty() && extensions.iter().all(image)).then_some(solidcraft_engine::doc::canvas::MAX_CANVAS_BYTES)
|
||||
}
|
||||
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension). A
|
||||
/// design is `.sc` unless the name already has an older design extension, which it keeps.
|
||||
pub fn save_type(name: &str) -> (&'static str, &'static str, String) {
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
match ext.as_deref() {
|
||||
Some("json") => ("Plain JSON design", "application/json", ".json".into()),
|
||||
Some(e) if solidcraft_engine::io::is_design_ext(e) => ("SolidCraft design", "application/octet-stream", format!(".{e}")),
|
||||
_ => ("SolidCraft design", "application/octet-stream", format!(".{}", solidcraft_engine::io::DESIGN_EXT)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, 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();
|
||||
let designs = solidcraft_engine::io::DESIGN_EXTS.iter().map(|e| format!(".{e}"));
|
||||
let others = [".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"].map(String::from);
|
||||
for e in designs.chain(others) {
|
||||
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
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
// The temporary folder (a screenshot without a path, #7192) stays in memory.
|
||||
assert_eq!(route(&format!("{}/solidcraft-shot.png", solidcraft_engine::io::vfs::BROWSER_TEMP)), 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(&["sc"]));
|
||||
assert!(opens_design(&["solidcraft"]));
|
||||
assert!(opens_design(&["solvecraft"]));
|
||||
assert!(!opens_design(&["step", "stp"]));
|
||||
assert!(!opens_design(&["json"]));
|
||||
}
|
||||
|
||||
/// Insert DXF or SVG, images and other non-design files get a file input that offers their
|
||||
/// types in every browser (#2052); designs keep the disk dialog or the browser's files.
|
||||
#[test]
|
||||
fn opens_of_other_files_use_a_file_input_for_their_types() {
|
||||
assert_eq!(open_route(true, &["dxf"]), OpenRoute::Upload);
|
||||
assert_eq!(open_route(false, &["png", "jpg", "jpeg"]), OpenRoute::Upload);
|
||||
assert_eq!(open_route(true, &[]), OpenRoute::Disk);
|
||||
assert_eq!(open_route(false, &["solidcraft"]), OpenRoute::Panel);
|
||||
assert_eq!(accept_list(&["dxf"]), ".dxf");
|
||||
assert_eq!(accept_list(&["png", ".JPG", "", "jpeg"]), ".png,.jpg,.jpeg");
|
||||
assert_eq!(accept_list(&[]), "");
|
||||
assert!(upload_limit(&["png", "jpg", "jpeg"]).is_some());
|
||||
assert_eq!(upload_limit(&["dxf"]), None);
|
||||
}
|
||||
|
||||
/// An iPhone or iPad greys out a `.solidcraft` design the `accept` list names, as it knows no
|
||||
/// type for that extension, so its inputs offer every file; other devices keep the list (#6977).
|
||||
#[test]
|
||||
fn file_inputs_on_an_iphone_or_ipad_offer_every_file() {
|
||||
let iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1";
|
||||
let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15";
|
||||
let chrome_ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.156 Mobile/15E148 Safari/604.1";
|
||||
let chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
assert!(apple_touch(iphone, 5));
|
||||
assert!(apple_touch(chrome_ios, 5), "every browser on iOS has the same picker");
|
||||
assert!(apple_touch(mac, 5), "an iPad presents itself as a Mac");
|
||||
assert!(!apple_touch(mac, 0), "a Mac has no touch screen");
|
||||
assert!(!apple_touch(chrome, 10), "a touch screen elsewhere filters as before");
|
||||
assert!(!apple_touch("", 0), "hostile input is just not an iPhone");
|
||||
let designs = ["solidcraft", "json", "step"];
|
||||
assert_eq!(input_accept(&designs, true), "");
|
||||
assert_eq!(input_accept(&designs, false), ".solidcraft,.json,.step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_dialog_offers_the_type_of_the_name() {
|
||||
assert_eq!(save_type("Plate.sc").2, ".sc");
|
||||
assert_eq!(save_type("Plate.JSON").2, ".json");
|
||||
assert_eq!(save_type("Plate").2, ".sc");
|
||||
// A design opened under an older extension keeps it.
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_dialog_is_not_an_error() {
|
||||
assert!(is_abort("AbortError"));
|
||||
assert!(!is_abort("NotAllowedError"));
|
||||
assert!(!is_abort("SecurityError"));
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Open with EasyPrint in the browser: upload the model to Printables' import endpoint with
|
||||
//! `fetch` (it allows any origin) and answer with the address to open.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use js_sys::{Array, Uint8Array};
|
||||
use solidcraft_engine::easyprint::{IMPORT_URL, parse_import_response};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
|
||||
use crate::files::err;
|
||||
|
||||
/// Start the upload; the answer arrives on `done`.
|
||||
pub fn upload(name: &str, bytes: Vec<u8>, done: Sender<Result<String, String>>) {
|
||||
let name = name.to_string();
|
||||
spawn_local(async move {
|
||||
let _ = done.send(post(&name, &bytes).await);
|
||||
});
|
||||
}
|
||||
|
||||
async fn post(name: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let parts = Array::new();
|
||||
parts.push(&Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(err)?;
|
||||
let form = web_sys::FormData::new().map_err(err)?;
|
||||
form.append_with_blob_and_filename("file", &blob, name).map_err(err)?;
|
||||
form.append_with_str("file_name", name).map_err(err)?;
|
||||
let headers = web_sys::Headers::new().map_err(err)?;
|
||||
headers.set("Accept", "application/json").map_err(err)?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("POST");
|
||||
init.set_body(&form);
|
||||
init.set_headers(&headers);
|
||||
let response = JsFuture::from(window.fetch_with_str_and_init(IMPORT_URL, &init)).await.map_err(err)?;
|
||||
let response: web_sys::Response = response.dyn_into().map_err(|_| "response cast failed".to_string())?;
|
||||
let status = response.status();
|
||||
let text = JsFuture::from(response.text().map_err(err)?).await.map_err(err)?;
|
||||
parse_import_response(status, text.as_string().unwrap_or_default().as_bytes())
|
||||
}
|
||||
@@ -2,35 +2,29 @@
|
||||
//! 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.sc` and `recovery.json`), named with
|
||||
/// `solidcraft_engine::io::DESIGN_EXT`.
|
||||
const RECOVERY: &str = "recovery.sc";
|
||||
/// The autosaves earlier releases kept, newest first, one per entry of
|
||||
/// `solidcraft_engine::io::LEGACY_DESIGN_EXTS` (compatibility shim, docs/compatibility.md): the
|
||||
/// newest is recovered when there is no current one.
|
||||
const LEGACY_RECOVERY: [&str; 2] = ["recovery.solidcraft", "recovery.solvecraft"];
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solidcraft";
|
||||
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
|
||||
/// when there is no current one.
|
||||
const LEGACY_RECOVERY: &str = "recovery.solvecraft";
|
||||
const RECOVERY_META: &str = "recovery.json";
|
||||
|
||||
/// What to do with a file brought in from the computer.
|
||||
@@ -40,8 +34,6 @@ pub enum Intent {
|
||||
Open,
|
||||
/// Add its bodies to the open design (STEP, 3MF, STL).
|
||||
Insert,
|
||||
/// Import it as a theme: it was dropped on Preferences' Import tile (#8226).
|
||||
Theme,
|
||||
}
|
||||
|
||||
/// A stored design, as the file panel lists it.
|
||||
@@ -59,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).
|
||||
@@ -80,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));
|
||||
@@ -98,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();
|
||||
}
|
||||
@@ -149,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:?}"))
|
||||
}
|
||||
|
||||
@@ -172,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
|
||||
}
|
||||
@@ -275,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() {
|
||||
@@ -286,10 +260,9 @@ pub fn start(files: &Rc<Files>) {
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
|
||||
let legacy = (!has_recovery).then(|| LEGACY_RECOVERY.into_iter().find(|l| all.iter().any(|(n, _, _)| n == l))).flatten();
|
||||
for (name, bytes, modified) in all {
|
||||
if LEGACY_RECOVERY.contains(&name.as_str()) {
|
||||
if legacy == Some(name.as_str()) {
|
||||
if name == LEGACY_RECOVERY {
|
||||
if !has_recovery {
|
||||
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
|
||||
}
|
||||
continue;
|
||||
@@ -322,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,9 +396,7 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Documen
|
||||
}
|
||||
None => spawn_local(async move {
|
||||
let _ = remove_file(RECOVERY).await;
|
||||
for legacy in LEGACY_RECOVERY {
|
||||
let _ = remove_file(legacy).await;
|
||||
}
|
||||
let _ = remove_file(LEGACY_RECOVERY).await;
|
||||
let _ = remove_file(RECOVERY_META).await;
|
||||
}),
|
||||
}
|
||||
@@ -455,18 +420,6 @@ pub fn recovery_path() -> String {
|
||||
format!("{OPFS}{RECOVERY}")
|
||||
}
|
||||
|
||||
/// A recovery file lives in OPFS, but its relative links belong to the original design's
|
||||
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
|
||||
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
|
||||
let path = recovery_path();
|
||||
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
|
||||
solidcraft_engine::resolve_design_links(&mut doc, original_path);
|
||||
let bytes = solidcraft_engine::io::write_design_container(&doc).map_err(|e| e.to_string())?;
|
||||
vfs::insert(&path, bytes);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Pick files on the computer: the File System Access picker where there is one, else an
|
||||
/// `<input type=file>`. Read files arrive in `files.incoming`.
|
||||
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
|
||||
@@ -474,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 {
|
||||
@@ -492,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>(())
|
||||
};
|
||||
@@ -518,11 +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; plus the DXF and SVG drawings Open and Insert take.
|
||||
let mut extensions = solidcraft_engine::io::formats::open_extensions(true);
|
||||
extensions.extend(["dxf", "svg"]);
|
||||
input.set_accept(&disk::input_accept(&extensions, apple_touch()));
|
||||
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() {
|
||||
@@ -547,85 +483,14 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
input.click();
|
||||
}
|
||||
|
||||
/// Ask for one file of these types (`extensions`, e.g. `["dxf"]`) with a plain file input, the
|
||||
/// dialog every browser has. The answer is the file's path in the engine's files (under
|
||||
/// [`UPLOAD`], as for a file picked in the disk dialog), so the command that asked runs on it;
|
||||
/// `None` when the dialog was closed or the file could not be read.
|
||||
pub fn pick_upload(files: &Rc<Files>, extensions: &[&str]) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
// Shown now, in the click: a file dialog needs the user's gesture.
|
||||
let chosen = choose_file(&disk::input_accept(extensions, apple_touch()));
|
||||
let limit = disk::upload_limit(extensions);
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let read = async {
|
||||
let value = JsFuture::from(chosen?).await.map_err(err)?;
|
||||
// Closing the dialog answers no file: nothing to say.
|
||||
let Ok(file) = value.dyn_into::<web_sys::File>() else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if limit.is_some_and(|max| !file.size().is_finite() || file.size() > max as f64) {
|
||||
return Err(format!("{name}: the file is too large"));
|
||||
}
|
||||
let bytes = blob_bytes(&file).await.map_err(|e| format!("{name}: {e}"))?;
|
||||
let path = format!("{UPLOAD}{name}");
|
||||
vfs::insert(&path, bytes);
|
||||
Ok(Some(path))
|
||||
};
|
||||
match read.await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
files.say(format!("open: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page runs on an iPhone or iPad, whose file inputs offer every file (`disk::input_accept`).
|
||||
fn apple_touch() -> bool {
|
||||
web_sys::window().is_some_and(|w| {
|
||||
let nav = w.navigator();
|
||||
disk::apple_touch(&nav.user_agent().unwrap_or_default(), nav.max_touch_points())
|
||||
})
|
||||
}
|
||||
|
||||
/// Show a hidden `<input type=file>` offering `accept` (call in the click). The promise answers
|
||||
/// the picked `File`, or `null` when the dialog is closed (the input's `cancel` event).
|
||||
fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("this page can't show a file dialog")?;
|
||||
let input = doc.create_element("input").map_err(err)?.dyn_into::<web_sys::HtmlInputElement>().map_err(|_| "no file input".to_string())?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
input.set_attribute("style", "display:none").map_err(err)?;
|
||||
doc.body().ok_or("this page has no body")?.append_child(&input).map_err(err)?;
|
||||
let promise = js_sys::Promise::new(&mut |resolve: Function, _reject: Function| {
|
||||
// One answer for a pick or a cancel; read from the event, so it does not keep the input.
|
||||
let done = Closure::once_into_js(move |event: web_sys::Event| {
|
||||
let file = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()).and_then(|input| {
|
||||
input.set_onchange(None);
|
||||
input.set_oncancel(None);
|
||||
input.remove();
|
||||
input.files().and_then(|list| list.get(0))
|
||||
});
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &file.map_or(JsValue::NULL, JsValue::from));
|
||||
});
|
||||
input.set_onchange(Some(done.unchecked_ref()));
|
||||
input.set_oncancel(Some(done.unchecked_ref()));
|
||||
});
|
||||
input.click();
|
||||
Ok(promise)
|
||||
}
|
||||
|
||||
/// Read files dropped on the page.
|
||||
/// `theme`: the pointer is over Preferences' Import tile, so the files are themes.
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
|
||||
let intent = if theme { Intent::Theme } else { Intent::Insert };
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(b) => files.arrive(name, b, intent),
|
||||
Ok(b) => files.arrive(name, b, Intent::Insert),
|
||||
Err(e) => files.say(format!("{name}: {e}"), true),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a
|
||||
//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event
|
||||
//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for
|
||||
//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it
|
||||
//! gets its steps, unless the app is already running installed.
|
||||
|
||||
use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps};
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
|
||||
/// Where index.html keeps the browser's offer.
|
||||
const OFFER: &str = "solidcraftInstall";
|
||||
|
||||
/// The browser's offer, kept by index.html (None: none, or it was used).
|
||||
fn offer() -> Option<js_sys::Object> {
|
||||
let window = web_sys::window()?;
|
||||
js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok()
|
||||
}
|
||||
|
||||
/// Show the browser's install prompt (from the File menu click, so the page may).
|
||||
fn prompt() {
|
||||
let Some(offer) = offer() else { return };
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
|
||||
}
|
||||
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
|
||||
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
|
||||
.and_then(|f| f.call0(&offer))
|
||||
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
|
||||
match shown {
|
||||
// Accepted or dismissed, the browser handles it; a refusal is only logged.
|
||||
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("install prompt: {e:?}");
|
||||
}
|
||||
}),
|
||||
Err(e) => log::warn!("install prompt: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page runs as the installed app (its own window, or from the Home Screen on iOS).
|
||||
fn running_installed(window: &web_sys::Window) -> bool {
|
||||
let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches());
|
||||
let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
standalone || home_screen
|
||||
}
|
||||
|
||||
/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser
|
||||
/// makes or withdraws its offer (even with nothing else happening).
|
||||
pub fn service(ctx: &egui::Context) -> InstallApp {
|
||||
// Safari's steps, decided once: the browser and the way the page runs don't change.
|
||||
let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| {
|
||||
let nav = w.navigator();
|
||||
let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points())
|
||||
});
|
||||
if let Some(window) = web_sys::window() {
|
||||
for event in ["beforeinstallprompt", "appinstalled"] {
|
||||
let ctx = ctx.clone();
|
||||
let redraw = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
redraw.forget();
|
||||
}
|
||||
}
|
||||
let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) };
|
||||
InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) }
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
//! Bounded HTTPS or same-origin downloads, handed to the normal design-open command as unsaved copies.
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use js_sys::{Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{SolveApp, ui_kit};
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
|
||||
use crate::{check_download_size, design_url};
|
||||
|
||||
type Reply = Result<(String, Vec<u8>), String>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Download {
|
||||
reply: Rc<RefCell<Option<Reply>>>,
|
||||
pending: Option<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Download {
|
||||
pub fn start(url: Option<&str>, ctx: &egui::Context) -> Self {
|
||||
let download = Self::default();
|
||||
if let Some(url) = url {
|
||||
let url = url.to_owned();
|
||||
let slot = download.reply.clone();
|
||||
let ctx = ctx.clone();
|
||||
spawn_local(async move {
|
||||
*slot.borrow_mut() = Some(fetch(&url).await);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
download
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Some(reply) = self.reply.borrow_mut().take() {
|
||||
match reply {
|
||||
Ok(file) => self.pending = Some(file),
|
||||
Err(error) => app.set_status(format!("Could not open design link: {error}"), true),
|
||||
}
|
||||
}
|
||||
if self.pending.is_none() || app.file_picker_busy() {
|
||||
return;
|
||||
}
|
||||
if solidcraft_ui_egui::documents::any_dirty(app) {
|
||||
let mut answer = None;
|
||||
let closed = ui_kit::Window::new(egui::Id::new("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
match answer {
|
||||
Some(true) => {}
|
||||
Some(false) => {
|
||||
self.pending = None;
|
||||
return;
|
||||
}
|
||||
None if closed => {
|
||||
self.pending = None;
|
||||
return;
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
let Some((name, bytes)) = self.pending.take() else { return };
|
||||
// The extension selects the design loader even when the URL has no extension.
|
||||
let path = &format!("/linked/design.{}", solidcraft_engine::io::DESIGN_EXT);
|
||||
vfs::insert(path, bytes);
|
||||
app.open_path(path);
|
||||
if app.session.path.as_deref() == Some(path.as_str()) {
|
||||
app.session.path = None;
|
||||
app.session.doc_mut().name = name;
|
||||
app.session.mark_unsaved();
|
||||
} else {
|
||||
app.set_status("Could not open design link: the downloaded file is not a valid SolidCraft design", true);
|
||||
}
|
||||
let _ = vfs::remove(path);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch(value: &str) -> Reply {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let page_origin = window.location().origin().map_err(|_| "Cannot read page origin")?;
|
||||
let url = design_url(value, &page_origin)?;
|
||||
let name = url.path_segments().and_then(|mut segments| segments.next_back()).filter(|name| !name.is_empty()).unwrap_or("Design");
|
||||
let decoded = js_sys::decode_uri_component(name).ok().and_then(|name| name.as_string()).unwrap_or_else(|| name.to_owned());
|
||||
// `arm.sc` (or a legacy `arm.solidcraft`) is named `arm`.
|
||||
let stem = match decoded.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() && solidcraft_engine::io::is_design_ext(ext) => stem,
|
||||
_ => decoded.as_str(),
|
||||
};
|
||||
let name: String = stem.chars().take(255).collect();
|
||||
let response = JsFuture::from(window.fetch_with_str(url.as_str())).await.map_err(|_| "Network request failed or the host refused CORS access")?;
|
||||
let response: web_sys::Response = response.dyn_into().map_err(|_| "Invalid download response")?;
|
||||
design_url(&response.url(), &page_origin).map_err(|_| "Design redirected to a URL that does not use HTTPS or the page's own origin")?;
|
||||
if !response.ok() {
|
||||
return Err(format!("HTTP {} {}", response.status(), response.status_text()));
|
||||
}
|
||||
let length = response
|
||||
.headers()
|
||||
.get("Content-Length")
|
||||
.map_err(|_| "Cannot read download size")?
|
||||
.map(|value| value.parse::<u64>().map_err(|_| "Invalid Content-Length"))
|
||||
.transpose()?;
|
||||
if let Some(length) = length {
|
||||
check_download_size(0, length)?;
|
||||
}
|
||||
let stream = response.body().ok_or("Download has no body")?;
|
||||
let reader: web_sys::ReadableStreamDefaultReader = stream.get_reader().dyn_into().map_err(|_| "Cannot read download stream")?;
|
||||
let result = read(&reader).await;
|
||||
if result.is_err() {
|
||||
let _ = JsFuture::from(reader.cancel()).await;
|
||||
}
|
||||
// Fetch rejects interrupted HTTP bodies and decodes Content-Encoding. Content-Length
|
||||
// can describe compressed bytes, so the streamed limit also checks the decoded size.
|
||||
let bytes = result?;
|
||||
Ok((name, bytes))
|
||||
}
|
||||
|
||||
async fn read(reader: &web_sys::ReadableStreamDefaultReader) -> Result<Vec<u8>, String> {
|
||||
let mut bytes = Vec::new();
|
||||
// Bound even a stream which keeps returning empty chunks.
|
||||
for _ in 0..1_000_000 {
|
||||
let chunk = JsFuture::from(reader.read()).await.map_err(|_| "Download interrupted")?;
|
||||
if Reflect::get(&chunk, &JsValue::from_str("done")).map_err(|_| "Invalid download stream")?.as_bool() == Some(true) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let value = Reflect::get(&chunk, &JsValue::from_str("value")).map_err(|_| "Invalid download chunk")?;
|
||||
let chunk: Uint8Array = value.dyn_into().map_err(|_| "Invalid download bytes")?;
|
||||
check_download_size(bytes.len(), u64::from(chunk.length()))?;
|
||||
bytes.extend_from_slice(&chunk.to_vec());
|
||||
}
|
||||
Err("Download returned too many chunks".into())
|
||||
}
|
||||
+101
-735
File diff suppressed because it is too large
Load Diff
@@ -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,102 +7,27 @@ license.workspace = true
|
||||
publish = false
|
||||
default-run = "solidcraft"
|
||||
|
||||
[features]
|
||||
default = ["raytrace-cpu", "bundled-render-library"]
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
|
||||
|
||||
# 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), src/native_crash_macos.rs
|
||||
# (macOS crash signal handler) and src/native_frame.rs (Windows resize border, taskbar icon) 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 }
|
||||
# The D3D12 shader compiler the window starts with (#6756).
|
||||
solidcraft-gpu = { 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 = { 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, DPI size and class icon (src/native_frame.rs): the window procedure, hit
|
||||
# test, DPI, the exe's icon.
|
||||
# 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), and the fullscreen title-bar
|
||||
# inset (src/main.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
# Native crash capture (src/native_crash_macos.rs): the signal handler, the faulting module
|
||||
# (already in the tree through many crates).
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
# Bringing the window forward on X11 when a close asks about unsaved designs (src/come_forward.rs):
|
||||
# the window's id, and the window manager request winit doesn't send (MIT OR Apache-2.0; both
|
||||
# already in the tree through eframe and winit).
|
||||
raw-window-handle = "0.6"
|
||||
x11rb = { version = "0.13", default-features = false }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
|
||||
@@ -4,7 +4,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
|
||||
println!("cargo:rerun-if-changed={}", icon.display());
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -12,21 +11,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let copy = out.join("solidcraft.ico");
|
||||
std::fs::copy(icon, ©)?;
|
||||
let rc = out.join("solidcraft.rc");
|
||||
let icon_line = format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/"));
|
||||
std::fs::write(&rc, format!("{icon_line}{}", version_resource(&std::env::var("CARGO_PKG_VERSION")?)))?;
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
|
||||
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Windows version resource (what Explorer's Details tab shows and what a launcher reads).
|
||||
/// `FileVersion` and `ProductVersion` are the package version verbatim (`0.25.1`, `0.26.0-rc.1`):
|
||||
/// ArtCraft's launcher parses the `ProductVersion` string as semver, so a fourth `.0` would not
|
||||
/// read. The binary `FILEVERSION` fields need four numbers and take the leading `major.minor.patch`.
|
||||
fn version_resource(version: &str) -> String {
|
||||
let mut numbers = version.split(['-', '+']).next().unwrap_or("").split('.').map(|p| p.parse::<u16>().unwrap_or(0));
|
||||
let mut next = || numbers.next().unwrap_or(0);
|
||||
let (major, minor, patch) = (next(), next(), next());
|
||||
format!(
|
||||
"1 VERSIONINFO\nFILEVERSION {major},{minor},{patch},0\nPRODUCTVERSION {major},{minor},{patch},0\nFILEOS 0x40004\nFILETYPE 0x1\n{{\nBLOCK \"StringFileInfo\"\n{{\nBLOCK \"040904B0\"\n{{\nVALUE \"CompanyName\", \"SolidCraft contributors\"\nVALUE \"FileDescription\", \"SolidCraft, parametric 3D CAD\"\nVALUE \"FileVersion\", \"{version}\"\nVALUE \"InternalName\", \"solidcraft\"\nVALUE \"OriginalFilename\", \"solidcraft.exe\"\nVALUE \"ProductName\", \"SolidCraft\"\nVALUE \"ProductVersion\", \"{version}\"\n}}\n}}\nBLOCK \"VarFileInfo\"\n{{\nVALUE \"Translation\", 0x0409, 1200\n}}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Bringing the window forward when a close stops at "save changes?".
|
||||
//!
|
||||
//! A close from the taskbar or a window switcher reaches the window while another app has the
|
||||
//! focus. The prompt it opens is only seen once the window comes forward, so the window asks for
|
||||
//! the focus then. A window the user is already in is left alone.
|
||||
//!
|
||||
//! winit's Focus asks as an application, which a window manager with focus-stealing prevention
|
||||
//! (KWin's default) refuses for a window used before the switch away: it only marks the taskbar
|
||||
//! entry. On X11 the request is sent again with the source EWMH gives "direct user actions"
|
||||
//! (`_NET_ACTIVE_WINDOW`, source 2), which is what this is: the prompt is the rest of the user's
|
||||
//! close. Native Wayland has no way for a window to raise itself, so there the attention request
|
||||
//! (xdg-activation) lets the compositor raise it or mark its taskbar entry.
|
||||
//!
|
||||
//! When: not at once. KWin raises the window, then the taskbar's closing context menu hands the
|
||||
//! focus back to the app that had it, a few milliseconds later, and the window would flicker
|
||||
//! forward and back. So the window asks once the menu is gone, `FIRST_AFTER` the close. It asks
|
||||
//! again only if the focus is taken back right after it came (a slow menu) or never came; a later
|
||||
//! switch is the user's own, and the window stays where the user put it.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// From the close to the first request: past the taskbar menu's focus hand-back (2 to 11 ms on
|
||||
/// KDE Plasma 6), too short to notice.
|
||||
const FIRST_AFTER: Duration = Duration::from_millis(120);
|
||||
/// After the first request, how long the window asks again when it loses the focus: short of
|
||||
/// anything the user does next.
|
||||
const KEEP_ASKING: Duration = Duration::from_millis(400);
|
||||
/// The least time between two requests.
|
||||
const AGAIN_AFTER: Duration = Duration::from_millis(100);
|
||||
/// Focus lost this soon after a request was taken back by the taskbar menu; later, by the user.
|
||||
const TAKEN_BACK_WITHIN: Duration = Duration::from_millis(50);
|
||||
|
||||
/// The window coming forward after a close that asks about unsaved designs.
|
||||
#[derive(Default)]
|
||||
pub struct ComeForward {
|
||||
/// The first request and the end of asking; `None`: not asking.
|
||||
window: Option<(Instant, Instant)>,
|
||||
/// The last request.
|
||||
last: Option<Instant>,
|
||||
/// The window has had the focus since the last request.
|
||||
held: bool,
|
||||
}
|
||||
|
||||
/// What a frame does while the window is coming forward.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Step {
|
||||
/// Ask for the focus now.
|
||||
Ask,
|
||||
/// Look again after this long.
|
||||
Wait(Duration),
|
||||
/// Done asking.
|
||||
Done,
|
||||
}
|
||||
|
||||
impl ComeForward {
|
||||
/// A close stopped at the prompt: ask for the focus shortly.
|
||||
pub fn start(&mut self, now: Instant) {
|
||||
let first = now + FIRST_AFTER;
|
||||
self.window = Some((first, first + KEEP_ASKING));
|
||||
self.last = None;
|
||||
self.held = false;
|
||||
}
|
||||
|
||||
/// Every frame: ask once the taskbar menu is gone, and again while the focus is taken back.
|
||||
pub fn tick(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
|
||||
if self.window.is_none() {
|
||||
return;
|
||||
}
|
||||
let (focused, minimized) = ctx.input(|i| (i.viewport().focused, i.viewport().minimized));
|
||||
// The app may be idle: a frame to look again.
|
||||
match self.step(Instant::now(), focused) {
|
||||
Step::Ask => {
|
||||
bring(ctx, frame, minimized);
|
||||
ctx.request_repaint_after(AGAIN_AFTER);
|
||||
}
|
||||
Step::Wait(after) => ctx.request_repaint_after(after),
|
||||
Step::Done => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, now: Instant, focused: Option<bool>) -> Step {
|
||||
let Some((first, until)) = self.window else { return Step::Done };
|
||||
if now >= until {
|
||||
self.window = None;
|
||||
return Step::Done;
|
||||
}
|
||||
if now < first {
|
||||
return Step::Wait(first - now);
|
||||
}
|
||||
let Some(last) = self.last else {
|
||||
if focused == Some(true) {
|
||||
// The user went back to the window already.
|
||||
self.window = None;
|
||||
return Step::Done;
|
||||
}
|
||||
self.last = Some(now);
|
||||
return Step::Ask;
|
||||
};
|
||||
if focused == Some(true) {
|
||||
self.held = true;
|
||||
return Step::Wait(until - now);
|
||||
}
|
||||
if self.held {
|
||||
if now.duration_since(last) >= TAKEN_BACK_WITHIN {
|
||||
// The user switched away: leave the window there.
|
||||
self.window = None;
|
||||
return Step::Done;
|
||||
}
|
||||
self.held = false;
|
||||
}
|
||||
let next = last + AGAIN_AFTER;
|
||||
if now < next {
|
||||
return Step::Wait(next - now);
|
||||
}
|
||||
self.last = Some(now);
|
||||
Step::Ask
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the focus once.
|
||||
fn bring(ctx: &egui::Context, frame: &eframe::Frame, minimized: Option<bool>) {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let raised = match x11_activate(frame) {
|
||||
Ok(sent) => sent,
|
||||
Err(e) => {
|
||||
log::warn!("bringing the window forward through X11: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let raised = {
|
||||
let _ = frame;
|
||||
false
|
||||
};
|
||||
for c in commands(minimized, raised) {
|
||||
ctx.send_viewport_cmd(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport commands for a window in the background; `raised`: the X11 request went out,
|
||||
/// which also restores a minimized window.
|
||||
fn commands(minimized: Option<bool>, raised: bool) -> Vec<egui::ViewportCommand> {
|
||||
if raised {
|
||||
return vec![egui::ViewportCommand::Focus];
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
if minimized == Some(true) {
|
||||
commands.push(egui::ViewportCommand::Minimized(false));
|
||||
}
|
||||
commands.push(egui::ViewportCommand::Focus);
|
||||
commands.push(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
||||
commands
|
||||
}
|
||||
|
||||
/// Ask the X11 window manager to activate the window as a user action. `Ok(false)`: the window
|
||||
/// isn't an X11 window (native Wayland).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn x11_activate(frame: &eframe::Frame) -> Result<bool, String> {
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{ClientMessageEvent, ConnectionExt, EventMask};
|
||||
|
||||
let handle = frame.window_handle().map_err(|e| e.to_string())?;
|
||||
let window = match handle.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => u32::try_from(h.window).map_err(|e| e.to_string())?,
|
||||
RawWindowHandle::Xcb(h) => h.window.get(),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
// A connection of its own: winit's isn't reachable, and a close that asks is rare.
|
||||
let (conn, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
|
||||
let root = conn.setup().roots.get(screen).ok_or("no X11 screen")?.root;
|
||||
let atom = conn.intern_atom(false, b"_NET_ACTIVE_WINDOW").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
|
||||
// Source 2: a direct user action; the time is the server's current time.
|
||||
let event = ClientMessageEvent::new(32, window, atom, [2, x11rb::CURRENT_TIME, 0, 0, 0]);
|
||||
conn.send_event(false, root, EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, event).map_err(|e| e.to_string())?;
|
||||
conn.flush().map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AGAIN_AFTER, ComeForward, FIRST_AFTER, KEEP_ASKING, Step};
|
||||
use eframe::egui::{UserAttentionType, ViewportCommand as C};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The first request waits until the taskbar menu has handed the focus back. The window asks
|
||||
/// again when the focus is taken back right after it came, or never came, no more often than
|
||||
/// every `AGAIN_AFTER` and only for `KEEP_ASKING`; a later switch away is the user's.
|
||||
#[test]
|
||||
fn the_window_asks_once_the_taskbar_menu_is_gone() {
|
||||
let t0 = Instant::now();
|
||||
let ms = |n: u64| Duration::from_millis(n);
|
||||
let at = |n: u64| t0 + ms(n);
|
||||
let (first, again, keep) = (FIRST_AFTER.as_millis() as u64, AGAIN_AFTER.as_millis() as u64, KEEP_ASKING.as_millis() as u64);
|
||||
let mut f = ComeForward::default();
|
||||
assert_eq!(f.step(t0, Some(false)), Step::Done, "not started");
|
||||
f.start(t0);
|
||||
assert_eq!(f.step(t0, Some(false)), Step::Wait(FIRST_AFTER), "the menu is still closing");
|
||||
assert_eq!(f.step(at(5), Some(false)), Step::Wait(ms(first - 5)));
|
||||
assert_eq!(f.step(at(first), Some(false)), Step::Ask);
|
||||
assert_eq!(f.step(at(first + 3), Some(true)), Step::Wait(ms(keep - 3)), "raised");
|
||||
// A slow menu takes the focus back 10 ms later: ask again, not before AGAIN_AFTER.
|
||||
assert_eq!(f.step(at(first + 10), Some(false)), Step::Wait(ms(again - 10)));
|
||||
assert_eq!(f.step(at(first + again), Some(false)), Step::Ask);
|
||||
assert_eq!(f.step(at(first + again + 3), Some(true)), Step::Wait(ms(keep - again - 3)));
|
||||
// The user clicks another app 200 ms later: the window stays behind.
|
||||
assert_eq!(f.step(at(first + again + 200), Some(false)), Step::Done);
|
||||
assert_eq!(f.step(at(first + again + 300), Some(false)), Step::Done);
|
||||
|
||||
// Never raised (the request was refused): ask again every AGAIN_AFTER, for KEEP_ASKING.
|
||||
f.start(t0);
|
||||
assert_eq!(f.step(at(first), None), Step::Ask, "unknown focus counts as out of focus");
|
||||
assert_eq!(f.step(at(first + again), Some(false)), Step::Ask);
|
||||
assert_eq!(f.step(at(first + keep), Some(false)), Step::Done);
|
||||
|
||||
// Back in the window before the first request: nothing to do.
|
||||
f.start(t0);
|
||||
assert_eq!(f.step(at(first), Some(true)), Step::Done);
|
||||
}
|
||||
|
||||
/// Without the X11 request: focus, and the attention mark where focus is refused; a minimized
|
||||
/// window is restored first.
|
||||
#[test]
|
||||
fn a_window_closed_from_the_background_comes_forward() {
|
||||
let forward = [C::Focus, C::RequestUserAttention(UserAttentionType::Informational)];
|
||||
assert_eq!(super::commands(Some(false), false), forward);
|
||||
assert_eq!(super::commands(None, false), forward, "unknown counts as not minimized");
|
||||
let minimized = super::commands(Some(true), false);
|
||||
assert_eq!(minimized.first(), Some(&C::Minimized(false)));
|
||||
assert_eq!(minimized.get(1..), Some(&forward[..]));
|
||||
// The window manager took the X11 request: nothing left to mark.
|
||||
assert_eq!(super::commands(Some(true), true), [C::Focus]);
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,23 @@
|
||||
|
||||
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;
|
||||
use solidcraft_ui_egui::control::{DESIGN_WATCH, DesignWatch, Subscriber};
|
||||
|
||||
/// Connections served at once, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
/// How long a subscribed connection waits after a `design.changed` line before the next: what
|
||||
/// changes meanwhile (a parameter dragged) goes out as one line, the latest (#6985).
|
||||
const EVENT_GAP: Duration = Duration::from_millis(100);
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
|
||||
/// token in each, removed when the app ends unless another launch has written the file since.
|
||||
static TOKEN_FILES: std::sync::Mutex<Vec<(std::path::PathBuf, String)>> = 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> {
|
||||
@@ -35,86 +28,32 @@ fn new_token() -> Option<String> {
|
||||
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Remove this launch's token files and discovery file (at exit), leaving any that another
|
||||
/// launch has written since (#8794).
|
||||
/// Remove this launch's token file (at exit).
|
||||
pub fn remove_token_file() {
|
||||
if let Ok(files) = TOKEN_FILES.lock() {
|
||||
for (path, token) in files.iter() {
|
||||
control_auth::remove_token_file(path, token);
|
||||
}
|
||||
}
|
||||
if let Some((port, token)) = APP_PORT.lock().ok().and_then(|p| p.clone()) {
|
||||
control_auth::remove_current_port(port, &token);
|
||||
if let Some(p) = TOKEN_FILE.get() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
|
||||
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = 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.clone(), 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, token));
|
||||
}
|
||||
}
|
||||
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, token.clone()));
|
||||
}
|
||||
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}); \
|
||||
@@ -132,19 +71,19 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = send(&mut refused, &json!({"ok": false, "error": "too many connections"}));
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &token, tx, ctx, patient);
|
||||
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
|
||||
@@ -179,44 +118,6 @@ fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply line, written to the socket at once. `writeln!` with a JSON value hands the socket each
|
||||
/// of its pieces apart, a system call for every string, number and comma: half a second for a
|
||||
/// design's component tree when it has thousands of components.
|
||||
fn send(out: &mut impl Write, reply: &Value) -> std::io::Result<()> {
|
||||
let mut line = serde_json::to_vec(reply).map_err(std::io::Error::other)?;
|
||||
line.push(b'\n');
|
||||
out.write_all(&line)
|
||||
}
|
||||
|
||||
/// The `"token"` of a request line that is a JSON object, read without building the rest of it.
|
||||
/// Until a connection shows the token, a line costs only its own bytes: parsed whole, a 16 MiB
|
||||
/// line of `[0,0,…]` is hundreds of MB, and 16 such connections could exhaust memory before any
|
||||
/// token was checked.
|
||||
fn token_of(line: &str) -> Option<String> {
|
||||
struct Token;
|
||||
impl<'de> serde::de::Visitor<'de> for Token {
|
||||
type Value = Option<String>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a request object")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut token = None;
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if key == "token" {
|
||||
token = Some(map.next_value::<String>()?);
|
||||
} else {
|
||||
map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
let mut de = serde_json::Deserializer::from_str(line);
|
||||
let token = serde::Deserializer::deserialize_map(&mut de, Token).ok()?;
|
||||
de.end().ok()?;
|
||||
token
|
||||
}
|
||||
|
||||
/// The reply to a connection that didn't show the token; the connection is then closed.
|
||||
fn unauthorized() -> Value {
|
||||
json!({"ok": false, "error": "unauthorized: send the control token as \"token\" (see docs/control-protocol.md)"})
|
||||
@@ -227,408 +128,68 @@ fn unauthorized() -> Value {
|
||||
/// the connection, and a reset can reach the client before it has read the refusal.
|
||||
fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
|
||||
use std::io::Read;
|
||||
let _ = send(out, &unauthorized());
|
||||
let _ = writeln!(out, "{}", unauthorized());
|
||||
let _ = out.shutdown(std::net::Shutdown::Write);
|
||||
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_millis(500)));
|
||||
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
|
||||
}
|
||||
|
||||
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
|
||||
/// on a patient connection).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Waits {
|
||||
short: Duration,
|
||||
long: Duration,
|
||||
}
|
||||
|
||||
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
|
||||
|
||||
/// A request that timed out but is still running in the app (#1618): its id and method, and where
|
||||
/// its reply will land.
|
||||
struct Pending {
|
||||
id: Value,
|
||||
method: String,
|
||||
/// 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
|
||||
)
|
||||
}
|
||||
|
||||
/// A subscribed connection's socket, for its replies and its events: each line is written whole
|
||||
/// and alone, so an event never lands inside a reply (#6985).
|
||||
struct Lines {
|
||||
socket: TcpStream,
|
||||
turn: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
fn send(&self, line: &Value) -> std::io::Result<()> {
|
||||
let _turn = self.turn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
send(&mut &self.socket, line)
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of
|
||||
/// their own until the connection ends.
|
||||
struct Subscription {
|
||||
lines: Arc<Lines>,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Write `reply`, the window's answer to `control.subscribe` naming the design `seen`, on
|
||||
/// `out`, then send `design`'s changes after it: the reply goes out before any event. `None`,
|
||||
/// with nothing written, when no thread or socket was left for the events.
|
||||
fn start(
|
||||
design: &'static DesignWatch,
|
||||
counted: Subscriber,
|
||||
out: &TcpStream,
|
||||
seen: (String, u64),
|
||||
reply: &Value,
|
||||
) -> Option<(Self, std::io::Result<()>)> {
|
||||
let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) });
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let (l, s) = (lines.clone(), stop.clone());
|
||||
// The connection's seat on the watch rides the events thread: it leaves when the thread
|
||||
// does, so the next failed write to a closed client retires it even while the connection's
|
||||
// own thread is slow to see the end of the stream (#7226).
|
||||
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, counted)).ok()?;
|
||||
let sent = send(&mut &lines.socket, reply);
|
||||
drop(first);
|
||||
Some((Subscription { lines, stop }, sent))
|
||||
}
|
||||
}
|
||||
|
||||
/// The design a `control.subscribe` reply names.
|
||||
fn design_of(reply: &Value) -> Option<(String, u64)> {
|
||||
let result = reply.get("result")?;
|
||||
Some((result.get("document_id")?.as_str()?.to_string(), result.get("revision")?.as_u64()?))
|
||||
}
|
||||
|
||||
impl Drop for Subscription {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// The socket closes now, not when the thread next looks; a line blocked on a client that
|
||||
// stopped reading ends too.
|
||||
let _ = self.lines.socket.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `design.changed` lines of a subscribed connection, each time the design differs from the
|
||||
/// last one it was told of (`seen`), until `stop`. Owns the connection's seat on the watch
|
||||
/// (`counted`), so the seat leaves exactly when this thread does: on `stop`, or on the first
|
||||
/// write a closed client refuses (#7226).
|
||||
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), _counted: Subscriber) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue };
|
||||
if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() {
|
||||
break;
|
||||
}
|
||||
seen = now;
|
||||
std::thread::sleep(EVENT_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// What this app's control channel does beyond the methods every version has, for a client to
|
||||
/// find out before it relies on it (an older app answers `control.capabilities` with "unknown
|
||||
/// method"). `param_check`: the levels `engine.execute`, `engine.batch` and `engine.script` take
|
||||
/// as a request's own parameter check (#9184).
|
||||
fn capabilities() -> Value {
|
||||
json!({"param_check": solidcraft_engine::schema::ParamCheck::ALL.map(|l| l.name())})
|
||||
}
|
||||
|
||||
/// Write `reply` on the connection: through its subscription once it has one.
|
||||
fn answer(out: &mut TcpStream, events: Option<&Subscription>, reply: &Value) -> std::io::Result<()> {
|
||||
match events {
|
||||
Some(e) => e.lines.send(reply),
|
||||
None => send(out, reply),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING, &DESIGN_WATCH);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn serve_with(
|
||||
stream: TcpStream,
|
||||
token: &str,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
waits: Waits,
|
||||
running: &'static Running,
|
||||
design: &'static DesignWatch,
|
||||
) {
|
||||
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;
|
||||
let mut events: Option<Subscription> = None;
|
||||
loop {
|
||||
// A first `control.subscribe` on its way to the window (#6985).
|
||||
let mut joining = None;
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = answer(&mut out, events.as_ref(), &json!({"ok": false, "error": e.to_string()}));
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The first request must carry the token; later ones on the same connection may.
|
||||
if !authenticated {
|
||||
match token_of(&line) {
|
||||
Some(t) if control_auth::tokens_match(&t, token) => authenticated = true,
|
||||
_ => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
// The first request must carry the token; later ones on the same connection may.
|
||||
if !authenticated {
|
||||
match msg.get("token").and_then(Value::as_str) {
|
||||
Some(t) if control_auth::tokens_match(t, token) => authenticated = true,
|
||||
_ => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// A request that timed out may have finished since (#1618). `control.running` and
|
||||
// `control.capabilities` only look, so they leave that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| !matches!(method.as_str(), "control.running" | "control.capabilities")).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.capabilities" {
|
||||
json!({"ok": true, "result": capabilities()})
|
||||
} 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 method == "control.subscribe" && events.is_some() {
|
||||
if late.is_some() {
|
||||
// As below: finished, and the client moved on without asking.
|
||||
pending = None;
|
||||
}
|
||||
// Its events already say how the design moves; a second answer could only
|
||||
// arrive behind a newer one.
|
||||
json!({"ok": false, "error": "already subscribed: this connection is sent design.changed lines; ask document.revision for the design now"})
|
||||
} 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);
|
||||
// Counted before the window answers, so it publishes every change from its
|
||||
// answer on.
|
||||
joining = (method == "control.subscribe").then(|| design.join());
|
||||
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);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(_) if !authenticated => {
|
||||
refuse(&mut out, &mut reader);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
// Subscribed once the window named the design (not when it refused or timed out).
|
||||
let sent = match joining.and_then(|counted| design_of(&reply).map(|seen| (counted, seen))) {
|
||||
Some((counted, seen)) => match Subscription::start(design, counted, &out, seen, &reply) {
|
||||
Some((subscription, sent)) => {
|
||||
events = Some(subscription);
|
||||
sent
|
||||
}
|
||||
None => {
|
||||
send(&mut out, &json!({"id": reply.get("id"), "ok": false, "error": "not subscribed: no thread or socket left for the events"}))
|
||||
}
|
||||
},
|
||||
None => answer(&mut out, events.as_ref(), &reply),
|
||||
};
|
||||
if sent.is_err() {
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
@@ -641,302 +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, &DESIGN_WATCH);
|
||||
}
|
||||
});
|
||||
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, &DESIGN_WATCH));
|
||||
}
|
||||
});
|
||||
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}");
|
||||
// What the app's control channel does beyond the methods every version has (#9184).
|
||||
let r = watcher(json!({"id": 0, "method": "control.capabilities", "token": token}));
|
||||
assert_eq!((&r["ok"], &r["result"]["param_check"]), (&json!(true), &json!(["off", "warn", "strict"])), "{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}");
|
||||
// So may it ask what the app can do: answered at once, not refused as busy.
|
||||
let r = export(json!({"id": 8, "method": "control.capabilities"}));
|
||||
assert_eq!(r["result"]["param_check"], json!(["off", "warn", "strict"]), "{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}");
|
||||
}
|
||||
|
||||
/// #6985: a connection that sends `control.subscribe` gets the design in the reply, then a
|
||||
/// `design.changed` line each time the window publishes another: never before the reply,
|
||||
/// none for the same design, the latest after a burst, each line whole between its replies.
|
||||
/// A second subscribe is refused without reaching the window. A connection that did not
|
||||
/// subscribe gets no events, and one that closed is no longer counted.
|
||||
#[test]
|
||||
fn subscribed_connections_are_told_of_design_changes() {
|
||||
static RUNNING: Running = Running::new();
|
||||
static WATCH: DesignWatch = DesignWatch::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: it answers `control.subscribe` as the app does, publishing the design first;
|
||||
// then the design changes at once, before the reply may have been written.
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
if req.method == "control.subscribe" {
|
||||
WATCH.publish("d-1", 3);
|
||||
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
|
||||
WATCH.publish("d-1", 4);
|
||||
} else {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
}
|
||||
});
|
||||
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, &WATCH));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
(s.try_clone().unwrap(), BufReader::new(s))
|
||||
};
|
||||
let next = |lines: &mut BufReader<TcpStream>| -> Value {
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let changed = |id: &str, revision: u64| json!({"event": "design.changed", "params": {"document_id": id, "revision": revision}});
|
||||
|
||||
let (mut quiet_out, mut quiet) = open();
|
||||
writeln!(quiet_out, "{}", json!({"id": 1, "method": "ui.view", "token": token})).unwrap();
|
||||
assert_eq!(next(&mut quiet)["id"], 1);
|
||||
|
||||
let (mut out, mut lines) = open();
|
||||
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
|
||||
assert_eq!(next(&mut lines), json!({"id": 1, "ok": true, "result": {"document_id": "d-1", "revision": 3}}));
|
||||
assert_eq!(next(&mut lines), changed("d-1", 4), "the change after the window's answer comes after the reply");
|
||||
assert!(WATCH.subscribed());
|
||||
// The design it was told of is no news; another revision is.
|
||||
WATCH.publish("d-1", 4);
|
||||
WATCH.publish("d-1", 5);
|
||||
assert_eq!(next(&mut lines), changed("d-1", 5));
|
||||
// A burst ends with its latest.
|
||||
for revision in 6..=10 {
|
||||
WATCH.publish("d-1", revision);
|
||||
}
|
||||
let mut last = 5;
|
||||
while last < 10 {
|
||||
let e = next(&mut lines);
|
||||
let revision = e["params"]["revision"].as_u64().unwrap();
|
||||
assert!(e["event"] == "design.changed" && revision > last, "{e} after {last}");
|
||||
last = revision;
|
||||
}
|
||||
// Another tab: another id, whatever its revision.
|
||||
WATCH.publish("d-2", 2);
|
||||
assert_eq!(next(&mut lines), changed("d-2", 2));
|
||||
// Subscribed already: refused here (the window would have published d-1 again).
|
||||
writeln!(out, "{}", json!({"id": 2, "method": "control.subscribe"})).unwrap();
|
||||
let r = next(&mut lines);
|
||||
assert!(r["id"] == 2 && r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("already subscribed")), "{r}");
|
||||
// Replies still come, whole, and nothing else was sent meanwhile.
|
||||
writeln!(out, "{}", json!({"id": 3, "method": "ui.view"})).unwrap();
|
||||
assert_eq!(next(&mut lines), json!({"id": 3, "ok": true, "result": "ui.view"}));
|
||||
writeln!(quiet_out, "{}", json!({"id": 2, "method": "ui.view"})).unwrap();
|
||||
assert_eq!(next(&mut quiet), json!({"id": 2, "ok": true, "result": "ui.view"}), "no events without subscribing");
|
||||
// Closed, it is no longer counted. Each publish asks the events thread for one more line,
|
||||
// and its write to the closed connection is what retires the seat; that keeps the wait off
|
||||
// the connection's own thread, which anything else this process is doing may hold up past
|
||||
// the deadline (#7226).
|
||||
drop((out, lines));
|
||||
let mut revision = 11;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while WATCH.subscribed() && Instant::now() < deadline {
|
||||
WATCH.publish("d-1", revision);
|
||||
revision += 1;
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(!WATCH.subscribed());
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
|
||||
/// The token is read from a request object without building the rest of it: other fields,
|
||||
/// however large or nested, are only skipped. Anything but an object with a string token is
|
||||
/// no token.
|
||||
#[test]
|
||||
fn token_is_read_without_parsing_the_request() {
|
||||
assert_eq!(token_of(r#"{"id":1,"token":"abc","params":{"a":[1,[2,{"b":null}]]}}"#).as_deref(), Some("abc"));
|
||||
let flood = format!(r#"{{"params":[{}0],"token":"t"}}"#, "0,".repeat(1 << 20));
|
||||
assert_eq!(token_of(&flood).as_deref(), Some("t"));
|
||||
for no in [r#"["abc"]"#, r#"{"token":5}"#, r#"{"token":"a"} x"#, r#"{"id":1}"#, "GET / HTTP/1.1", r#""token""#] {
|
||||
assert_eq!(token_of(no), None, "{no}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #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.
|
||||
@@ -958,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]| {
|
||||
@@ -1029,31 +294,4 @@ mod tests {
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert!(read_line(&mut r, 1000).is_err());
|
||||
}
|
||||
|
||||
/// A large reply reaches the socket in one write, not one per JSON piece (a component tree of
|
||||
/// thousands of components took half a second to send), and reads back as it was.
|
||||
#[test]
|
||||
fn a_large_reply_is_written_at_once() {
|
||||
struct Counted {
|
||||
writes: usize,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
impl Write for Counted {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.writes += 1;
|
||||
self.bytes.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let components: Vec<Value> = (0..2000).map(|i| json!({"id": i, "name": format!("Part {i}"), "bodies": ["a", "b"], "parent": 0})).collect();
|
||||
let reply = json!({"ok": true, "id": 7, "result": {"components": components}});
|
||||
let mut out = Counted { writes: 0, bytes: Vec::new() };
|
||||
send(&mut out, &reply).unwrap();
|
||||
assert_eq!(out.writes, 1);
|
||||
assert_eq!(out.bytes.last(), Some(&b'\n'));
|
||||
assert_eq!(serde_json::from_slice::<Value>(&out.bytes).unwrap(), reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+97
-1129
File diff suppressed because it is too large
Load Diff
@@ -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,879 +0,0 @@
|
||||
//! macOS: what a crash in native code leaves behind (docs/crash-reports.md).
|
||||
//!
|
||||
//! A crash inside a graphics driver or another library (a segfault, an `abort()`, a stack
|
||||
//! overflow) is no Rust panic: the panic hook never runs and the next start only found "an
|
||||
//! unclean exit". A signal handler for SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP (how a trap
|
||||
//! instruction arrives on Apple silicon) and SIGABRT now writes, before the process goes, the
|
||||
//! same small JSON file as the Windows filter (`native_crash.rs`): the signal, the faulting
|
||||
//! module and the offset into it, and a short frame-pointer walk, next to the run's crash journal
|
||||
//! (`solidcraft_engine::crash::native_paths`). The next start puts it into the unclean exit's
|
||||
//! report. The signal then goes on to the handler that was there before (the standard library's
|
||||
//! stack overflow message) or to the default action, so the process still dies of it and macOS
|
||||
//! still writes its own crash report (`~/Library/Logs/DiagnosticReports`; the file names the
|
||||
//! `pid` the JSON records).
|
||||
//!
|
||||
//! Installing a signal handler and asking dyld which image holds an address have no safe API,
|
||||
//! hence the `unsafe_code` allowance on this module (the app crate denies it). The handler runs
|
||||
//! on a thread that may have a broken heap, or on the small alternate stack after a stack
|
||||
//! overflow: it doesn't allocate, formats into a buffer on the stack, and only reads memory on
|
||||
//! the thread's own stack. It writes what it knows from the signal first and only then calls
|
||||
//! `dladdr` (not on POSIX's async-signal-safe list; it takes dyld's recursive reader lock and
|
||||
//! doesn't allocate) to name the modules, rewriting the file, so a hang or a fault in there
|
||||
//! still leaves a record.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ffi::{CStr, CString, c_int, c_void};
|
||||
use std::fmt::Write as _;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
|
||||
|
||||
use libc::{SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGSEGV, SIGTRAP, siginfo_t};
|
||||
|
||||
/// The signals a crash arrives as, in the order of [`PREVIOUS`].
|
||||
const SIGNALS: [c_int; 6] = [SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP, SIGABRT];
|
||||
/// Frames the walk records (the faulting instruction first).
|
||||
const MAX_FRAMES: usize = 24;
|
||||
/// A fault this far below a thread's stack is taken for a stack overflow.
|
||||
const GUARD: usize = 64 << 10;
|
||||
|
||||
/// Where the handler writes (NUL-terminated), set once by [`install`].
|
||||
static PATH: OnceLock<CString> = OnceLock::new();
|
||||
/// The handler that was installed before ours for each of [`SIGNALS`] (called after ours), as an
|
||||
/// address (`SIG_DFL` is 0, `SIG_IGN` 1), and its `sa_flags`.
|
||||
static PREVIOUS: [AtomicUsize; 6] = [const { AtomicUsize::new(libc::SIG_DFL) }; 6];
|
||||
static PREVIOUS_FLAGS: [AtomicI32; 6] = [const { AtomicI32::new(0) }; 6];
|
||||
/// The handler has started writing (a second signal while it does is only passed on).
|
||||
static BUSY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
|
||||
pub fn install() {
|
||||
let Some((info, _)) = solidcraft_engine::crash::native_paths() else { return };
|
||||
let Ok(path) = CString::new(info.as_os_str().as_bytes()) else { return };
|
||||
if PATH.set(path).is_err() {
|
||||
return;
|
||||
}
|
||||
for (slot, &signal) in SIGNALS.iter().enumerate() {
|
||||
// SAFETY: `sigaction` is plain data; all zeros is a valid (empty) value.
|
||||
let mut ours: libc::sigaction = unsafe { std::mem::zeroed() };
|
||||
ours.sa_sigaction = handler as *const () as usize;
|
||||
// On the alternate stack the standard library gives each of its threads, so that a stack
|
||||
// overflow can still be recorded.
|
||||
ours.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK;
|
||||
// SAFETY: as above.
|
||||
let mut old: libc::sigaction = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: `handler` has the signature SA_SIGINFO calls it with and stays valid for the
|
||||
// process's life; both structs are valid for the call.
|
||||
if unsafe { libc::sigaction(signal, &ours, &mut old) } == 0
|
||||
&& old.sa_sigaction != ours.sa_sigaction
|
||||
&& let (Some(p), Some(f)) = (PREVIOUS.get(slot), PREVIOUS_FLAGS.get(slot))
|
||||
{
|
||||
f.store(old.sa_flags, Ordering::SeqCst);
|
||||
p.store(old.sa_sigaction, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The signal handler: record, then pass the signal on.
|
||||
unsafe extern "C" fn handler(signal: c_int, info: *mut siginfo_t, context: *mut c_void) {
|
||||
// SAFETY: `__error` is this thread's errno, restored for whatever runs after us.
|
||||
let errno = unsafe { *libc::__error() };
|
||||
let slot = SIGNALS.iter().position(|&s| s == signal).unwrap_or(0);
|
||||
let previous = PREVIOUS.get(slot).map_or(libc::SIG_DFL, |p| p.load(Ordering::SeqCst));
|
||||
// SAFETY: the system passes a valid siginfo and the interrupted thread's context for the
|
||||
// duration of an SA_SIGINFO handler.
|
||||
let f = unsafe { fault(signal, info, context) };
|
||||
// Someone sent a signal the app ignored before: still nothing to record.
|
||||
let ignored = previous == libc::SIG_IGN && !f.fault;
|
||||
if !ignored && !BUSY.swap(true, Ordering::SeqCst) {
|
||||
record(&f);
|
||||
}
|
||||
// SAFETY: as above.
|
||||
unsafe { pass_on(signal, slot, previous, f.fault, info, context) };
|
||||
// SAFETY: as above.
|
||||
unsafe { *libc::__error() = errno };
|
||||
}
|
||||
|
||||
/// The signal comes from the faulting instruction itself (not `kill`, `raise` or `abort`), so
|
||||
/// returning runs it again: on arm64, the exception the thread last took matches the signal.
|
||||
/// macOS gives a SIGSEGV sent with `kill` the same `si_code` as a real one (SEGV_ACCERR), but
|
||||
/// the exception is then the thread's last system call (or something long past, whose fault
|
||||
/// address doesn't match).
|
||||
#[cfg(any(target_arch = "aarch64", test))]
|
||||
fn arm64_fault(signal: c_int, si_code: c_int, addr: usize, esr: u32, far: u64) -> bool {
|
||||
const ABORTS: [u32; 4] = [0x20, 0x21, 0x24, 0x25];
|
||||
const SVC: u32 = 0x15;
|
||||
let class = esr >> 26;
|
||||
signal != SIGABRT
|
||||
&& (1..0x10000).contains(&si_code)
|
||||
&& match signal {
|
||||
SIGSEGV | SIGBUS => ABORTS.contains(&class) && far as usize == addr,
|
||||
_ => class != SVC && !ABORTS.contains(&class),
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`arm64_fault`], from the x86 trap number and page-fault address.
|
||||
#[cfg(any(target_arch = "x86_64", test))]
|
||||
fn x86_fault(signal: c_int, si_code: c_int, addr: usize, trapno: u16, faultvaddr: u64) -> bool {
|
||||
signal != SIGABRT
|
||||
&& (1..0x10000).contains(&si_code)
|
||||
&& match signal {
|
||||
SIGSEGV | SIGBUS => (trapno == 14 && faultvaddr as usize == addr) || matches!(trapno, 12 | 13),
|
||||
SIGILL => trapno == 6,
|
||||
SIGFPE => matches!(trapno, 0 | 16 | 19),
|
||||
SIGTRAP => matches!(trapno, 1 | 3),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the signal to the previous handler, or to the default action. A crash signal that was
|
||||
/// only sent goes to the default action: the handler before ours (the standard library's, for
|
||||
/// SIGSEGV and SIGBUS) expects a fault that happens again when it returns.
|
||||
unsafe fn pass_on(signal: c_int, slot: usize, previous: usize, fault: bool, info: *mut siginfo_t, context: *mut c_void) {
|
||||
let ours = handler as *const () as usize;
|
||||
if previous != libc::SIG_DFL && previous != libc::SIG_IGN && previous != ours && (fault || signal == SIGABRT) {
|
||||
let flags = PREVIOUS_FLAGS.get(slot).map_or(0, |f| f.load(Ordering::SeqCst));
|
||||
if flags & libc::SA_SIGINFO != 0 {
|
||||
// SAFETY: the address came from `sigaction` as an SA_SIGINFO handler.
|
||||
let previous: unsafe extern "C" fn(c_int, *mut siginfo_t, *mut c_void) = unsafe { std::mem::transmute(previous) };
|
||||
// SAFETY: as for this handler.
|
||||
unsafe { previous(signal, info, context) };
|
||||
} else {
|
||||
// SAFETY: the address came from `sigaction` as a plain handler.
|
||||
let previous: unsafe extern "C" fn(c_int) = unsafe { std::mem::transmute(previous) };
|
||||
// SAFETY: as for this handler.
|
||||
unsafe { previous(signal) };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if previous == libc::SIG_IGN && !fault {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as in `install`.
|
||||
let mut default: libc::sigaction = unsafe { std::mem::zeroed() };
|
||||
default.sa_sigaction = libc::SIG_DFL;
|
||||
// SAFETY: a valid struct; restoring the default action is async-signal-safe.
|
||||
unsafe { libc::sigaction(signal, &default, std::ptr::null_mut()) };
|
||||
// A fault happens again when we return, now with the default action, so macOS reports it at
|
||||
// the instruction; anything else is raised again, and arrives once this handler returns.
|
||||
if !fault {
|
||||
// SAFETY: async-signal-safe.
|
||||
unsafe { libc::raise(signal) };
|
||||
}
|
||||
}
|
||||
|
||||
/// The darwin `ucontext_t`, up to the pointer to the machine context (`<sys/_types/_ucontext.h>`).
|
||||
#[repr(C)]
|
||||
struct UContext {
|
||||
_onstack: c_int,
|
||||
_sigmask: u32,
|
||||
_stack: libc::stack_t,
|
||||
_link: *mut c_void,
|
||||
_mcsize: usize,
|
||||
mcontext: *const MContext,
|
||||
}
|
||||
|
||||
/// The darwin arm64 machine context: exception state, then the general registers
|
||||
/// (`<mach/arm/_structs.h>`; the floating-point state that follows isn't read).
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
#[repr(C)]
|
||||
struct MContext {
|
||||
far: u64,
|
||||
esr: u32,
|
||||
_exception: u32,
|
||||
_x: [u64; 29],
|
||||
fp: u64,
|
||||
_lr: u64,
|
||||
_sp: u64,
|
||||
pc: u64,
|
||||
}
|
||||
|
||||
/// The darwin x86_64 machine context: exception state, then the general registers
|
||||
/// (`<mach/i386/_structs.h>`; rax rbx rcx rdx rdi rsi rbp rsp r8–r15, then rip).
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[repr(C)]
|
||||
struct MContext {
|
||||
trapno: u16,
|
||||
_cpu: u16,
|
||||
err: u32,
|
||||
faultvaddr: u64,
|
||||
gpr: [u64; 16],
|
||||
rip: u64,
|
||||
}
|
||||
|
||||
/// What the faulting instruction did to its address, when the processor says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Access {
|
||||
Unknown,
|
||||
Read,
|
||||
Write,
|
||||
Execute,
|
||||
}
|
||||
|
||||
/// What a crash signal says, read out of the signal before anything is looked up.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Fault {
|
||||
signal: c_int,
|
||||
si_code: c_int,
|
||||
/// `si_addr`: the address that faulted (for SIGILL, SIGFPE and SIGTRAP, the instruction).
|
||||
addr: usize,
|
||||
pc: usize,
|
||||
fp: usize,
|
||||
access: Access,
|
||||
/// It comes from the faulting instruction (not `kill`, `raise` or `abort`).
|
||||
fault: bool,
|
||||
/// The process that sent the signal (0 or our own for a fault or an `abort()`).
|
||||
sender: i32,
|
||||
pid: i32,
|
||||
thread: u64,
|
||||
thread_name: [u8; 64],
|
||||
/// The thread's stack, `[low, high)` (0, 0 when unknown).
|
||||
stack: (usize, usize),
|
||||
}
|
||||
|
||||
impl Fault {
|
||||
/// The fault hit just below the thread's stack: the stack ran out.
|
||||
fn overflow(&self) -> bool {
|
||||
let (low, high) = self.stack;
|
||||
matches!(self.signal, SIGSEGV | SIGBUS) && high > low && self.addr < low.saturating_add(4096) && self.addr >= low.saturating_sub(GUARD)
|
||||
}
|
||||
|
||||
fn signal_name(&self) -> &'static str {
|
||||
match self.signal {
|
||||
SIGSEGV => "SIGSEGV",
|
||||
SIGBUS => "SIGBUS",
|
||||
SIGILL => "SIGILL",
|
||||
SIGFPE => "SIGFPE",
|
||||
SIGTRAP => "SIGTRAP",
|
||||
SIGABRT => "SIGABRT",
|
||||
_ => "signal",
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
if self.overflow() {
|
||||
return "stack overflow";
|
||||
}
|
||||
match self.signal {
|
||||
SIGSEGV => "segmentation fault",
|
||||
SIGBUS => "bus error",
|
||||
SIGILL => "illegal instruction",
|
||||
SIGFPE => "arithmetic exception",
|
||||
SIGTRAP => "trap",
|
||||
SIGABRT => "abort",
|
||||
_ => "signal",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `si_code` names that say something about a crash.
|
||||
fn code_name(&self) -> Option<&'static str> {
|
||||
Some(match (self.signal, self.si_code) {
|
||||
(SIGSEGV, 1) => "SEGV_MAPERR",
|
||||
(SIGSEGV, 2) => "SEGV_ACCERR",
|
||||
(SIGBUS, 1) => "BUS_ADRALN",
|
||||
(SIGBUS, 2) => "BUS_ADRERR",
|
||||
(SIGBUS, 3) => "BUS_OBJERR",
|
||||
(SIGFPE, 7) => "FPE_INTDIV",
|
||||
(SIGFPE, 8) => "FPE_INTOVF",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One line on what happened: the address read or written, a stack overflow, who sent it.
|
||||
fn detail(&self, b: &mut Buf) {
|
||||
if self.sender != 0 && self.sender != self.pid {
|
||||
let _ = write!(b, "sent by process {}", self.sender);
|
||||
return;
|
||||
}
|
||||
if !self.fault {
|
||||
if self.signal != SIGABRT {
|
||||
let _ = b.write_str("sent with kill or raise, not a fault");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let what = match self.access {
|
||||
Access::Read => "read of",
|
||||
Access::Write => "write to",
|
||||
Access::Execute => "execution at",
|
||||
Access::Unknown => "access to",
|
||||
};
|
||||
if self.overflow() {
|
||||
let _ = write!(b, "{what} 0x{:016X}, below the thread's stack", self.addr);
|
||||
} else if matches!(self.signal, SIGSEGV | SIGBUS) {
|
||||
let _ = write!(b, "{what} 0x{:016X}", self.addr);
|
||||
} else {
|
||||
let _ = write!(b, "at 0x{:016X}", self.addr);
|
||||
}
|
||||
if let Some(c) = self.code_name() {
|
||||
let _ = write!(b, " ({c})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An image that holds an address, as `dladdr` names it.
|
||||
struct Module<'a> {
|
||||
path: &'a [u8],
|
||||
base: usize,
|
||||
symbol: Option<(&'a [u8], usize)>,
|
||||
}
|
||||
|
||||
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
|
||||
struct Buf {
|
||||
bytes: [u8; 8192],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Buf {
|
||||
fn new() -> Buf {
|
||||
Buf { bytes: [0; 8192], len: 0 }
|
||||
}
|
||||
|
||||
fn room(&self) -> usize {
|
||||
self.bytes.len().saturating_sub(self.len)
|
||||
}
|
||||
|
||||
fn as_bytes(&self) -> &[u8] {
|
||||
self.bytes.get(..self.len).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `s` as the inside of a JSON string, at most `max` bytes of it (bytes that aren't UTF-8
|
||||
/// and control characters become `?`).
|
||||
fn escaped(&mut self, s: &[u8], max: usize) {
|
||||
for chunk in s.get(..s.len().min(max)).unwrap_or_default().utf8_chunks() {
|
||||
for c in chunk.valid().chars() {
|
||||
let _ = match c {
|
||||
'"' => self.write_str("\\\""),
|
||||
'\\' => self.write_str("\\\\"),
|
||||
c if (c as u32) < 0x20 => self.write_str("?"),
|
||||
c => self.write_char(c),
|
||||
};
|
||||
}
|
||||
if !chunk.invalid().is_empty() {
|
||||
let _ = self.write_str("?");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Write for Buf {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let n = s.len().min(self.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 last part of a path.
|
||||
fn file_name(path: &[u8]) -> &[u8] {
|
||||
path.rsplit(|&c| c == b'/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// The report: what the signal says, then (with `resolve`) the module of the faulting
|
||||
/// instruction and the `frames`. Always valid JSON: frames stop while there is room to close it.
|
||||
fn json(b: &mut Buf, f: &Fault, frames: &[usize], resolve: impl Fn(usize, bool) -> Option<Module<'static>>) {
|
||||
let signal = f.signal_name();
|
||||
let _ = write!(b, "{{\"signal\":\"{signal}\",\"number\":{},\"code\":\"{signal}\",\"name\":\"{}\"", f.signal, f.name());
|
||||
let _ = write!(b, ",\"si_code\":{},\"address\":\"0x{:016X}\",\"thread\":{},\"pid\":{}", f.si_code, f.pc, f.thread, f.pid);
|
||||
let name = f.thread_name.split(|&c| c == 0).next().unwrap_or_default();
|
||||
if !name.is_empty() {
|
||||
let _ = b.write_str(",\"thread_name\":\"");
|
||||
b.escaped(name, 64);
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
let mark = b.len;
|
||||
let _ = b.write_str(",\"detail\":\"");
|
||||
let start = b.len;
|
||||
f.detail(b);
|
||||
if b.len == start {
|
||||
b.len = mark;
|
||||
} else {
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
if let Some(m) = resolve(f.pc, false) {
|
||||
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", f.pc.wrapping_sub(m.base));
|
||||
b.escaped(m.path, 300);
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
if !frames.is_empty() {
|
||||
let _ = b.write_str(",\"frames\":[");
|
||||
for (i, &at) in frames.iter().enumerate() {
|
||||
// A frame is at most ~300 bytes; keep room for it and the closing `]}`.
|
||||
if b.room() < 400 {
|
||||
break;
|
||||
}
|
||||
if i > 0 {
|
||||
let _ = b.write_str(",");
|
||||
}
|
||||
let _ = b.write_str("\"");
|
||||
// A return address points after the call: look up the call itself.
|
||||
match resolve(at, i > 0) {
|
||||
Some(m) => {
|
||||
b.escaped(file_name(m.path), 100);
|
||||
let _ = write!(b, "+0x{:X}", at.wrapping_sub(m.base));
|
||||
if let Some((symbol, start)) = m.symbol {
|
||||
let _ = b.write_str(" ");
|
||||
b.escaped(symbol, 160);
|
||||
let _ = write!(b, "+0x{:X}", at.wrapping_sub(start));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = write!(b, "0x{at:016X}");
|
||||
}
|
||||
}
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
let _ = b.write_str("]");
|
||||
}
|
||||
let _ = b.write_str("}");
|
||||
}
|
||||
|
||||
/// The return addresses up the frame-pointer chain from `fp`, after `pc`. `read` gives the word
|
||||
/// at an address; frames outside `stack`, misaligned or not going up end the walk.
|
||||
fn walk(pc: usize, fp: usize, stack: (usize, usize), read: impl Fn(usize) -> Option<usize>, out: &mut [usize; MAX_FRAMES]) -> usize {
|
||||
let (low, high) = stack;
|
||||
let mut n = 0;
|
||||
if let Some(slot) = out.get_mut(n) {
|
||||
*slot = pc;
|
||||
n += 1;
|
||||
}
|
||||
let mut fp = fp;
|
||||
while n < MAX_FRAMES {
|
||||
let in_stack = fp >= low && fp.checked_add(16).is_some_and(|end| end <= high);
|
||||
if !fp.is_multiple_of(8) || !in_stack {
|
||||
break;
|
||||
}
|
||||
let (Some(next), Some(ret)) = (read(fp), read(fp + 8)) else { break };
|
||||
let ret = strip_pointer_auth(ret);
|
||||
if ret == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = out.get_mut(n) {
|
||||
*slot = ret;
|
||||
n += 1;
|
||||
}
|
||||
if next <= fp {
|
||||
break;
|
||||
}
|
||||
fp = next;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Return addresses saved by arm64e code carry a signature in their top bits.
|
||||
fn strip_pointer_auth(addr: usize) -> usize {
|
||||
if cfg!(target_arch = "aarch64") { addr & 0x0000_7FFF_FFFF_FFFF } else { addr }
|
||||
}
|
||||
|
||||
/// Read what the signal and the interrupted thread's registers say.
|
||||
unsafe fn fault(signal: c_int, info: *const siginfo_t, context: *const c_void) -> Fault {
|
||||
let mut f = Fault {
|
||||
signal,
|
||||
si_code: 0,
|
||||
addr: 0,
|
||||
pc: 0,
|
||||
fp: 0,
|
||||
access: Access::Unknown,
|
||||
fault: false,
|
||||
sender: 0,
|
||||
// SAFETY: async-signal-safe.
|
||||
pid: unsafe { libc::getpid() },
|
||||
thread: 0,
|
||||
thread_name: [0; 64],
|
||||
stack: (0, 0),
|
||||
};
|
||||
if !info.is_null() {
|
||||
// SAFETY: the system passes a valid siginfo for the duration of the handler.
|
||||
let info = unsafe { &*info };
|
||||
f.si_code = info.si_code;
|
||||
f.addr = info.si_addr as usize;
|
||||
f.sender = info.si_pid;
|
||||
// Without registers (never seen), the signal's own code has to do.
|
||||
f.fault = signal != SIGABRT && (1..0x10000).contains(&f.si_code);
|
||||
}
|
||||
let context = context as *const UContext;
|
||||
// SAFETY: an SA_SIGINFO handler's third argument is the interrupted thread's ucontext, whose
|
||||
// machine context pointer the kernel fills in.
|
||||
let mcontext = if context.is_null() { std::ptr::null() } else { unsafe { (*context).mcontext } };
|
||||
if !mcontext.is_null() {
|
||||
// SAFETY: as above.
|
||||
let m = unsafe { &*mcontext };
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
f.pc = strip_pointer_auth(m.pc as usize);
|
||||
f.fp = m.fp as usize;
|
||||
f.fault = arm64_fault(signal, f.si_code, f.addr, m.esr, m.far);
|
||||
// A data abort says whether it was a write (WnR); an instruction abort is execution.
|
||||
f.access = match m.esr >> 26 {
|
||||
0x24 | 0x25 if m.esr & (1 << 6) != 0 => Access::Write,
|
||||
0x24 | 0x25 => Access::Read,
|
||||
0x20 | 0x21 => Access::Execute,
|
||||
_ => Access::Unknown,
|
||||
};
|
||||
}
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
f.pc = m.rip as usize;
|
||||
f.fp = m.gpr.get(6).copied().unwrap_or(0) as usize;
|
||||
f.fault = x86_fault(signal, f.si_code, f.addr, m.trapno, m.faultvaddr);
|
||||
// A page fault's error code: bit 4 instruction fetch, bit 1 write.
|
||||
f.access = match (m.trapno, m.err) {
|
||||
(14, e) if e & 0x10 != 0 => Access::Execute,
|
||||
(14, e) if e & 0x2 != 0 => Access::Write,
|
||||
(14, _) => Access::Read,
|
||||
_ => Access::Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
if f.pc == 0 && matches!(signal, SIGILL | SIGFPE | SIGTRAP) {
|
||||
f.pc = f.addr;
|
||||
}
|
||||
// SAFETY: these read the calling thread's own pthread structure; the buffers are as long as
|
||||
// we say.
|
||||
unsafe {
|
||||
let me = libc::pthread_self();
|
||||
libc::pthread_threadid_np(me, &mut f.thread);
|
||||
libc::pthread_getname_np(me, f.thread_name.as_mut_ptr().cast(), f.thread_name.len());
|
||||
let high = libc::pthread_get_stackaddr_np(me) as usize;
|
||||
let size = libc::pthread_get_stacksize_np(me);
|
||||
if let Some(low) = high.checked_sub(size) {
|
||||
f.stack = (low, high);
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The image that holds `addr` (the call before it, for a return address).
|
||||
fn resolve(addr: usize, return_address: bool) -> Option<Module<'static>> {
|
||||
let at = if return_address { addr.saturating_sub(1) } else { addr };
|
||||
// SAFETY: `Dl_info` is plain data; all zeros is a valid value.
|
||||
let mut info: libc::Dl_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: `dladdr` only reads dyld's image list; the strings it returns live as long as the
|
||||
// image, which a crashing process never unloads.
|
||||
unsafe {
|
||||
if libc::dladdr(at as *const c_void, &mut info) == 0 || info.dli_fname.is_null() {
|
||||
return None;
|
||||
}
|
||||
let symbol = (!info.dli_sname.is_null()).then(|| (CStr::from_ptr(info.dli_sname).to_bytes(), info.dli_saddr as usize));
|
||||
Some(Module { path: CStr::from_ptr(info.dli_fname).to_bytes(), base: info.dli_fbase as usize, symbol })
|
||||
}
|
||||
}
|
||||
|
||||
/// Write what the signal says into the info file, then again with the modules and frames.
|
||||
fn record(f: &Fault) {
|
||||
let Some(path) = PATH.get() else { return };
|
||||
// SAFETY: NUL-terminated path; the descriptor is closed below.
|
||||
let fd = unsafe { libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC, 0o600) };
|
||||
if fd < 0 {
|
||||
return;
|
||||
}
|
||||
let mut b = Buf::new();
|
||||
json(&mut b, f, &[], |_, _| None);
|
||||
let bytes = b.as_bytes();
|
||||
// SAFETY: the buffer is as long as we say.
|
||||
unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
|
||||
let mut frames = [0usize; MAX_FRAMES];
|
||||
// SAFETY: `walk` only reads inside the thread's own stack, 8-byte aligned.
|
||||
let n = walk(f.pc, f.fp, f.stack, |at| Some(unsafe { (at as *const usize).read() }), &mut frames);
|
||||
b.len = 0;
|
||||
json(&mut b, f, frames.get(..n).unwrap_or_default(), resolve);
|
||||
let bytes = b.as_bytes();
|
||||
// SAFETY: as above.
|
||||
unsafe {
|
||||
if libc::pwrite(fd, bytes.as_ptr().cast(), bytes.len(), 0) == bytes.len() as isize {
|
||||
libc::ftruncate(fd, bytes.len() as libc::off_t);
|
||||
}
|
||||
libc::close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fault(signal: c_int, si_code: c_int, addr: usize) -> Fault {
|
||||
let mut thread_name = [0u8; 64];
|
||||
thread_name[..4].copy_from_slice(b"main");
|
||||
Fault {
|
||||
signal,
|
||||
si_code,
|
||||
addr,
|
||||
pc: 0x1_0000_1234,
|
||||
fp: 0,
|
||||
access: Access::Read,
|
||||
fault: true,
|
||||
sender: 0,
|
||||
pid: 77,
|
||||
thread: 4242,
|
||||
thread_name,
|
||||
stack: (0x7000_0000, 0x7080_0000),
|
||||
}
|
||||
}
|
||||
|
||||
fn report(f: &Fault, frames: &[usize]) -> serde_json::Value {
|
||||
let mut b = Buf::new();
|
||||
json(&mut b, f, frames, |at, _| {
|
||||
(at >= 0x1_0000_0000).then_some(Module {
|
||||
path: b"/Applications/SolidCraft.app/Contents/MacOS/solidcraft",
|
||||
base: 0x1_0000_0000,
|
||||
symbol: Some((b"_ZN4main17h0123456789abcdefE", 0x1_0000_1200)),
|
||||
})
|
||||
});
|
||||
serde_json::from_slice(b.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segfault_says_the_signal_the_address_read_and_the_module() {
|
||||
let v = report(&fault(SIGSEGV, 1, 0x10), &[0x1_0000_1234, 0x1_0000_2000, 0x5000]);
|
||||
assert_eq!(v["signal"], "SIGSEGV");
|
||||
assert_eq!(v["code"], "SIGSEGV");
|
||||
assert_eq!(v["name"], "segmentation fault");
|
||||
assert_eq!(v["detail"], "read of 0x0000000000000010 (SEGV_MAPERR)");
|
||||
assert_eq!(v["address"], "0x0000000100001234");
|
||||
assert_eq!(v["module"], "/Applications/SolidCraft.app/Contents/MacOS/solidcraft");
|
||||
assert_eq!(v["offset"], "0x1234");
|
||||
assert_eq!(v["thread"], 4242);
|
||||
assert_eq!(v["thread_name"], "main");
|
||||
assert_eq!(v["pid"], 77);
|
||||
let frames: Vec<&str> = v["frames"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
frames,
|
||||
["solidcraft+0x1234 _ZN4main17h0123456789abcdefE+0x34", "solidcraft+0x2000 _ZN4main17h0123456789abcdefE+0xE00", "0x0000000000005000"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fault_below_the_stack_is_a_stack_overflow() {
|
||||
let mut f = fault(SIGBUS, 2, 0x7000_0000 - 0x100);
|
||||
f.access = Access::Write;
|
||||
let v = report(&f, &[]);
|
||||
assert_eq!(v["name"], "stack overflow");
|
||||
assert_eq!(v["code"], "SIGBUS");
|
||||
assert_eq!(v["detail"], "write to 0x000000006FFFFF00, below the thread's stack (BUS_ADRERR)");
|
||||
// Far below it, or inside it, it is not.
|
||||
assert_eq!(report(&fault(SIGBUS, 2, 0x10), &[])["name"], "bus error");
|
||||
assert_eq!(report(&fault(SIGSEGV, 2, 0x7040_0000), &[])["name"], "segmentation fault");
|
||||
// Nor without knowing the stack.
|
||||
let mut f = fault(SIGSEGV, 1, 0x7000_0000 - 0x100);
|
||||
f.stack = (0, 0);
|
||||
assert_eq!(report(&f, &[])["name"], "segmentation fault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abort_or_a_signal_sent_by_another_process_has_no_address_detail() {
|
||||
let mut f = fault(SIGABRT, 0, 0);
|
||||
f.fault = false;
|
||||
f.sender = 77;
|
||||
let v = report(&f, &[]);
|
||||
assert_eq!(v["signal"], "SIGABRT");
|
||||
assert_eq!(v["name"], "abort");
|
||||
assert!(v.get("detail").is_none(), "{v}");
|
||||
let mut f = fault(SIGSEGV, 0x10001, 0);
|
||||
f.fault = false;
|
||||
f.sender = 501;
|
||||
assert_eq!(report(&f, &[])["detail"], "sent by process 501");
|
||||
// `kill` without a sender, or `raise`.
|
||||
f.sender = 0;
|
||||
assert_eq!(report(&f, &[])["detail"], "sent with kill or raise, not a fault");
|
||||
// A trap names the instruction.
|
||||
assert_eq!(report(&fault(SIGTRAP, 1, 0x1_0000_1234), &[])["detail"], "at 0x0000000100001234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_fault_runs_again_when_the_handler_returns() {
|
||||
// Measured on an M5 Pro, macOS 27: a read of address 16, then `kill -SEGV` while the
|
||||
// thread waits in a system call. Both have si_code 2.
|
||||
assert!(arm64_fault(SIGSEGV, 2, 0x10, 0x9200_0046, 0x10));
|
||||
assert!(!arm64_fault(SIGSEGV, 2, 0, 0x5600_0080, 0));
|
||||
assert!(!arm64_fault(SIGBUS, 1, 0, 0x5600_0080, 0));
|
||||
// A data abort long past, while the signal names no address.
|
||||
assert!(!arm64_fault(SIGSEGV, 2, 0, 0x9200_0046, 0x10));
|
||||
// A trap instruction (BRK) or an undefined one; but not a system call.
|
||||
assert!(arm64_fault(SIGTRAP, 1, 0x1000, 0xF200_0001, 0));
|
||||
assert!(arm64_fault(SIGILL, 1, 0x1000, 0x0200_0000, 0));
|
||||
assert!(!arm64_fault(SIGILL, 1, 0x1000, 0x5600_0080, 0));
|
||||
// Sent: SI_USER, no code, or an abort.
|
||||
assert!(!arm64_fault(SIGSEGV, 0x10001, 0x10, 0x9200_0046, 0x10));
|
||||
assert!(!arm64_fault(SIGTRAP, 0, 0, 0xF200_0001, 0));
|
||||
assert!(!arm64_fault(SIGABRT, 1, 0, 0x5600_0080, 0));
|
||||
// x86_64: a page fault at its address, a general protection fault, ud2, int3.
|
||||
assert!(x86_fault(SIGSEGV, 1, 0x10, 14, 0x10));
|
||||
assert!(!x86_fault(SIGSEGV, 1, 0, 14, 0x10));
|
||||
assert!(x86_fault(SIGSEGV, 1, 0, 13, 0));
|
||||
assert!(x86_fault(SIGILL, 1, 0x1000, 6, 0));
|
||||
assert!(x86_fault(SIGTRAP, 1, 0x1000, 3, 0));
|
||||
assert!(!x86_fault(SIGILL, 1, 0x1000, 14, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_with_quotes_control_characters_or_bad_utf8_stay_valid_json() {
|
||||
let mut f = fault(SIGILL, 1, 0x1_0000_1234);
|
||||
f.thread_name = [0; 64];
|
||||
f.thread_name[..7].copy_from_slice(b"a\"b\\\n\xFFc");
|
||||
let v = report(&f, &[]);
|
||||
assert_eq!(v["thread_name"], "a\"b\\??c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_with_long_names_still_closes_its_json() {
|
||||
let long: &'static [u8] = Box::leak(vec![b'x'; 4000].into_boxed_slice());
|
||||
let f = fault(SIGSEGV, 1, 0x10);
|
||||
let mut b = Buf::new();
|
||||
json(&mut b, &f, &[0x1_0000_1234; MAX_FRAMES], |_, _| Some(Module { path: long, base: 0x1_0000_0000, symbol: Some((long, 0x1_0000_0000)) }));
|
||||
let v: serde_json::Value = serde_json::from_slice(b.as_bytes()).unwrap();
|
||||
assert_eq!(v["module"].as_str().unwrap().len(), 300);
|
||||
let frames = v["frames"].as_array().unwrap();
|
||||
assert!(!frames.is_empty() && frames.len() <= MAX_FRAMES, "{}", frames.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_walk_stays_on_the_stack_and_goes_up() {
|
||||
let stack = (0x1000, 0x2000);
|
||||
// Three frames: 0x1100 -> 0x1200 -> 0x1300 -> 0 (end).
|
||||
let memory = |at: usize| match at {
|
||||
0x1100 => Some(0x1200),
|
||||
0x1108 => Some(0xA),
|
||||
0x1200 => Some(0x1300),
|
||||
0x1208 => Some(0xB),
|
||||
0x1300 => Some(0),
|
||||
0x1308 => Some(0xC),
|
||||
_ => None,
|
||||
};
|
||||
let mut out = [0; MAX_FRAMES];
|
||||
let n = walk(0x9, 0x1100, stack, memory, &mut out);
|
||||
assert_eq!(&out[..n], &[0x9, 0xA, 0xB, 0xC]);
|
||||
// A frame pointer off the stack, misaligned, or one that doesn't go up ends the walk.
|
||||
assert_eq!(walk(0x9, 0x3000, stack, memory, &mut out), 1);
|
||||
assert_eq!(walk(0x9, 0x1101, stack, memory, &mut out), 1);
|
||||
let looping = |at: usize| if at.is_multiple_of(16) { Some(at) } else { Some(0xA) };
|
||||
assert_eq!(walk(0x9, 0x1100, stack, looping, &mut out), 2);
|
||||
// A chain that never ends stops at MAX_FRAMES.
|
||||
let endless = |at: usize| if at.is_multiple_of(16) { Some(at + 16) } else { Some(0xA) };
|
||||
assert_eq!(walk(0x9, 0x1000, stack, endless, &mut out), MAX_FRAMES);
|
||||
}
|
||||
|
||||
/// The child half: install crash reporting and the handler, then crash the way
|
||||
/// `SOLIDCRAFT_NATIVE_CRASH_FAULT` says.
|
||||
#[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-25" };
|
||||
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
|
||||
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
|
||||
let Some(fault) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT") else {
|
||||
solidcraft_engine::crash::end_session();
|
||||
return;
|
||||
};
|
||||
super::install();
|
||||
solidcraft_engine::crash::phase("device creation");
|
||||
match fault.to_str() {
|
||||
Some("abort") => std::process::abort(),
|
||||
Some("overflow") => println!("unreachable {}", recurse(0)),
|
||||
Some("raise") => {
|
||||
// SAFETY: none needed; this is the crash under test.
|
||||
unsafe { libc::raise(SIGSEGV) };
|
||||
println!("survived");
|
||||
}
|
||||
_ => segfault(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs out of stack.
|
||||
#[inline(never)]
|
||||
fn recurse(n: u64) -> u64 {
|
||||
let a = std::hint::black_box([n; 64]);
|
||||
if n == u64::MAX { 0 } else { recurse(n + 1).wrapping_add(a[0]) }
|
||||
}
|
||||
|
||||
/// The crash under test: a read of address 16, as a null struct pointer's field would be.
|
||||
#[inline(never)]
|
||||
fn segfault() {
|
||||
// SAFETY: none; this is the crash under test.
|
||||
let v = unsafe { std::ptr::read_volatile(std::hint::black_box(16usize) as *const u64) };
|
||||
println!("unreachable {v}");
|
||||
}
|
||||
|
||||
fn run(dir: &Path, fault: Option<&str>) -> std::process::Output {
|
||||
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
c.args(["--exact", "native_crash_macos::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
|
||||
if let Some(f) = fault {
|
||||
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", f);
|
||||
}
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
/// Crash a child the way `fault` says; returns what the handler wrote and the next start's report.
|
||||
fn crash(name: &str, fault: &str, signal: c_int) -> (serde_json::Value, serde_json::Value, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let out = run(&dir, Some(fault));
|
||||
assert_eq!(out.status.signal(), Some(signal), "the child died of the signal itself: {out:?}");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
let info =
|
||||
std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).find(|p| p.to_string_lossy().ends_with(".native.json"));
|
||||
let info = info.expect("the handler wrote what it caught");
|
||||
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
|
||||
// The next start reports it.
|
||||
let out = run(&dir, None);
|
||||
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();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
(v, r, stderr)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segfault_is_reported_with_its_signal_module_frames_and_step() {
|
||||
let (v, r, _) = crash("segv", "segv", SIGSEGV);
|
||||
assert_eq!(v["signal"], "SIGSEGV", "{v}");
|
||||
// Address 16 is in __PAGEZERO, mapped without access: SEGV_ACCERR.
|
||||
assert_eq!(v["detail"], "read of 0x0000000000000010 (SEGV_ACCERR)", "{v}");
|
||||
assert!(v["module"].as_str().unwrap().contains("solidcraft"), "the fault is in the test binary: {v}");
|
||||
let frames = v["frames"].as_array().unwrap();
|
||||
assert!(frames.len() >= 3, "{v}");
|
||||
assert!(frames.iter().any(|f| f.as_str().unwrap().contains("child")), "the walk reaches the test that crashed: {v}");
|
||||
let message = r["message"].as_str().unwrap();
|
||||
assert!(message.contains("segmentation fault (SIGSEGV) in solidcraft"), "{r}");
|
||||
assert!(message.contains("during startup (device creation"), "{r}");
|
||||
assert_eq!(r["kind"], "unclean_exit");
|
||||
assert_eq!(r["native"]["signal"], "SIGSEGV");
|
||||
assert!(r["backtrace"].as_array().unwrap().iter().any(|f| f.as_str().unwrap().contains("segfault")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abort_is_reported_as_sigabrt_with_the_code_that_called_it() {
|
||||
let (v, r, _) = crash("abrt", "abort", SIGABRT);
|
||||
assert_eq!(v["signal"], "SIGABRT", "{v}");
|
||||
assert!(v.get("detail").is_none(), "{v}");
|
||||
let frames = v["frames"].as_array().unwrap();
|
||||
assert!(frames.iter().any(|f| f.as_str().unwrap().contains("abort")), "{v}");
|
||||
assert!(r["message"].as_str().unwrap().contains("abort (SIGABRT)"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_overflow_is_recorded_and_the_standard_library_still_says_so() {
|
||||
// The fault on the guard page is recorded on the alternate stack; the standard library's
|
||||
// handler, called after ours, prints its message and aborts.
|
||||
let (v, r, stderr) = crash("overflow", "overflow", SIGABRT);
|
||||
assert!(matches!(v["signal"].as_str(), Some("SIGSEGV" | "SIGBUS")), "{v}");
|
||||
assert_eq!(v["name"], "stack overflow", "{v}");
|
||||
assert!(v["detail"].as_str().unwrap().contains("below the thread's stack"), "{v}");
|
||||
assert!(stderr.contains("has overflowed its stack"), "{stderr}");
|
||||
assert!(r["message"].as_str().unwrap().contains("stack overflow ("), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segfault_signal_that_was_only_sent_still_ends_the_app() {
|
||||
// The standard library's handler, which comes before ours, returns expecting the fault
|
||||
// to happen again; a sent signal never does, so it goes to the default action instead.
|
||||
let (v, r, _) = crash("raise", "raise", SIGSEGV);
|
||||
assert_eq!(v["detail"], "sent with kill or raise, not a fault", "{v}");
|
||||
assert!(r["message"].as_str().unwrap().contains("(SIGSEGV)"), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -1,250 +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 and setting a window class's icon have 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.
|
||||
//!
|
||||
//! [`set_class_icon`] gives the window's class the exe's icon, so the taskbar button shows the
|
||||
//! mark even when the window is too busy to answer the taskbar's request for its icon.
|
||||
#![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::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GCLP_HICON, GCLP_HICONSM, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT,
|
||||
HTBOTTOMRIGHT, HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IMAGE_ICON, IsIconic, IsZoomed, LR_DEFAULTCOLOR, LoadImageW, SM_CXICON,
|
||||
SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SM_CXSMICON, SetWindowLongPtrW, WM_GETDPISCALEDSIZE, WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
// windows-sys has SetClassLongPtrW only on 64-bit targets; on 32-bit Windows it is SetClassLongW.
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SetClassLongPtrW;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SetClassLongW as SetClassLongPtrW;
|
||||
|
||||
/// 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 exe's icon resource (`build.rs` embeds it as `1 ICON`).
|
||||
const ICON_RESOURCE: usize = 1;
|
||||
|
||||
/// Give the main window's class the exe's icon, big and small. Call it before the window is
|
||||
/// shown (eframe's app creator).
|
||||
///
|
||||
/// The taskbar asks a window for its icon when the window appears. eframe sets the window's
|
||||
/// icon before that, while the window is still hidden, and then shows the window at the end of
|
||||
/// its first frame; if that frame is slow, or startup kept the thread busy long enough for
|
||||
/// Windows to mark the window as not responding, the request isn't answered in time. The taskbar then
|
||||
/// takes the window class's icon and doesn't ask again. winit registers its class without one,
|
||||
/// so the button showed Windows' generic icon for as long as the window was open.
|
||||
/// `false` if it isn't a Win32 window or the exe's icon doesn't load; SetClassLongPtrW's own
|
||||
/// failure can't be told from a class that had no icon (both return 0), so it isn't reported.
|
||||
pub fn set_class_icon(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;
|
||||
// SAFETY: a plain query on the live main window.
|
||||
let dpi = unsafe { GetDpiForWindow(hwnd) };
|
||||
let (Some(big), Some(small)) = (exe_icon(SM_CXICON, dpi), exe_icon(SM_CXSMICON, dpi)) else { return false };
|
||||
// SAFETY: `hwnd` is the live main window and both icons are valid handles. The class keeps
|
||||
// them for the rest of the process, so they are never destroyed. `as _`: on 32-bit Windows
|
||||
// this is SetClassLongW (see the imports), which takes an i32.
|
||||
unsafe {
|
||||
SetClassLongPtrW(hwnd, GCLP_HICON, big as _);
|
||||
SetClassLongPtrW(hwnd, GCLP_HICONSM, small as _);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The exe's icon at the size of system metric `metric` (`SM_CXICON`, `SM_CXSMICON`) at `dpi`.
|
||||
fn exe_icon(metric: i32, dpi: u32) -> Option<windows_sys::Win32::Foundation::HANDLE> {
|
||||
// SAFETY: GetModuleHandleW(null) is this exe, loaded for the life of the process; the name
|
||||
// is an integer resource id (MAKEINTRESOURCE), which LoadImageW accepts in place of a string.
|
||||
let icon = unsafe {
|
||||
let size = GetSystemMetricsForDpi(metric, dpi);
|
||||
LoadImageW(GetModuleHandleW(std::ptr::null()), std::ptr::without_provenance(ICON_RESOURCE), IMAGE_ICON, size, size, LR_DEFAULTCOLOR)
|
||||
};
|
||||
(!icon.is_null()).then_some(icon)
|
||||
}
|
||||
|
||||
/// 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,195 +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}"),
|
||||
("install_cli", "Install solidcraft-cli command"),
|
||||
] {
|
||||
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.menu_enabled(app, ctx.egui_wants_keyboard_input())
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
for (item, action) in &self.help_actions {
|
||||
let enabled = solidcraft_ui_egui::help::available(app, action);
|
||||
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,463 +0,0 @@
|
||||
//! The navigation bridge: a 3D input device's own program (a homemade device's driver app) moves
|
||||
//! the view over a loopback connection, the way a 3D mouse driver hands its axes to the programs
|
||||
//! it supports, so the device emulates no mouse and the pointer stays the user's.
|
||||
//!
|
||||
//! The device app listens on `127.0.0.1:<port>`; with Preferences › Navigation › "Navigate with a
|
||||
//! device app" on (off by default), SolidCraft connects to it, says hello and reads one JSON
|
||||
//! object per line (docs/3d-mouse.md). The reader thread only sums the motion; the egui thread
|
||||
//! hands it to the view once per frame (`space_mouse::bridge_frame`). A device the app says it
|
||||
//! reads is left to it by the 3D mouse reader ([`Claim`]), so the view doesn't move twice.
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::Context;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{BRIDGE_VIEWS, BridgeMotion, bridge_frame, bridge_step};
|
||||
|
||||
/// The longest line read; a longer one is skipped to its end.
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// Standard views waiting for the next frame; past this many, more are a flood and dropped.
|
||||
const MAX_VIEWS: usize = 8;
|
||||
/// The longest name a device app may give for itself or its device, in characters.
|
||||
const MAX_NAME: usize = 64;
|
||||
/// How long a connection attempt waits.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// How long a read waits for data before the reader looks at the preferences again.
|
||||
const READ_WAKE: Duration = Duration::from_millis(250);
|
||||
/// The wait before connecting again when no device app answered or one went away.
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(1);
|
||||
/// In zoom mode, a motion of this many points along Y is one zoom step: with 8 % a step, the 1 %
|
||||
/// a point that a zoom drag moves.
|
||||
const POINTS_PER_ZOOM_STEP: f64 = 8.0;
|
||||
|
||||
/// What the device's motion drives, as the device app last said (`mode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
#[default]
|
||||
Orbit,
|
||||
Pan,
|
||||
Zoom,
|
||||
}
|
||||
|
||||
/// What the reader thread and the egui thread share.
|
||||
#[derive(Debug, Default)]
|
||||
struct Shared {
|
||||
/// The port the preferences ask to connect to; none while the bridge is off.
|
||||
wanted: Option<u16>,
|
||||
/// The device app connected, by the name it gave ("?" until it gives one).
|
||||
connected: Option<String>,
|
||||
/// The device the connected app reads, as it named it: the 3D mouse reader leaves that one alone.
|
||||
device: Option<String>,
|
||||
/// Motion since the egui thread last took it.
|
||||
motion: BridgeMotion,
|
||||
/// Standard views asked for since then.
|
||||
views: Vec<String>,
|
||||
}
|
||||
|
||||
fn lock(shared: &Mutex<Shared>) -> Option<MutexGuard<'_, Shared>> {
|
||||
shared.lock().ok()
|
||||
}
|
||||
|
||||
/// A name a device app gave, without control characters and cut to [`MAX_NAME`]; none when empty.
|
||||
fn name(value: Option<&Value>) -> Option<String> {
|
||||
let name: String = value?.as_str()?.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
let name = name.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// A number of a message, finite and in range; none when it isn't one.
|
||||
fn number(msg: &Value, key: &str) -> f64 {
|
||||
bridge_step(msg.get(key).and_then(Value::as_f64).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Takes one line from the device app into `shared`. Lines that aren't a message this version
|
||||
/// knows are ignored. Returns whether the view has something new to show.
|
||||
fn consume(line: &[u8], mode: &mut Mode, shared: &mut Shared) -> bool {
|
||||
let Ok(msg) = serde_json::from_slice::<Value>(line) else { return false };
|
||||
let add = |shared: &mut Shared, motion: BridgeMotion| {
|
||||
shared.motion = shared.motion.plus(motion);
|
||||
!motion.is_still()
|
||||
};
|
||||
match msg.get("t").and_then(Value::as_str) {
|
||||
Some("axis") => {
|
||||
let (x, y) = (number(&msg, "x"), number(&msg, "y"));
|
||||
let motion = match mode {
|
||||
Mode::Orbit => BridgeMotion { orbit: [x, y], ..Default::default() },
|
||||
Mode::Pan => BridgeMotion { pan: [x, y], ..Default::default() },
|
||||
// Up zooms in, as a zoom drag does.
|
||||
Mode::Zoom => BridgeMotion { zoom: -y / POINTS_PER_ZOOM_STEP, ..Default::default() },
|
||||
};
|
||||
add(shared, motion)
|
||||
}
|
||||
Some("zoom") => add(shared, BridgeMotion { zoom: number(&msg, "d"), ..Default::default() }),
|
||||
Some("mode") => {
|
||||
let on = msg.get("on").and_then(Value::as_bool).unwrap_or(false);
|
||||
*mode = match msg.get("mode").and_then(Value::as_str) {
|
||||
Some("pan") if on => Mode::Pan,
|
||||
Some("zoom") if on => Mode::Zoom,
|
||||
_ => Mode::Orbit,
|
||||
};
|
||||
false
|
||||
}
|
||||
Some("view") => {
|
||||
let view = msg.get("name").and_then(Value::as_str).and_then(|n| BRIDGE_VIEWS.iter().find(|v| **v == n));
|
||||
match view {
|
||||
Some(view) if shared.views.len() < MAX_VIEWS => {
|
||||
shared.views.push((*view).to_string());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
Some("hello") => {
|
||||
shared.connected = name(msg.get("server")).or_else(|| shared.connected.clone());
|
||||
shared.device = name(msg.get("device"));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits what arrives into lines, across reads. A line longer than [`MAX_LINE`] is dropped up to
|
||||
/// its newline, so a peer that never sends one can't make the reader hold more than that.
|
||||
#[derive(Default)]
|
||||
struct Lines {
|
||||
line: Vec<u8>,
|
||||
skipping: bool,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
fn feed(&mut self, mut data: &[u8], mut each: impl FnMut(&[u8])) {
|
||||
while !data.is_empty() {
|
||||
let end = data.iter().position(|b| *b == b'\n');
|
||||
let (part, rest) = match end {
|
||||
Some(i) => (data.get(..i).unwrap_or_default(), data.get(i + 1..).unwrap_or_default()),
|
||||
None => (data, &[][..]),
|
||||
};
|
||||
if !self.skipping {
|
||||
let room = (MAX_LINE + 1).saturating_sub(self.line.len());
|
||||
self.line.extend_from_slice(part.get(..room.min(part.len())).unwrap_or_default());
|
||||
if self.line.len() > MAX_LINE {
|
||||
self.line.clear();
|
||||
self.skipping = true;
|
||||
}
|
||||
}
|
||||
if end.is_some() {
|
||||
if !self.skipping {
|
||||
each(self.line.strip_suffix(b"\r").unwrap_or(&self.line));
|
||||
}
|
||||
self.line.clear();
|
||||
self.skipping = false;
|
||||
}
|
||||
data = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What SolidCraft says first, so the device app knows which program it is driving.
|
||||
fn hello() -> Value {
|
||||
json!({"t": "hello", "app": "solidcraft", "version": env!("CARGO_PKG_VERSION")})
|
||||
}
|
||||
|
||||
/// Reads one connection until the device app closes it, it fails, or the preferences no longer
|
||||
/// ask for this port; then forgets what it had.
|
||||
fn serve(mut stream: TcpStream, port: u16, ctx: &Context, shared: &Mutex<Shared>) {
|
||||
// Without a read timeout the reader couldn't notice the bridge being switched off.
|
||||
if stream.set_read_timeout(Some(READ_WAKE)).is_err() || writeln!(stream, "{}", hello()).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = stream.set_nodelay(true);
|
||||
log::info!("navigation bridge: connected to 127.0.0.1:{port}");
|
||||
if let Some(mut shared) = lock(shared) {
|
||||
shared.connected = Some("?".into());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let (mut lines, mut mode) = (Lines::default(), Mode::default());
|
||||
let mut chunk = [0_u8; 4096];
|
||||
while lock(shared).is_some_and(|shared| shared.wanted == Some(port)) {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => {
|
||||
let mut news = false;
|
||||
if let Some(mut shared) = lock(shared) {
|
||||
lines.feed(chunk.get(..size).unwrap_or_default(), |line| news |= consume(line, &mut mode, &mut shared));
|
||||
}
|
||||
if news {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => {}
|
||||
Err(error) => {
|
||||
log::info!("navigation bridge: connection lost: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut shared) = lock(shared) {
|
||||
*shared = Shared { wanted: shared.wanted, ..Shared::default() };
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Connects to the device app while the preferences ask for it, again whenever it goes away.
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Shared>>) {
|
||||
loop {
|
||||
let Some(port) = lock(&shared).and_then(|shared| shared.wanted) else {
|
||||
std::thread::sleep(READ_WAKE);
|
||||
continue;
|
||||
};
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), CONNECT_TIMEOUT) {
|
||||
serve(stream, port, &ctx, &shared);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which 3D mouse the connected device app reads, for the 3D mouse reader to leave alone. Only
|
||||
/// the Windows and macOS readers know a device's strings to compare.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
pub struct Claim(Arc<Mutex<Shared>>);
|
||||
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
impl Claim {
|
||||
/// Whether a device with these manufacturer and product strings is the one the connected
|
||||
/// device app reads (its name appears in one, ignoring case).
|
||||
pub fn claims(&self, strings: &[String]) -> bool {
|
||||
let Some(device) = lock(&self.0).and_then(|shared| shared.device.clone()) else { return false };
|
||||
let device = device.to_lowercase();
|
||||
strings.iter().any(|s| s.to_lowercase().contains(&device))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NavBridge {
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
}
|
||||
|
||||
impl NavBridge {
|
||||
pub fn start(ctx: &Context) -> Self {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let (worker_ctx, worker_state) = (ctx.clone(), Arc::clone(&shared));
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-nav-bridge".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("navigation bridge: reader could not start: {error}");
|
||||
}
|
||||
Self { shared }
|
||||
}
|
||||
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
pub fn claim(&self) -> Claim {
|
||||
Claim(Arc::clone(&self.shared))
|
||||
}
|
||||
|
||||
/// Each frame: passes the preferences to the reader, and the motion and views since the last
|
||||
/// frame to the view. Motion that arrived while the window had no focus is dropped with it.
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let wanted = Some(app.preferences.nav_bridge_port).filter(|port| app.preferences.nav_bridge_enabled && *port != 0);
|
||||
let Some((motion, views, connected)) = lock(&self.shared).map(|mut shared| {
|
||||
shared.wanted = wanted;
|
||||
(std::mem::take(&mut shared.motion), std::mem::take(&mut shared.views), shared.connected.clone())
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
app.space_mouse.bridge = connected.filter(|_| wanted.is_some());
|
||||
if !motion.is_still() || !views.is_empty() {
|
||||
bridge_frame(app, ctx, motion, &views);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::time::Instant;
|
||||
|
||||
fn feed(shared: &mut Shared, mode: &mut Mode, text: &str) -> bool {
|
||||
let mut news = false;
|
||||
Lines::default().feed(text.as_bytes(), |line| news |= consume(line, mode, shared));
|
||||
news
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_motion_orbits_pans_or_zooms_as_the_mode_says() {
|
||||
let (mut shared, mut mode) = (Shared::default(), Mode::default());
|
||||
assert!(feed(&mut shared, &mut mode, "{\"t\":\"axis\",\"x\":3,\"y\":-2}\n{\"t\":\"axis\",\"x\":1.5,\"y\":0}\n"));
|
||||
assert_eq!(shared.motion, BridgeMotion { orbit: [4.5, -2.0], ..Default::default() });
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":true}\n{\"t\":\"axis\",\"x\":5,\"y\":6}\n");
|
||||
assert_eq!(mode, Mode::Pan);
|
||||
assert_eq!(shared.motion.pan, [5.0, 6.0]);
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"zoom\",\"on\":true}\n{\"t\":\"axis\",\"x\":0,\"y\":-16}\n");
|
||||
assert_eq!(shared.motion.zoom, 2.0, "up zooms in, 8 points a step");
|
||||
// Off, whichever mode it names, is back to orbit.
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":false}\n");
|
||||
assert_eq!(mode, Mode::Orbit);
|
||||
// The ring's steps add up with the zoom mode's.
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"zoom\",\"d\":-0.5}\n");
|
||||
assert_eq!(shared.motion.zoom, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_are_queued_by_their_known_names_only() {
|
||||
let (mut shared, mut mode) = (Shared::default(), Mode::default());
|
||||
assert!(feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"iso\"}\n{\"t\":\"view\",\"name\":\"fit\"}\n"));
|
||||
assert!(!feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"ISO\"}\n{\"t\":\"view\",\"name\":\"../etc\"}\n{\"t\":\"view\"}\n"));
|
||||
assert_eq!(shared.views, ["iso", "fit"]);
|
||||
let flood = "{\"t\":\"view\",\"name\":\"top\"}\n".repeat(100);
|
||||
feed(&mut shared, &mut mode, &flood);
|
||||
assert_eq!(shared.views.len(), MAX_VIEWS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_lines_never_move_the_view_out_of_range() {
|
||||
let (mut shared, mut mode) = (Shared::default(), Mode::default());
|
||||
let lines = [
|
||||
"not json",
|
||||
"{\"t\":\"axis\",\"x\":\"5\",\"y\":null}",
|
||||
"{\"t\":\"axis\",\"x\":1e308,\"y\":-1e308}",
|
||||
"{\"t\":\"axis\",\"x\":1e999}",
|
||||
"{\"t\":\"zoom\",\"d\":[1]}",
|
||||
"{\"t\":42}",
|
||||
"[1,2,3]",
|
||||
"{\"t\":\"mode\",\"mode\":{\"x\":1},\"on\":\"yes\"}",
|
||||
"\u{0}\u{1}\u{2}",
|
||||
];
|
||||
for line in lines {
|
||||
Lines::default().feed(format!("{line}\n").as_bytes(), |l| {
|
||||
consume(l, &mut mode, &mut shared);
|
||||
});
|
||||
}
|
||||
let limit = solidcraft_ui_egui::space_mouse::BRIDGE_MAX_STEP;
|
||||
let values = shared.motion.orbit.into_iter().chain(shared.motion.pan).chain([shared.motion.zoom]);
|
||||
for value in values {
|
||||
assert!(value.is_finite() && value.abs() <= limit, "{value}");
|
||||
}
|
||||
assert_eq!(mode, Mode::Orbit);
|
||||
// Invalid UTF-8 is no message either.
|
||||
assert!(!consume(&[0xff, 0xfe, b'{', b'}'], &mut mode, &mut shared));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_split_across_reads_and_long_ones_are_skipped_to_their_end() {
|
||||
let mut got = Vec::new();
|
||||
let mut lines = Lines::default();
|
||||
for part in ["{\"t\":", "\"zoom\"}\r\n{\"a\"", ":1}\n"] {
|
||||
lines.feed(part.as_bytes(), |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
}
|
||||
assert_eq!(got, ["{\"t\":\"zoom\"}", "{\"a\":1}"]);
|
||||
got.clear();
|
||||
let long = vec![b'x'; MAX_LINE * 3];
|
||||
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
assert!(lines.line.len() <= MAX_LINE + 1, "never holds more than one line");
|
||||
lines.feed(b"tail\nnext\n", |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
assert_eq!(got, ["next"], "the long line is dropped to its newline, the next one read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_device_app_names_itself_and_the_device_it_leaves_alone() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let claim = Claim(Arc::clone(&shared));
|
||||
let strings = ["STMicroelectronics".to_string(), "MAKMOUSE 3D".to_string()];
|
||||
assert!(!claim.claims(&strings), "nothing is claimed before an app says so");
|
||||
let mut mode = Mode::default();
|
||||
if let Ok(mut shared) = shared.lock() {
|
||||
let hello = format!("{{\"t\":\"hello\",\"server\":\"Dev\\u0007App {}\",\"device\":\"makmouse\"}}\n", "x".repeat(200));
|
||||
feed(&mut shared, &mut mode, &hello);
|
||||
assert_eq!(shared.connected.as_deref().map(|n| n.chars().count()), Some(MAX_NAME));
|
||||
assert!(shared.connected.as_deref().is_some_and(|n| n.starts_with("DevApp")));
|
||||
}
|
||||
assert!(claim.claims(&strings), "the name matches ignoring case");
|
||||
assert!(!claim.claims(&["3Dconnexion".to_string(), "SpaceMouse Compact".to_string()]), "another 3D mouse stays the reader's");
|
||||
}
|
||||
|
||||
/// Waits until `check` holds, or fails after a few seconds.
|
||||
fn wait_for(what: &str, mut check: impl FnMut() -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !check() {
|
||||
assert!(Instant::now() < deadline, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_says_hello_reads_the_stream_and_ends_when_the_bridge_is_switched_off() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
|
||||
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let (mut server, _) = listener.accept().unwrap();
|
||||
let worker = {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
|
||||
};
|
||||
let mut hello_line = String::new();
|
||||
BufReader::new(server.try_clone().unwrap()).read_line(&mut hello_line).unwrap();
|
||||
let hello: Value = serde_json::from_str(&hello_line).unwrap();
|
||||
assert_eq!((hello["t"].as_str(), hello["app"].as_str()), (Some("hello"), Some("solidcraft")));
|
||||
server
|
||||
.write_all(b"{\"t\":\"hello\",\"server\":\"MAKMOUSE\",\"device\":\"MAKMOUSE\"}\n{\"t\":\"axis\",\"x\":2,\"y\":1}\n{\"t\":\"view\",\"name\":\"top\"}\n")
|
||||
.unwrap();
|
||||
wait_for("the stream", || shared.lock().is_ok_and(|s| s.motion.orbit == [2.0, 1.0] && s.views == ["top"]));
|
||||
assert_eq!(shared.lock().unwrap().connected.as_deref(), Some("MAKMOUSE"));
|
||||
// Switched off: the connection ends and what it had is forgotten.
|
||||
shared.lock().unwrap().wanted = None;
|
||||
worker.join().unwrap();
|
||||
let after = shared.lock().unwrap();
|
||||
assert!(after.connected.is_none() && after.device.is_none() && after.motion.is_still() && after.views.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_connection_forgets_the_app() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
|
||||
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let (server, _) = listener.accept().unwrap();
|
||||
let worker = {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
|
||||
};
|
||||
wait_for("the connection", || shared.lock().is_ok_and(|s| s.connected.is_some()));
|
||||
drop(server);
|
||||
worker.join().unwrap();
|
||||
let after = shared.lock().unwrap();
|
||||
assert_eq!((after.connected.as_deref(), after.wanted), (None, Some(port)), "still wanted, so the reader connects again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_frame_hands_the_view_the_motion_and_passes_the_preferences_on() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
let ctx = Context::default();
|
||||
let _ = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {});
|
||||
let mut bridge = NavBridge { shared: Arc::new(Mutex::new(Shared::default())) };
|
||||
let queue = |bridge: &NavBridge| {
|
||||
let mut shared = bridge.shared.lock().unwrap();
|
||||
shared.connected = Some("MAKMOUSE".into());
|
||||
shared.motion = BridgeMotion { orbit: [10.0, 0.0], ..Default::default() };
|
||||
};
|
||||
// Off (the default): nothing is wanted, nothing reaches the view.
|
||||
queue(&bridge);
|
||||
bridge.tick(&mut app, &ctx);
|
||||
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
|
||||
assert!(app.viewport.bridge_motion.is_none() && app.space_mouse.bridge.is_none());
|
||||
// On: the port is passed to the reader and the motion to the view, once.
|
||||
app.preferences.nav_bridge_enabled = true;
|
||||
app.preferences.nav_bridge_port = 9123;
|
||||
queue(&bridge);
|
||||
bridge.tick(&mut app, &ctx);
|
||||
assert_eq!(bridge.shared.lock().unwrap().wanted, Some(9123));
|
||||
assert_eq!(app.space_mouse.bridge.as_deref(), Some("MAKMOUSE"));
|
||||
assert_eq!(app.viewport.bridge_motion.map(|m| m.orbit), Some([10.0, 0.0]));
|
||||
assert!(bridge.shared.lock().unwrap().motion.is_still(), "taken, not handed over twice");
|
||||
// Port 0 is no port.
|
||||
app.preferences.nav_bridge_port = 0;
|
||||
bridge.tick(&mut app, &ctx);
|
||||
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+305
-2376
File diff suppressed because it is too large
Load Diff
@@ -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,607 +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.
|
||||
//! - **A renderer that cannot start**: on Windows, try the remaining graphics APIs in order,
|
||||
//! then the software renderer (`SOLIDCRAFT_RENDERER=cpu`). An explicit `WGPU_BACKEND` fails
|
||||
//! cleanly without switching. Exhausted retries show a message box with the error.
|
||||
|
||||
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";
|
||||
|
||||
/// Explicit application-settings isolation for side-by-side builds and native UI
|
||||
/// acceptance. A supplied invalid path refuses startup rather than falling back
|
||||
/// to the user's ordinary settings. No directory is read or created here.
|
||||
pub fn settings_directory(override_path: Option<&std::ffi::OsStr>, default: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||
match override_path {
|
||||
None => Ok(default),
|
||||
Some(raw) => {
|
||||
let path = PathBuf::from(raw);
|
||||
if raw.is_empty() || !path.is_absolute() {
|
||||
return Err("SOLIDCRAFT_SETTINGS_DIR must be a nonempty absolute directory path".into());
|
||||
}
|
||||
if path.exists() && !path.is_dir() {
|
||||
return Err("SOLIDCRAFT_SETTINGS_DIR names a file, not a directory".into());
|
||||
}
|
||||
Ok(Some(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(", ") })
|
||||
})
|
||||
}
|
||||
|
||||
/// Adapter properties used to choose a card without opening a device.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdapterChoice {
|
||||
pub name: String,
|
||||
pub compatible: bool,
|
||||
}
|
||||
|
||||
/// Choose only surface-compatible adapters. Names saved in preferences are exact; the
|
||||
/// environment override follows wgpu's case-insensitive substring convention.
|
||||
/// No match leaves selection to the normal wgpu request.
|
||||
pub fn choose_adapter(adapters: &[AdapterChoice], environment: Option<&str>, preference: Option<&str>) -> Option<usize> {
|
||||
// wgpu's environment helper panics on a missing name; an override must remain recoverable.
|
||||
let compatible = || adapters.iter().enumerate().filter(|(_, a)| a.compatible);
|
||||
if let Some(name) = environment.filter(|s| !s.is_empty()) {
|
||||
let name = name.to_lowercase();
|
||||
if let Some((index, _)) = compatible().find(|(_, a)| a.name.to_lowercase().contains(&name)) {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
if let Some(name) = preference.filter(|s| !s.is_empty())
|
||||
&& let Some((index, _)) = compatible().find(|(_, a)| a.name == name)
|
||||
{
|
||||
return Some(index);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Leave Automatic on wgpu's normal request path; named choices may defer to it too.
|
||||
pub fn configure_adapter(setup: &mut eframe::egui_wgpu::WgpuSetupCreateNew, cpu: bool, environment: Option<String>, preference: Option<String>) {
|
||||
if cpu {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(|adapters, surface| pick_cpu_adapter(adapters, surface).map(Some)));
|
||||
} else if environment.as_deref().is_some_and(|s| !s.is_empty()) || preference.as_deref().is_some_and(|s| !s.is_empty()) {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| {
|
||||
let choices: Vec<_> = adapters
|
||||
.iter()
|
||||
.map(|a| AdapterChoice { name: a.get_info().name, compatible: surface.is_none_or(|s| a.is_surface_supported(s)) })
|
||||
.collect();
|
||||
Ok(choose_adapter(&choices, environment.as_deref(), preference.as_deref()).and_then(|i| adapters.get(i)).cloned())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// `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))
|
||||
}
|
||||
|
||||
/// Internal, process-local retry choice; never written to the graphics preference.
|
||||
pub const RETRY_BACKEND_ENV: &str = "SOLIDCRAFT_STARTUP_BACKEND";
|
||||
/// Why this launch is using a fallback, carried to the new process for status and Preferences.
|
||||
pub const FALLBACK_NOTE_ENV: &str = "SOLIDCRAFT_GRAPHICS_FALLBACK";
|
||||
|
||||
/// A handled startup failure tries each remaining Windows API once, then software rendering.
|
||||
/// An explicit environment choice is final, including when software rendering was requested.
|
||||
pub fn fallback_backend(windows: bool, explicit: bool, cpu: bool, failed: eframe::wgpu::Backends) -> Option<&'static str> {
|
||||
if explicit || cpu {
|
||||
return None;
|
||||
}
|
||||
if windows {
|
||||
let last = WINDOWS_ORDER.iter().rposition(|backend| failed.intersects(*backend));
|
||||
if let Some(next) = last.and_then(|i| WINDOWS_ORDER.get(i + 1)) {
|
||||
return Some(if *next == eframe::wgpu::Backends::VULKAN { "vulkan" } else { "gl" });
|
||||
}
|
||||
}
|
||||
Some("cpu")
|
||||
}
|
||||
|
||||
/// Keep driver diagnostics on one bounded line, including across the finite retry chain.
|
||||
pub fn fallback_note(previous: &str, failed: eframe::wgpu::Backends, next: &str, error: &str) -> String {
|
||||
let previous: String = previous.chars().take(2048).collect();
|
||||
let error: String = error.chars().take(1024).collect();
|
||||
let line = format!("{previous} Graphics fallback: {failed:?} failed ({error}); trying {next}.");
|
||||
line.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(4096).collect()
|
||||
}
|
||||
|
||||
/// 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 = solidcraft_gpu::instance_descriptor_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();
|
||||
desc.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
|
||||
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 environment variable that switches wgpu's indirect-call validation on or off.
|
||||
pub const INDIRECT_VALIDATION_ENV: &str = "WGPU_VALIDATION_INDIRECT_CALL";
|
||||
|
||||
/// The instance flags for the window. wgpu's indirect-call validation compiles a compute shader
|
||||
/// when the device is created, and on Intel Macs (macOS 12 with Intel Iris graphics, #3024,
|
||||
/// #3080, #4605) the Metal compiler refuses it: the device is lost and the window never opens.
|
||||
/// So on an Intel Mac the validation is left off, unless the user turned it on or off with
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL` themselves.
|
||||
pub fn window_instance_flags(flags: eframe::wgpu::InstanceFlags, intel_mac: bool, env: Option<&str>) -> eframe::wgpu::InstanceFlags {
|
||||
if intel_mac && env.map(str::trim).is_none_or(str::is_empty) { flags - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL } else { flags }
|
||||
}
|
||||
|
||||
/// Windows with OpenGL: the window keeps the system title bar (#2063). The window without OS
|
||||
/// decorations keeps its caption style while having no frame, and some OpenGL drivers (older
|
||||
/// Intel HD/UHD) then show the picture a caption's height lower than the window, so it no longer
|
||||
/// lines up with the pointer. Direct3D 12 and Vulkan draw it in place and keep the application bar
|
||||
/// as the title bar. `backends` are the ones wgpu starts: OpenGL alone is decided before the
|
||||
/// window opens.
|
||||
pub fn system_title_bar(windows: bool, backends: eframe::wgpu::Backends) -> bool {
|
||||
use eframe::wgpu::Backends;
|
||||
windows && backends.contains(Backends::GL) && !backends.intersects(Backends::DX12 | Backends::VULKAN)
|
||||
}
|
||||
|
||||
/// The `--max-file-size` value (#4086): a size such as `2GB` or `1500MB`, at most 16 GB. It raises
|
||||
/// the largest STEP/import and design file this run reads, over the saved preference, without
|
||||
/// saving anything. A missing or bad value stops the start with this error.
|
||||
pub fn max_file_size(value: Option<&str>) -> Result<u64, String> {
|
||||
let value = value.ok_or("--max-file-size needs a size, for example --max-file-size 2GB")?;
|
||||
solidcraft_engine::io::limits::parse_flag(value).map_err(|e| format!("--max-file-size: {e}"))
|
||||
}
|
||||
|
||||
/// 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 cards() -> Vec<AdapterChoice> {
|
||||
["Integrated", "Discrete small", "Discrete large"].into_iter().map(|name| AdapterChoice { name: name.into(), compatible: true }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_file_size_parses_sizes_and_refuses_bad_ones() {
|
||||
assert_eq!(max_file_size(Some("2GB")), Ok(2 << 30));
|
||||
assert_eq!(max_file_size(Some("1500MB")), Ok(1500 << 20));
|
||||
for bad in [None, Some(""), Some("2"), Some("17GB"), Some("lots"), Some("-1GB")] {
|
||||
let error = max_file_size(bad).unwrap_err();
|
||||
assert!(error.contains("--max-file-size"), "{bad:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_names_environment_override_and_missing_card_fallback() {
|
||||
let cards = cards();
|
||||
assert_eq!(choose_adapter(&cards, None, Some("Discrete small")), Some(1));
|
||||
assert_eq!(choose_adapter(&cards, Some("LARGE"), Some("Discrete small")), Some(2));
|
||||
assert_eq!(choose_adapter(&cards, None, Some("removed")), None);
|
||||
assert_eq!(choose_adapter(&cards, Some("missing"), Some("Discrete small")), Some(1));
|
||||
assert_eq!(choose_adapter(&cards, Some(""), Some("Discrete small")), Some(1));
|
||||
assert_eq!(choose_adapter(&cards, None, Some("Discrete")), None, "saved names are exact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_adapter_leaves_selection_to_wgpu() {
|
||||
let mut cards = cards();
|
||||
assert_eq!(choose_adapter(&cards, None, None), None);
|
||||
assert_eq!(choose_adapter(&cards, Some(""), Some("")), None);
|
||||
cards[2].compatible = false;
|
||||
assert_eq!(choose_adapter(&cards, Some("large"), Some("Discrete large")), None);
|
||||
assert_eq!(choose_adapter(&cards, Some("small"), None), Some(1));
|
||||
assert_eq!(choose_adapter(&[], None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_adapter_preserves_the_previous_request_configuration() {
|
||||
use eframe::egui_wgpu::WgpuSetupCreateNew;
|
||||
let previous = WgpuSetupCreateNew::without_display_handle();
|
||||
for preference in [None, Some(String::new())] {
|
||||
let mut setup = WgpuSetupCreateNew::without_display_handle();
|
||||
configure_adapter(&mut setup, false, None, preference);
|
||||
assert!(setup.native_adapter_selector.is_none(), "Automatic must use the normal wgpu request");
|
||||
assert_eq!(setup.power_preference, previous.power_preference);
|
||||
assert_eq!(setup.instance_descriptor.backends, previous.instance_descriptor.backends);
|
||||
}
|
||||
let mut setup = WgpuSetupCreateNew::without_display_handle();
|
||||
configure_adapter(&mut setup, false, Some("missing".into()), Some("removed".into()));
|
||||
assert!(setup.native_adapter_selector.unwrap()(&[], None).unwrap().is_none(), "missing names must use the normal request too");
|
||||
}
|
||||
|
||||
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 isolated_settings_are_explicit_absolute_and_never_fall_back() {
|
||||
let default = std::env::temp_dir().join("ordinary-settings");
|
||||
assert_eq!(settings_directory(None, Some(default.clone())).unwrap(), Some(default.clone()));
|
||||
let selected = std::env::temp_dir().join("solidcraft-isolated-settings-fixture");
|
||||
assert_eq!(settings_directory(Some(selected.as_os_str()), Some(default.clone())).unwrap(), Some(selected));
|
||||
for raw in ["", "relative/settings", "../ordinary"] {
|
||||
assert!(settings_directory(Some(std::ffi::OsStr::new(raw)), Some(default.clone())).is_err());
|
||||
}
|
||||
let file = std::env::current_exe().unwrap();
|
||||
assert!(settings_directory(Some(file.as_os_str()), Some(default)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_macs_start_without_indirect_call_validation() {
|
||||
use eframe::wgpu::InstanceFlags as F;
|
||||
let on = F::VALIDATION | F::VALIDATION_INDIRECT_CALL;
|
||||
assert_eq!(window_instance_flags(on, true, None), F::VALIDATION);
|
||||
assert_eq!(window_instance_flags(on, true, Some(" ")), F::VALIDATION);
|
||||
// The user's own choice stands, on or off.
|
||||
assert_eq!(window_instance_flags(on, true, Some("1")), on);
|
||||
assert_eq!(window_instance_flags(F::VALIDATION, true, Some("0")), F::VALIDATION);
|
||||
// Every other machine keeps what it had.
|
||||
assert_eq!(window_instance_flags(on, false, None), on);
|
||||
}
|
||||
|
||||
#[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 failed_windows_backends_fall_back_in_order_and_stop_after_software() {
|
||||
use eframe::wgpu::Backends as B;
|
||||
assert_eq!(fallback_backend(true, false, false, B::DX12), Some("vulkan"));
|
||||
assert_eq!(fallback_backend(true, false, false, B::VULKAN), Some("gl"));
|
||||
assert_eq!(fallback_backend(true, false, false, B::GL), Some("cpu"));
|
||||
assert_eq!(fallback_backend(true, false, true, B::DX12), None);
|
||||
assert_eq!(fallback_backend(true, false, false, B::DX12 | B::VULKAN), Some("gl"));
|
||||
assert_eq!(fallback_backend(false, false, false, B::VULKAN), Some("cpu"));
|
||||
let note = fallback_note("", B::DX12, "vulkan", "egui_pipeline\n0x80070057");
|
||||
assert!(note.contains("egui_pipeline 0x80070057") && note.contains("vulkan") && !note.contains('\n'));
|
||||
let long = fallback_note(&"x".repeat(5000), B::VULKAN, "gl", &"y".repeat(5000));
|
||||
assert!(long.chars().count() <= 4096 && long.ends_with("trying gl."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_failure_never_switches_api_or_retries_software() {
|
||||
use eframe::wgpu::Backends as B;
|
||||
for windows in [false, true] {
|
||||
for cpu in [false, true] {
|
||||
for backend in [B::DX12, B::VULKAN, B::GL, B::all()] {
|
||||
assert_eq!(fallback_backend(windows, true, cpu, backend), None);
|
||||
let mut probed = false;
|
||||
assert_eq!(
|
||||
choose_backends(windows, true, cpu, Some("vulkan"), |_| {
|
||||
probed = true;
|
||||
false
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert!(!probed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_opengl_on_windows_gets_the_system_title_bar() {
|
||||
use eframe::wgpu::Backends;
|
||||
assert!(system_title_bar(true, Backends::GL));
|
||||
for b in [Backends::DX12, Backends::VULKAN, Backends::DX12 | Backends::GL, Backends::VULKAN | Backends::GL, Backends::all()] {
|
||||
assert!(!system_title_bar(true, b), "{b:?}");
|
||||
}
|
||||
for b in [Backends::GL, Backends::VULKAN, Backends::METAL] {
|
||||
assert!(!system_title_bar(false, b), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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. On Windows the probe
|
||||
// goes the way the app's does, stopping at the first backend with a GPU: a machine with
|
||||
// D3D12 never loads its OpenGL driver into the test process, and AMD's (`atio6axx.dll`)
|
||||
// aborted the process as it exited (#1156).
|
||||
if cfg!(target_os = "windows") {
|
||||
let _ = choose_backends(true, false, false, None, has_hardware_adapter);
|
||||
} else {
|
||||
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"));
|
||||
// The adapters the software renderer chooses among: it starts D3D12 alone on Windows
|
||||
// (WARP), not every backend, so the test doesn't load the OpenGL driver either (#1156).
|
||||
let options = crate::wgpu_options(true, None);
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { panic!("the software renderer makes its own device") };
|
||||
let backends = setup.instance_descriptor.backends;
|
||||
if cfg!(target_os = "windows") && std::env::var_os(WGPU_BACKEND_ENV).is_none() {
|
||||
assert_eq!(backends, eframe::wgpu::Backends::DX12);
|
||||
}
|
||||
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
|
||||
desc.backends = backends;
|
||||
let instance = eframe::wgpu::Instance::new(desc);
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
|
||||
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.
|
||||
+7
-11
@@ -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
|
||||
@@ -25,18 +23,16 @@ assets/
|
||||
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
|
||||
locales/{en,de,ja,ko}.json # shared offline catalogs
|
||||
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
|
||||
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
|
||||
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
|
||||
@@ -76,16 +72,16 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
| macOS | Composer master and paired exports; bundle installs `Assets.car` and `SolidCraft.icns` |
|
||||
| Windows executable | `solidcraft-executable.ico`: original DIB/mask representations at 16/24/32/48/64/128/256 px |
|
||||
| Windows installer | `solidcraft.ico`: PNG representations at 16/20/24/32/40/48/64/128/256 px; `SOLIDCRAFT_ICONS` override retained |
|
||||
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `io.tangencylabs.solidcraft` |
|
||||
| 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,5 +1,5 @@
|
||||
//! Built-in model appearances: names, sRGB colours and metalness.
|
||||
pub const LIBRARY: &[(&str, [u8; 3], f64)] = &[
|
||||
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
|
||||
("Aluminum - Satin", [204, 208, 214], 1.0),
|
||||
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
|
||||
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
//! Original illustrative opaque finishes; not measured optical or engineering data.
|
||||
//! (name, sRGB color, metal fraction, perceptual roughness). IOR is an illustrative 1.5.
|
||||
//! Separate names preserve the scalar library's appearance and saved assignments.
|
||||
pub const OPAQUE_FINISHES: &[(&str, [u8; 3], f64, f64)] = &[
|
||||
("Steel - Satin PBR", [158, 164, 172], 1.0, 0.4),
|
||||
("Aluminum - Satin PBR", [204, 208, 214], 1.0, 0.3),
|
||||
("Copper - Clean PBR", [204, 122, 80], 1.0, 0.25),
|
||||
("ABS - Molded PBR", [222, 222, 216], 0.0, 0.4),
|
||||
("PLA - Smooth PBR", [110, 160, 222], 0.0, 0.35),
|
||||
("Wood - Oak Uniform PBR Preview", [190, 146, 92], 0.0, 0.65),
|
||||
];
|
||||
@@ -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] = &[
|
||||
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),
|
||||
),
|
||||
];
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user