Compare commits
357 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea32f8aac | |||
| 034aa60d47 | |||
| 971b223246 | |||
| 8c861f9f46 | |||
| e04a016e7c | |||
| 93a2922c5e | |||
| ff8b09c225 | |||
| 975a08c992 | |||
| 55587f4ac4 | |||
| e94900933d | |||
| 4927214976 | |||
| 57bcc40838 | |||
| 9974413a5b | |||
| 818af2b856 | |||
| 80238a7cfb | |||
| bd65a561c8 | |||
| 776541b081 | |||
| 79a843c963 | |||
| 1bcc3602b4 | |||
| 93684a4728 | |||
| 9db39f3d7d | |||
| 29369a0bb5 | |||
| 5a9e771cc9 | |||
| 63cb48a4ee | |||
| 36c732ddb7 | |||
| fdec948abc | |||
| 7803b3968f | |||
| cbac4eab3f | |||
| 08eff4f9dd | |||
| 9b80163421 | |||
| 164554edba | |||
| 453c6bcb14 | |||
| f44ff18ca4 | |||
| 98cd67f029 | |||
| d9d6af51c6 | |||
| 38c710e72e | |||
| 5b5b051e02 | |||
| 686576e368 | |||
| 68e4186080 | |||
| b66ad0a9c9 | |||
| 2eacbc37f4 | |||
| f2f9c6fe27 | |||
| d21e2b1afd | |||
| bdd2fb1a5c | |||
| e64f1e0427 | |||
| ac362945cc | |||
| 382680d8e8 | |||
| 316b49cd84 | |||
| b0932a487e | |||
| edad8078ba | |||
| 26c0c19ef7 | |||
| 2f43ad5c5b | |||
| 6a8604c55d | |||
| 4098ff9dc8 | |||
| 97383454c6 | |||
| 10d7bd045e | |||
| 6e87fb1a25 | |||
| 30e0db1e05 | |||
| 2b1cb2c4e1 | |||
| f815491936 | |||
| a595805fc8 | |||
| 0379ee76c6 | |||
| 4f7ff8c4d8 | |||
| d2188fb7ab | |||
| 438abbcf82 | |||
| 079a600481 | |||
| 94bd8719aa | |||
| fc3dedfd5c | |||
| 41a62d210c | |||
| 5638259737 | |||
| 090119a5c5 | |||
| b621819c0c | |||
| 522ae77556 | |||
| 5ba8c815a6 | |||
| cb1b230732 | |||
| 6e22e3709d | |||
| 1b84ce6330 | |||
| 687caad24c | |||
| 7e05c8b23c | |||
| 7dfbaf2a2c | |||
| 1e0e8f78dc | |||
| ec552a5300 | |||
| c3151fb7fe | |||
| a9aab13d92 | |||
| 6235295ba6 | |||
| 51e2f0a34b | |||
| 0191e3e157 | |||
| 754eb56e5b | |||
| 9d04e28b51 | |||
| 8e77d9fd85 | |||
| c78095ccde | |||
| 6decbf407f | |||
| 6f521991c7 | |||
| 6c9ddc2528 | |||
| 41f5957d60 | |||
| d45fcce72c | |||
| a3e2fe25bb | |||
| 57aad71bbe | |||
| f2eaead594 | |||
| a5e0fe3ec4 | |||
| dad56187df | |||
| 7026248e25 | |||
| 1122cd44b8 | |||
| 837dbe66df | |||
| 845edeba5f | |||
| c2e3bdfe1c | |||
| a0c1a67220 | |||
| 3606d44ad9 | |||
| 20a0d3b2e4 | |||
| 5d9ebe9911 | |||
| 76af4ee855 | |||
| b2168435ba | |||
| 34afcf75c1 | |||
| 932d8d6b4f | |||
| fb87cde6a1 | |||
| 5305d7c82d | |||
| 59cd55f531 | |||
| 2668518079 | |||
| e29a57dc3a | |||
| e0cc740cc3 | |||
| 511faedcfa | |||
| c5129a45d7 | |||
| 23b0120759 | |||
| 76b17f2663 | |||
| 83790139b1 | |||
| 7bda28e786 | |||
| db75b7bfac | |||
| 2f5f65d6e3 | |||
| 9e78e86b92 | |||
| 4dcca282da | |||
| e98e065584 | |||
| af32243df1 | |||
| 885d2aafbb | |||
| c7bd467be7 | |||
| fb811fbf21 | |||
| 843c0bf97a | |||
| 50da77abc4 | |||
| 95de388ba1 | |||
| d8eb65d26e | |||
| 7c6845da95 | |||
| 9c26f6dbc9 | |||
| 2061ad1694 | |||
| e37a8fa6bb | |||
| b3811168fc | |||
| c64a611818 | |||
| 9a0e6f8ae0 | |||
| 689038d187 | |||
| 3f7959b536 | |||
| a7bc7fe2ff | |||
| 876d57a67b | |||
| 47108fa063 | |||
| ca9ebb44db | |||
| e44dd4ff9e | |||
| ebada963a4 | |||
| 9666c0be56 | |||
| 9506336829 | |||
| 9dfa9e2d52 | |||
| c5b637600c | |||
| 3855c4c7e2 | |||
| d60b438f42 | |||
| 2ace233cd4 | |||
| c83446c6c9 | |||
| 349a6c6fc4 | |||
| f6cf8ceaad | |||
| 83ecf91ae7 | |||
| a3f28c0b89 | |||
| ac757960dc | |||
| 953fd7d5f8 | |||
| ae2ab87b99 | |||
| 8a562f2719 | |||
| e9b1a077d7 | |||
| 37a8402cae | |||
| 9e15995a74 | |||
| 926a7353a3 | |||
| c400188778 | |||
| 58303ba036 | |||
| fe3b14e798 | |||
| e27d43553c | |||
| de7fabc3bc | |||
| 1b6c10f6e0 | |||
| 59b6eea516 | |||
| b168750fb3 | |||
| 25ea9e6419 | |||
| fa3ada1983 | |||
| 759e22b582 | |||
| 14b7529465 | |||
| bdfaf690d7 | |||
| b4520d265e | |||
| 46a0b25280 | |||
| ca9c363a98 | |||
| d0afc31632 | |||
| 80719744cf | |||
| 2de99c728e | |||
| adaac4d1b6 | |||
| 0a328a3d0a | |||
| 74af4a3471 | |||
| 9e7a7b260b | |||
| e661e6f809 | |||
| b622a46624 | |||
| 3ffd8efffc | |||
| 26dd2548b7 | |||
| 07be3f8852 | |||
| 33d7a86d50 | |||
| e984e7d738 | |||
| 62b49f9f89 | |||
| c8283f2196 | |||
| f97a84c100 | |||
| af436493a2 | |||
| 57b52d8a49 | |||
| d55521aa3e | |||
| 68c7208203 | |||
| 91753051a1 | |||
| b2622325a1 | |||
| b93d8fa0b1 | |||
| 843726a8cb | |||
| 65e6d9dafd | |||
| 803ae6d5be | |||
| d90ec9586c | |||
| a108ffac38 | |||
| ee67ed53af | |||
| 504d7c9488 | |||
| 206fffdd80 | |||
| 54b00fa7b1 | |||
| a9f621837f | |||
| 68ac9976f3 | |||
| 9c954b46ed | |||
| 66951921a1 | |||
| 98c30d28f9 | |||
| 4d99d79205 | |||
| 44cbd7ae73 | |||
| 53a9260aeb | |||
| e5291dc8f7 | |||
| a1badaed17 | |||
| 3d105fb281 | |||
| 0b787f8eae | |||
| a6b44ab0d3 | |||
| b0c4b6e939 | |||
| edc01b19bf | |||
| 69db6ee6e4 | |||
| 247cf357b6 | |||
| f945e79278 | |||
| fd5795b5ad | |||
| f1b721b00c | |||
| c41845e6d7 | |||
| 2f92aaee6c | |||
| 6b4026fd19 | |||
| e9b850572d | |||
| b045d9deae | |||
| 7555b34afb | |||
| 948c208db9 | |||
| 983fbe2b14 | |||
| 80dcf9de7f | |||
| 02c77df5f7 | |||
| 77c3b1e765 | |||
| 28d67fb577 | |||
| 1999e763ab | |||
| 750e08aab7 | |||
| 7ce9c29f95 | |||
| 31b41978fc | |||
| f42c068063 | |||
| 26f42ae09b | |||
| f68a3dd5d5 | |||
| 72601e678a | |||
| 42e991069f | |||
| c1f9224579 | |||
| b0b59480eb | |||
| e1268c6ad9 | |||
| 473f1e6516 | |||
| 539c17b425 | |||
| d3fa22e987 | |||
| 45ae7c00e0 | |||
| 24d64799d8 | |||
| a9c7616e7f | |||
| d306d6e658 | |||
| b20e693c2e | |||
| b3f205a638 | |||
| bd4eb42c2d | |||
| 45d0b6053b | |||
| 99d8d382b9 | |||
| d8d04c9009 | |||
| 6f93e7a851 | |||
| 1cd9983c8f | |||
| f1f5298bc5 | |||
| 15db51304e | |||
| b00d45cc7b | |||
| f3505f0db6 | |||
| e4d9015f68 | |||
| 3b4db52c10 | |||
| fb82ccb273 | |||
| 3115c50359 | |||
| 5bed5196c7 | |||
| b4e049f9a6 | |||
| b76302e850 | |||
| 771f62be71 | |||
| aee47fca00 | |||
| 4c30d944f5 | |||
| 5f399207bf | |||
| 7fd51ce6a5 | |||
| aef13b52f4 | |||
| 355154fa9b | |||
| f1a5f4ab30 | |||
| 3a9b053f5b | |||
| aaa35a94d8 | |||
| 852d364d5f | |||
| 34cb3167b6 | |||
| 1666ca7018 | |||
| 768e69c4c7 | |||
| 5997d28de7 | |||
| 5122b98af3 | |||
| f9342aa8b6 | |||
| d9c3fdb80f | |||
| dc27988bc2 | |||
| e3831b05d4 | |||
| 59ba87103e | |||
| 19ffb0dd75 | |||
| c66e33284b | |||
| 57d210130c | |||
| 71534117af | |||
| 38f6ed76c9 | |||
| 66108a6b99 | |||
| 8e762fee57 | |||
| 6b1fc16d47 | |||
| 1ea85ff231 | |||
| 2aa808b116 | |||
| aa438611a7 | |||
| 86b2b97d6f | |||
| c177adfceb | |||
| 7ad5130cd2 | |||
| a3080d02d6 | |||
| 17e173cb7b | |||
| 46bc44b706 | |||
| 9eb1540a34 | |||
| 3a1e1eed39 | |||
| 9ee2de9a0f | |||
| ed64988d8f | |||
| 03e6c3d468 | |||
| cbf589eb8c | |||
| cd85efd73d | |||
| 2452513b65 | |||
| 9c411859bc | |||
| 22a86a744e | |||
| 143435dc1c | |||
| 36aa4a15fe | |||
| e887792182 | |||
| dd23be12d5 | |||
| ab22da1343 | |||
| 011ff24107 | |||
| 4a4eb59788 | |||
| 207c67d1da | |||
| ff5d96aaad | |||
| bc68e14c3e | |||
| 6d410c342c | |||
| dded27277f | |||
| 46462957e4 | |||
| a940f77447 | |||
| c59842b8e1 | |||
| a587d3339e |
@@ -0,0 +1,14 @@
|
||||
# cargo-mutants (docs/mutation-testing.md). Mutants are judged by the black-box suites that will
|
||||
# outlive the kernel backend: the reference tests (tests/oracle, through the oracle_cases test)
|
||||
# and the pull-request sample of the robustness goldens (every 8th seed, as ci.yml). The
|
||||
# kernel's own unit tests are left out on purpose: a mutant only they catch is a behaviour the
|
||||
# backend-neutral suites would miss when the kernel is swapped.
|
||||
profile = "mutants"
|
||||
additional_cargo_test_args = [
|
||||
"--test", "oracle_cases", "--lib", "--", "--include-ignored",
|
||||
"oracle_suite",
|
||||
"robustness_goldens::seed_00", "robustness_goldens::seed_08", "robustness_goldens::seed_16",
|
||||
"robustness_goldens::seed_24", "robustness_goldens::seed_32", "robustness_goldens::seed_40",
|
||||
"robustness_goldens::seed_48", "robustness_goldens::seed_56", "robustness_goldens::seed_64",
|
||||
"robustness_goldens::seed_72", "robustness_goldens::seed_80", "robustness_goldens::seed_88",
|
||||
]
|
||||
@@ -1,8 +1,8 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
|
||||
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
@@ -17,6 +17,7 @@ not (
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
|
||||
| (package(solidcraft-kernel) & binary(boolean_props))
|
||||
| (package(solidcraft-engine) & binary(atomic_commands))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
@@ -32,10 +33,39 @@ slow-timeout = { period = "60s" }
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
|
||||
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
|
||||
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
|
||||
# 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 = "num-cpus"
|
||||
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
|
||||
|
||||
@@ -2,3 +2,10 @@ 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,38 +2,50 @@
|
||||
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
|
||||
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
|
||||
# packages that depend on them, and Clippy, the test archive and the test shards cover only
|
||||
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
|
||||
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
|
||||
# pushes to main, the nightly run and Run workflow.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# those. Test data under tests/, vendored crates under vendor/ and root files a crate embeds
|
||||
# select the packages that read them. Changes to the lockfile, root manifest,
|
||||
# toolchain/cargo/nextest config, this workflow, scripts/ or any file no package owns select the
|
||||
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
|
||||
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
|
||||
# no native job runs for them, on pull requests or on main.
|
||||
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
|
||||
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
|
||||
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
|
||||
# nightly always run them. Only main saves the build caches; pull requests restore them.
|
||||
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
|
||||
# main that a passing merge-queue run already tested runs only the repository checks; a push
|
||||
# that skipped the queue still runs everything. Pull requests run the reference tests when the
|
||||
# command line (or anything it depends on) changes, and doc tests for the affected packages.
|
||||
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
|
||||
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
|
||||
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
|
||||
# only main commits.
|
||||
# A push to main runs only the repository checks when a passing merge-queue run tested that very
|
||||
# commit, or when nothing a build reads changed since the last push to main whose run passed.
|
||||
# Otherwise it runs everything.
|
||||
# Heavy checks wait for review: a pull request without the `approved-for-ci` label runs only
|
||||
# Select checks, the format and repository checks and a quick compile. Adding the label (after
|
||||
# review) runs everything above, and every later push to the PR does too, except a push that only
|
||||
# merges main into a change that already passed (scripts/ci/main_merge.py): it carries that
|
||||
# result and runs the repository checks. Merge groups, the nightly run and Run workflow are not
|
||||
# pull requests and always run the full suite.
|
||||
# GitHub cannot filter `labeled` events by label name, so any other label starts a run whose jobs
|
||||
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run. Its
|
||||
# `ci` job still runs and reports the commit's real result (see the `ci` job): the merge queue
|
||||
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
|
||||
# of this repository (people with write access; fork pull requests never run on this machine).
|
||||
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
|
||||
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
|
||||
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
|
||||
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
|
||||
# `self-hosted-untrusted` runners (no warm caches) when the PR number ends in 7, 8 or 9 (the rest of
|
||||
# the fork PRs, and everything else, run on GitHub's runners, which have far more free slots).
|
||||
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
|
||||
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
|
||||
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
|
||||
# use GitHub's runners only; the self-hosted runners refuse any other job.
|
||||
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
|
||||
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
|
||||
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
|
||||
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
|
||||
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
|
||||
# other run restores read-only. Nightly and by hand, Build tests also makes an archive the matrix
|
||||
# jobs run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
|
||||
# same.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
|
||||
# marked ready for review, and on every push after that.
|
||||
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
|
||||
# only (other labels are skipped, see the top of this file).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled]
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
@@ -53,7 +65,10 @@ permissions:
|
||||
concurrency:
|
||||
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
|
||||
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
|
||||
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
|
||||
# unlabelled run with the full one.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -63,7 +78,7 @@ jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -75,10 +90,10 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
# Not on a draft: every other check needs this one, so they skip too.
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
# Not on a draft: every other check needs this one, so they skip too. Not on a label other than
|
||||
# approved-for-ci either (the same skip, for the same reason).
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
@@ -89,12 +104,13 @@ jobs:
|
||||
goldens: ${{ steps.paths.outputs.goldens }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
|
||||
quick: ${{ steps.paths.outputs.quick }}
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
oracle: ${{ steps.paths.outputs.oracle }}
|
||||
# Whether the other Linux jobs use the self-hosted runners too.
|
||||
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
|
||||
# route the jobs after them to the trusted runners.
|
||||
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
doctests: ${{ steps.paths.outputs.doctests }}
|
||||
# The passing run a merge of main carries forward (its URL), when this push is only that.
|
||||
carried: ${{ steps.paths.outputs.carried }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -106,7 +122,11 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
native=false
|
||||
@@ -115,17 +135,38 @@ jobs:
|
||||
windows=false
|
||||
full=true
|
||||
packages=
|
||||
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
|
||||
# something or the whole workspace. Documentation, packaging, other workflows and the bots'
|
||||
# scripts select nothing. Pull requests and pushes to main share it.
|
||||
select_packages() {
|
||||
python3 -I scripts/ci/affected.py --paths "$1" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
}
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# The queue lands the very commit its merge_group run tested. If that run passed,
|
||||
# testing the push again proves nothing new; otherwise (a merge that skipped the
|
||||
# queue, or the API cannot answer) test everything.
|
||||
# curl and grep only: Select checks of a push runs on the self-hosted runner.
|
||||
# The API through curl and grep, which every runner image has.
|
||||
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
|
||||
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
|
||||
if [ "${tested:-0}" -gt 0 ]; then
|
||||
echo "The merge queue already tested $PUSH_SHA: repository checks only."
|
||||
elif tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=push&branch=main&status=success&per_page=1" \
|
||||
| grep -o '"head_sha": *"[0-9a-f]*"' | head -n 1 | grep -o '[0-9a-f]\{40\}') \
|
||||
&& [ -n "$tested" ] && git merge-base --is-ancestor "$tested" "$PUSH_SHA" \
|
||||
&& git diff --no-renames --name-only -z "$tested" "$PUSH_SHA" > "$RUNNER_TEMP/ci-paths" \
|
||||
&& select_packages "$RUNNER_TEMP/ci-paths" && [ "$full" != true ] && [ -z "$packages" ]; then
|
||||
# Nothing a build reads changed since the last push to main whose run passed (the
|
||||
# push before may never have run: a newer push cancels a waiting one).
|
||||
echo "$tested..$PUSH_SHA changes no code: repository checks only."
|
||||
else
|
||||
# Code changed (or the push can't be compared): main runs everything.
|
||||
full=true
|
||||
packages=
|
||||
native=true
|
||||
windows=true
|
||||
fi
|
||||
@@ -137,9 +178,12 @@ jobs:
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
|
||||
# What can change a robustness golden: the kernel and the crates a design is built with.
|
||||
# What a pull request re-checks the goldens for: the kernel, truck and the goldens
|
||||
# themselves (a re-record is checked before it is queued). The goldens are frozen, so
|
||||
# on any other change they would only re-check unchanged results; the merge queue,
|
||||
# main and the nightly run every seed before anything lands.
|
||||
case "$path" in
|
||||
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*) goldens=true ;;
|
||||
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
|
||||
esac
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
@@ -147,14 +191,6 @@ jobs:
|
||||
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
|
||||
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
|
||||
# The GitHub bots' scripts: no Rust, tested in this job (Claim and triage bot tests).
|
||||
scripts/claim/*|scripts/triage/*) ;;
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
|
||||
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
|
||||
@@ -162,13 +198,10 @@ jobs:
|
||||
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
|
||||
windows=true
|
||||
fi
|
||||
# Pull requests build and test the touched packages and their dependents.
|
||||
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
# Code changed but nothing was selected: be safe and test everything.
|
||||
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
|
||||
# Pull requests build and test the touched packages and their dependents; nothing
|
||||
# selected means no code changed.
|
||||
select_packages "$RUNNER_TEMP/ci-paths"
|
||||
if [ "$full" = true ] || [ -n "$packages" ]; then native=true; fi
|
||||
# The goldens are solidcraft-engine tests: its tests must be in the archive.
|
||||
if [ "$goldens" = true ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-engine "*) ;; *) packages="$packages solidcraft-engine" ;; esac
|
||||
@@ -180,36 +213,60 @@ jobs:
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
|
||||
fi
|
||||
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
|
||||
# not on every pull request push; a break there is fixed forward on main.
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
|
||||
# The pull request fast lane: Windows, the reference tests and the doc tests run on main
|
||||
# (and the nightly), not on every pull request push; a break there is fixed forward on
|
||||
# main. Over three days of pull requests the doc tests were the only failing job 3 times
|
||||
# in about 7,900 runner-minutes, and the reference tests never were.
|
||||
doctests=$native
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; doctests=false; fi
|
||||
# Until a maintainer adds approved-for-ci, a pull request gets a quick compile instead of
|
||||
# Clippy, the tests, the doc tests and the goldens.
|
||||
quick=false
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$APPROVED" != true ]; then
|
||||
quick=$native
|
||||
native=false
|
||||
oracle=false
|
||||
goldens=false
|
||||
fi
|
||||
# A push that only merges main into the change (Merge branch 'main', Update branch) when
|
||||
# the change itself already passed: nothing new to test here, and main tests the merged
|
||||
# result after it lands. Over three days, 872 of 1,241 such merges brought in nothing that
|
||||
# touched the pull request's files, and each restarted the whole run.
|
||||
carried=
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$native" = true ]; then
|
||||
carried=$(python3 -I scripts/ci/main_merge.py --base "$BASE_SHA" --head "$HEAD_SHA" --repo "$REPO" \
|
||||
--pr "$PR_NUMBER" --head-repo "$HEAD_REPO" --head-ref "$HEAD_REF" | sed -n 's/^carried=//p')
|
||||
if [ -n "$carried" ]; then
|
||||
echo "Only a merge of main into a change that passed in $carried: repository checks only."
|
||||
native=false
|
||||
goldens=false
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
echo "goldens=$goldens"
|
||||
echo "windows=$windows"
|
||||
echo "oracle=$oracle"
|
||||
echo "doctests=$doctests"
|
||||
echo "carried=$carried"
|
||||
echo "full=$full"
|
||||
echo "quick=$quick"
|
||||
echo "packages=$packages"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh checks
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -231,27 +288,43 @@ jobs:
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim, triage and kernel-paused bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
name: Quick compile
|
||||
needs: changes
|
||||
if: needs.changes.outputs.quick == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: quick-compile
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh clippy
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -267,21 +340,33 @@ jobs:
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
# The archive is for the action matrix and the property tests, which run only nightly and by
|
||||
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 90 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
# Fork builds start from main's cache, read-only; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-archive
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
@@ -313,73 +398,110 @@ jobs:
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
name: Native Linux tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
|
||||
# Builds the tests and runs them in one job on a big runner: there is no archive to upload
|
||||
# and download (3 GB on a full run), and the job starts beside the repository checks, not
|
||||
# after them. nextest hands tests to its threads as they free up, the slowest first (priority
|
||||
# in .config/nextest.toml), so they start at once and no core idles at the end.
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
|
||||
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[1, 2, 3, 4]') }}
|
||||
env:
|
||||
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
|
||||
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
|
||||
# would skip for want of an adapter fails instead.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
ACTION_MATRIX_JOBS: "12"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# Preinstalled on the self-hosted runner, which has no sudo.
|
||||
if: runner.environment == 'github-hosted'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-tests
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
GOLDENS: ${{ needs.changes.outputs.goldens }}
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
|
||||
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
|
||||
# queue and the nightly run every seed.
|
||||
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
|
||||
# below is that filter, so a pull request still runs only what it ran before.
|
||||
base='default()'
|
||||
if [ "$EVENT_NAME" = pull_request ]; then
|
||||
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
|
||||
# main and nightly, not on every pull request push.
|
||||
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
|
||||
fi
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
|
||||
- name: Robustness goldens (pull requests that change geometry)
|
||||
# Every seed of tests/robustness/goldens.json (each seed is a test), split over the shards;
|
||||
# pushes to main and the nightly run them in the step above.
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.goldens == 'true'
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
run: |
|
||||
if ! cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --partition "slice:$SHARD/$SHARDS" --no-tests=pass \
|
||||
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::/)'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
base='not (package(solidcraft-kernel) & binary(boolean_props))'
|
||||
elif [ "$GOLDENS" = true ]; then
|
||||
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
|
||||
# also checks every seed but the slow ones, in the same run so they share its cores.
|
||||
base="(default() & not (binary(action_matrix) & test(=action_matrix_sample))) | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))"
|
||||
fi
|
||||
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
|
||||
rm -f "$junit"
|
||||
# Pull requests test the affected packages (scripts/ci/affected.py).
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
status=0
|
||||
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
|
||||
--ignore-default-filter -E "$base" || status=1
|
||||
if [ "$status" != 0 ]; then
|
||||
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
|
||||
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
name: junit
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
@@ -390,7 +512,7 @@ jobs:
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 55
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -432,7 +554,7 @@ jobs:
|
||||
name: Action matrix determinism
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -454,7 +576,7 @@ jobs:
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -477,26 +599,31 @@ jobs:
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
if: needs.changes.outputs.doctests == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh doc-tests
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-doc
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -531,25 +658,30 @@ jobs:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh oracle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-oracle
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -577,7 +709,7 @@ jobs:
|
||||
name: Windows D3D12 FXC shaders
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
runs-on: windows-latest
|
||||
runs-on: blacksmith-8vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
WGPU_BACKEND: dx12
|
||||
@@ -605,6 +737,8 @@ jobs:
|
||||
name: Windows app startup
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
# GitHub-hosted: the startup smoke needs an interactive desktop that presents frames; on
|
||||
# Blacksmith's Windows runners the window never presents ("no frame was presented").
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -627,19 +761,57 @@ jobs:
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
# On a run another label starts (every other job skipped), this job reports the result of the
|
||||
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
|
||||
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
|
||||
name: ci
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
# To read the commit's other runs of this workflow.
|
||||
actions: read
|
||||
steps:
|
||||
- name: Report the commit's real result (a run another label started)
|
||||
if: github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
# The other runs of ci.yml for this commit. While one is still going, wait for it (at
|
||||
# most 90 minutes); then pass only when the newest one that ran its checks passed.
|
||||
deadline=$((SECONDS + 5400))
|
||||
while :; do
|
||||
runs=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&per_page=100" \
|
||||
--jq "[.workflow_runs[] | select(.id != $RUN_ID)]")
|
||||
pending=$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs")
|
||||
if [ "$pending" -eq 0 ] || [ "$SECONDS" -ge "$deadline" ]; then break; fi
|
||||
echo "Waiting for $pending run(s) of this commit to finish."
|
||||
sleep 30
|
||||
done
|
||||
# Runs with every job skipped (labels, before this job reported) are not a result.
|
||||
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
|
||||
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
|
||||
echo "The commit's latest run: $latest"
|
||||
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
|
||||
test "${latest%% *}" = success
|
||||
- name: Verify required checks
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci'
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
QUICK: ${{ needs.changes.outputs.quick }}
|
||||
QUICK_COMPILE: ${{ needs.quick-compile.result }}
|
||||
WINDOWS: ${{ needs.changes.outputs.windows }}
|
||||
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
|
||||
DOCTESTS: ${{ needs.changes.outputs.doctests }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
@@ -648,10 +820,19 @@ jobs:
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
# An unapproved pull request: the heavy jobs are skipped on purpose.
|
||||
if [ "$QUICK" = true ]; then
|
||||
test "$QUICK_COMPILE" = success
|
||||
fi
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
# The archive for the action matrix and property tests exists nightly and by hand only.
|
||||
if [ "$EVENT_NAME" = schedule ] || [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
test "$BUILD" = success
|
||||
fi
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
if [ "$DOCTESTS" = true ]; then
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
if [ "$ORACLE_RUN" = true ]; then
|
||||
@@ -661,11 +842,17 @@ jobs:
|
||||
test "$WINDOWS_FXC" = success
|
||||
test "$WINDOWS_STARTUP" = success
|
||||
fi
|
||||
- name: Note a carried result
|
||||
if: needs.changes.outputs.carried != ''
|
||||
env:
|
||||
CARRIED: ${{ needs.changes.outputs.carried }}
|
||||
run: |
|
||||
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -681,7 +868,7 @@ jobs:
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -27,9 +27,7 @@ concurrency:
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
# The bots run main's own scripts (never PR code), so the trusted light runner takes them
|
||||
# and GitHub's shared runners stay free for pull request builds.
|
||||
runs-on: self-hosted-light
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
|
||||
@@ -14,6 +14,11 @@ 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:
|
||||
|
||||
@@ -6,6 +6,18 @@ name: Generated files
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
|
||||
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
|
||||
# read through solidcraft-cli. Other pushes regenerate nothing.
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "**/Cargo.toml"
|
||||
- "vendor/**"
|
||||
- "assets/fonts/**"
|
||||
- "crates/engine/**"
|
||||
- "apps/solidcraft-cli/**"
|
||||
- "xtask/**"
|
||||
- ".github/workflows/generated.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
|
||||
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
|
||||
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
discussion:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/kernel-paused
|
||||
issues: write
|
||||
pull-requests: read # to list a PR's changed paths
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
paused:
|
||||
name: Kernel paused bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/kernel-paused
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const paused = require('./scripts/kernel-paused/kernel-paused.js');
|
||||
const done = context.eventName === 'discussion'
|
||||
? await paused.handleDiscussion({ github, context })
|
||||
: await paused.handleOpened({ github, context });
|
||||
core.info(done);
|
||||
@@ -6,6 +6,8 @@ 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:
|
||||
|
||||
@@ -33,9 +33,7 @@ concurrency:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
# The bots run main's own scripts (never PR code), so the trusted light runner takes them
|
||||
# and GitHub's shared runners stay free for pull request builds.
|
||||
runs-on: self-hosted-light
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
@@ -48,6 +50,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
|
||||
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
@@ -96,12 +100,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
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.
|
||||
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
|
||||
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
|
||||
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
|
||||
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
|
||||
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
|
||||
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
|
||||
check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
@@ -120,22 +125,34 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check 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.
|
||||
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
|
||||
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
|
||||
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
|
||||
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
|
||||
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
|
||||
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
|
||||
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
|
||||
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
|
||||
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
|
||||
make its pending status clear at handoff; do not claim it merged without checking.
|
||||
|
||||
## Before you start: look at what is already open
|
||||
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
|
||||
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
|
||||
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
|
||||
work on the same issue, the same feature under another number, and PRs that change the files
|
||||
you are about to change (`gh pr list ... --json number,title,files`).
|
||||
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
|
||||
PRs for one change can't both land, and one of them is wasted work and CI.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
|
||||
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
|
||||
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
|
||||
the issue as taken. Reference `#N` in the PR so the bot can find it.
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
|
||||
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
|
||||
a claim before you have a branch.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
|
||||
@@ -28,6 +28,13 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/0.png | SolidCraft contributors | original Sample Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/1.png | SolidCraft contributors | original Sheet Metal Bracket from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/2.png | SolidCraft contributors | original Hinge Assembly from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/3.png | SolidCraft contributors | original Plastic Enclosure from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/4.png | SolidCraft contributors | original Bolted Flange from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/5.png | SolidCraft contributors | original Vent Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/scripts.json | SolidCraft contributors | SHA-256 fingerprints of the original sample scripts, generated with the shipped thumbnails | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
@@ -45,8 +52,28 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf | IBM Corp. (IBM Plex Mono) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-mono/OFL.txt; the interface monospace font | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf | IBM Corp. (IBM Plex Sans, variable wdth and wght) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-sans/OFL.txt; the default interface font | SIL OFL 1.1 |
|
||||
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_inch.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_placed.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_seam.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_twice.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/dome.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/dome_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/far_cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/grooved_shaft.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/plate_100_holes.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/sheet_disc.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
@@ -67,9 +94,10 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
|
||||
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
@@ -88,20 +116,30 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/hu.json | SolidCraft contributors | Original Hungarian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/id.json | SolidCraft contributors | Original Indonesian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
|
||||
| assets/locales/nl.json | SolidCraft contributors | Original Dutch translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/sv.json | SolidCraft contributors | Original Swedish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ro.json | SolidCraft contributors | Original Romanian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh-TW.json | SolidCraft contributors | Original Traditional Chinese (Taiwan) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
@@ -126,6 +164,10 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/palette.toml | SolidCraft contributors | icon colour roles (light and dark) and accents, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/chevron_right.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/close.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/disclosure_closed.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/disclosure_open.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/fit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/folder.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/help.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -134,6 +176,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/flat/moon.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/nav_settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/orbit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/plus.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/pan.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/perspective.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/redo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -219,7 +262,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/export.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/extend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/extrude.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/eye.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/eye.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fastener.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/filter.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -269,6 +312,13 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/assistant.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/agent_link.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_two_sides.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_symmetric.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/import_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/export_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -320,7 +370,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/status_error.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/status_warning.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/stitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/suppress.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/suppress.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/svg.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/sweep.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -404,10 +454,12 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/tools/lint_icons.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/preview.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/review_state.py | SolidCraft contributors | icon review state helper (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/requested_icons.txt | SolidCraft contributors | Runtime icon requests collected by the headless menu audit, original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/review.json | SolidCraft contributors | icon review state (accepted icons and notes), original SolidCraft data | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
|
||||
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
@@ -469,7 +521,7 @@ copied into SolidCraft.
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
|
||||
@@ -1,183 +1,3 @@
|
||||
# SolidCraft — instructions for agents
|
||||
@AGENTS.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.
|
||||
|
||||
## 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"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
- **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`.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **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.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
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. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
The agent instructions for this repo live in AGENTS.md; edit that file only.
|
||||
|
||||
@@ -15,6 +15,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
|
||||
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
|
||||
@@ -72,7 +81,7 @@ desktop integration and how to distinguish an old executable on `PATH`.
|
||||
|
||||
## How the code is laid out
|
||||
|
||||
The [README](README.md#architecture) has the layer table. The short version:
|
||||
The [architecture page](docs/architecture.md) has the layer table. The short version:
|
||||
|
||||
- `crates/geom`: vectors, planes, meshes, measures.
|
||||
- `crates/sketch`: our own constraint solver, plus profile finding.
|
||||
@@ -148,6 +157,8 @@ the most complete description of the house style.
|
||||
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.
|
||||
@@ -181,25 +192,27 @@ 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 and bot-script-only PRs can run
|
||||
repository checks only. 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; otherwise it runs the full suite.
|
||||
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 four minutes each), which run in full validation
|
||||
Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation
|
||||
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
|
||||
Check that validation after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that change the kernel, truck or the goldens run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
|
||||
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
@@ -283,6 +296,11 @@ and a fresh CI run:
|
||||
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).
|
||||
|
||||
@@ -307,12 +325,15 @@ Changes with no useful observable behavior do not need screenshot or video evide
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
Keep 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.
|
||||
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
|
||||
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
|
||||
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
|
||||
push a change anyway, or GitHub reports a conflict, rebase onto the latest `main` (push your
|
||||
branch with `--force-with-lease`) or merge `main` in once, and resolve conflicts without losing
|
||||
others' changes. Don't re-run CI on an unchanged commit either: a real failure fails again.
|
||||
Re-run only a job that runner trouble cancelled, with "Re-run failed jobs". While a task remains
|
||||
active, follow an open PR through its status changes; at handoff, clearly identify anything
|
||||
still awaiting merge.
|
||||
|
||||
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
owning one area, with a human setting direction and reporting bugs. Their working notes are in
|
||||
@@ -357,7 +378,21 @@ In order of how much they'd help users:
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
|
||||
## Updating the generated feature list
|
||||
|
||||
After adding or renaming commands, run `cargo xtask features`. The registry is the
|
||||
source of truth for `docs/features.md`; do not hand-edit the generated rows.
|
||||
The generator sorts commands by ID and omits global and per-section counters,
|
||||
so additions to unrelated areas no longer rewrite shared summary lines.
|
||||
|
||||
If two branches still conflict in this file (for example, adjacent additions in
|
||||
the same area), first resolve the command registry and source conflicts, then run
|
||||
`cargo xtask features` again and stage the regenerated file. Do not use Git's
|
||||
`union` merge driver: it can silently retain stale or duplicate command rows.
|
||||
The generator tests cover stable ordering and additions that leave existing lines
|
||||
unchanged. The initial switch to sorted rows is a one-time larger diff.
|
||||
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
|
||||
|
||||
@@ -813,6 +813,17 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chardetng"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -1164,6 +1175,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
@@ -3503,6 +3520,26 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
@@ -3730,6 +3767,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -3849,6 +3911,17 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
@@ -3862,6 +3935,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
@@ -4078,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4109,13 +4193,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
@@ -4124,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4150,11 +4235,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4167,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4176,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4193,12 +4280,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
"cadmpeg-registry",
|
||||
"chardetng",
|
||||
"encoding_rs",
|
||||
"gltf",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
@@ -4213,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4233,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4242,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4256,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4267,11 +4357,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4286,6 +4379,8 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"syn 2.0.119",
|
||||
"tiny-skia",
|
||||
"toml",
|
||||
"ttf-parser",
|
||||
@@ -4296,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5826,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.2"
|
||||
version = "0.18.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,6 +36,9 @@ 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"] }
|
||||
@@ -55,6 +58,11 @@ rayon = "1.12"
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# STEP text in a legacy encoding (GBK, Shift_JIS, …) instead of Part 21 escapes (#2344): detect
|
||||
# and decode it. Both MIT OR Apache-2.0 (encoding_rs adds BSD-3-Clause data); encoding_rs is
|
||||
# already in the tree through cadmpeg-codec-step.
|
||||
chardetng = "0.1"
|
||||
encoding_rs = "0.8"
|
||||
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
|
||||
# read by crates/io itself, so no image decoding or file access comes with it.
|
||||
gltf = { version = "1.4", default-features = false, features = ["names"] }
|
||||
@@ -123,6 +131,9 @@ 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
|
||||
@@ -142,3 +153,9 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
|
||||
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
|
||||
# builds, since every mutant rebuilds the crates above the one it changes.
|
||||
[profile.mutants]
|
||||
inherits = "ci"
|
||||
incremental = true
|
||||
|
||||
@@ -29,5 +29,8 @@ 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, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
describe compatibility goals.
|
||||
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.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<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>
|
||||
@@ -40,36 +41,96 @@
|
||||
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
- **Windows:** download and run the [installer][windows-msi].
|
||||
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
|
||||
[portable zip][windows-portable] that runs without installing.
|
||||
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
|
||||
`winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
|
||||
(Apple silicon and Intel).
|
||||
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
|
||||
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
|
||||
openSUSE), or use [Flatpak](https://docs.solidcraft.io/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].
|
||||
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
|
||||
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
|
||||
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**.
|
||||
ARM64 and 32-bit builds, portable zip, .deb, .rpm, Flatpak and the other packages are on the
|
||||
[downloads page](docs/downloads.md). The downloads aren't signed yet: on Windows choose
|
||||
**More info**, then **Run anyway** in SmartScreen; on macOS right-click SolidCraft and choose **Open**
|
||||
the first time. On SolidCraft 0.11 to 0.14, download once by hand; see
|
||||
[updating](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014).
|
||||
|
||||
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
|
||||
releases moved to a new address. Download the new version once from the links above; after that,
|
||||
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
|
||||
## What works
|
||||
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
Install details, upgrading from earlier builds and building from source are in
|
||||
[Getting started](https://docs.solidcraft.io/guide/getting-started.html).
|
||||
Early but usable for simple parts: 75 of 76 public reference parts match their analytic or
|
||||
FreeCAD-computed volume, area, centre of mass and bounding box ([oracle](docs/oracle.md)); the
|
||||
other is a tracked bug.
|
||||
|
||||
- **Sketches and constraints:** lines, arcs, splines, text and more, solved by our own constraint solver, with parameter-driven dimensions.
|
||||
- **Features:** extrude, revolve, sweep, loft, fillet, chamfer, shell, draft, holes, threads, patterns, mirror, split and combine on an editable parametric timeline.
|
||||
- **Parameters and configurations** with unit-aware expressions.
|
||||
- **Assemblies** with joints, motion studies, contact sets, interference and mass properties.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold and flat patterns, and plastic enclosure features.
|
||||
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.solidcraft` designs.
|
||||
- **Desktop app, browser app, headless CLI and MCP server**, all running the same commands.
|
||||
|
||||
The full list is in [docs/capabilities.md](docs/capabilities.md) and [docs/features.md](docs/features.md);
|
||||
status and plans are in [ROADMAP.md](ROADMAP.md). See also [what people built](docs/showcase.md),
|
||||
[3D mouse support](docs/3d-mouse.md) and the [architecture](docs/architecture.md).
|
||||
|
||||
## Build from source
|
||||
|
||||
```sh
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
|
||||
```
|
||||
|
||||
More commands, the Windows cross-build and the browser build are in [docs/building.md](docs/building.md).
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
|
||||
runs its own design session, or with `--connect` it drives the running app so you can watch.
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
|
||||
example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
A script is a JSON list of the same commands the UI runs, executed with `solidcraft-cli run`.
|
||||
Format and example: [docs/scripts.md](docs/scripts.md). Pages can also be driven through the
|
||||
[browser command API](docs/browser-api.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Help is welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers how to build and test, the code map,
|
||||
the clean-room and licence rules, and where help is wanted most. Open work is in
|
||||
[GitHub issues](https://github.com/tangency-labs/solidcraft/issues); chat is on
|
||||
[Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Sponsors
|
||||
|
||||
[](https://www.blacksmith.sh)
|
||||
|
||||
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
|
||||
sponsors its CI minutes.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0, at your option. Developed under the
|
||||
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk, Dassault Systèmes, PTC,
|
||||
Siemens or any other CAD vendor. Fusion, AutoCAD, Inventor, SolidWorks, Onshape, Creo, Blender and
|
||||
Tinkercad are trademarks of their respective owners, named only to describe compatibility
|
||||
(see [NOTICE](NOTICE)).
|
||||
|
||||
[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
|
||||
@@ -77,254 +138,7 @@ Install details, upgrading from earlier builds and building from source are in
|
||||
[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
|
||||
[windows-arm64-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
|
||||
[windows-x86-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
|
||||
[windows-portable]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
|
||||
[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
|
||||
[linux-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
|
||||
[linux-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
|
||||
[linux-arm64-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
|
||||
[linux-arm64-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
|
||||
[linux-arm64-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
|
||||
[linux-snap]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://solidcraft.io/app
|
||||
|
||||
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
**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/tangency-labs/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/tangency-labs/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
|
||||
|
||||
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
|
||||
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
- **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, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
|
||||
appearances) import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
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)); FreeCAD
|
||||
`.FCStd` and Rhino `.3dm` solids import the same way through
|
||||
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
|
||||
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
|
||||
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
|
||||
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
|
||||
See [opaque surfaces](docs/render-opaque-pbr.md).
|
||||
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
|
||||
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
|
||||
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
|
||||
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
|
||||
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
|
||||
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
|
||||
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
|
||||
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
|
||||
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
|
||||
faster than the previous mesher.
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
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-cli -- render examples/render-studio.json --out product.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
|
||||
```
|
||||
|
||||
UI artwork changes follow the [icon specification](assets/icons/ui/STYLE_GUIDE.md).
|
||||
The [included review tool](assets/icons/ui/README.md) discovers new icons and keeps
|
||||
unaccepted artwork Open; run `cargo xtask icons` to check the collection.
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
|
||||
declares itself one. 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, a speed control and, for each of the cap's six axes, an
|
||||
invert switch and a speed for the 3D mouse. The web app reads
|
||||
it too, through the browser; Firefox needs a 3DxWare setting first (see
|
||||
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
|
||||
|
||||
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
|
||||
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
|
||||
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
|
||||
|
||||
On Linux SolidCraft reads the 3D mouse through `spacenavd` when that daemon runs (its socket
|
||||
`/var/run/spnav.sock`), so what you set in `spnavcfg` (swapped or inverted axes, sensitivity, dead
|
||||
zones) applies as it does in other programs, and no udev rule is needed. With nothing set there,
|
||||
the motion is the same as without the daemon. Without `spacenavd`, 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 USB devices with 3Dconnexion's vendor id, and for its
|
||||
Bluetooth ones (where `idVendor` isn't set, so the HID id is matched instead):
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
SUBSYSTEM=="hidraw", KERNELS=="0005:256F:*", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see
|
||||
`lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own
|
||||
vendor and product id. 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.
|
||||
|
||||
SolidCraft finds the axes from the device's report descriptor, so it doesn't matter how a device
|
||||
lays them out: translation and rotation in separate reports (3Dconnexion's wired devices), all six
|
||||
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
|
||||
Each axis is scaled from the range the descriptor declares.
|
||||
|
||||
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
|
||||
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
|
||||
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
|
||||
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
|
||||
|
||||
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
Agents can control the document open in a browser tab through `window.solidcraft.execute(...)`
|
||||
and `window.solidcraft.request(...)`; see the [browser command API](docs/browser-api.md).
|
||||
|
||||
## 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`, `crash_reports`, `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 the headless scene description |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
<!-- 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.
|
||||
|
||||
@@ -14,6 +14,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| 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
|
||||
@@ -46,16 +50,16 @@ 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, 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, 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 and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; 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, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), 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), **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. 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), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -85,6 +89,9 @@ 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
|
||||
@@ -94,6 +101,8 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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
|
||||
|
||||
@@ -220,6 +229,9 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
|
||||
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
|
||||
it), drives it like any other dimension and keeps the circle on its side of the line.
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
@@ -338,13 +350,16 @@ 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 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.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
multi-face ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
@@ -453,10 +468,13 @@ 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 and spheres (convex or not:
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled.
|
||||
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
|
||||
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
|
||||
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
|
||||
between sections, and sections with holes cannot collapse to a point.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 552 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -75,6 +75,7 @@ cbc 0.1.2 (MIT OR Apache-2.0)
|
||||
cfg-if 1.0.5 (MIT OR Apache-2.0)
|
||||
cgl 0.3.2 (MIT / Apache-2.0)
|
||||
cgmath 0.18.0 (Apache-2.0)
|
||||
chardetng 0.1.17 (Apache-2.0 OR MIT)
|
||||
chrono 0.4.45 (MIT OR Apache-2.0)
|
||||
cipher 0.4.4 (MIT OR Apache-2.0)
|
||||
clipboard-win 5.4.1 (BSL-1.0)
|
||||
@@ -113,6 +114,7 @@ dlib 0.5.3 (MIT)
|
||||
document-features 0.2.12 (MIT OR Apache-2.0)
|
||||
downcast-rs 1.2.1 (MIT/Apache-2.0)
|
||||
dpi 0.1.2 (Apache-2.0 AND MIT)
|
||||
dyn-clone 1.0.20 (MIT OR Apache-2.0)
|
||||
ecolor 0.36.2 (MIT OR Apache-2.0)
|
||||
eframe 0.36.2 (MIT OR Apache-2.0)
|
||||
egui 0.36.2 (MIT OR Apache-2.0)
|
||||
@@ -331,6 +333,8 @@ read-fonts 0.41.0 (MIT OR Apache-2.0)
|
||||
redox_syscall 0.4.1 (MIT)
|
||||
redox_syscall 0.5.18 (MIT)
|
||||
redox_syscall 0.9.4 (MIT)
|
||||
ref-cast 1.0.27 (MIT OR Apache-2.0)
|
||||
ref-cast-impl 1.0.27 (MIT OR Apache-2.0)
|
||||
renderdoc-sys 1.1.0 (MIT OR Apache-2.0)
|
||||
rfd 0.17.2 (MIT)
|
||||
ring 0.17.14 (Apache-2.0 AND ISC)
|
||||
@@ -349,6 +353,8 @@ rustls-platform-verifier-android 0.2.0 (MIT OR Apache-2.0)
|
||||
rustls-webpki 0.103.15 (ISC)
|
||||
rustversion 1.0.23 (MIT OR Apache-2.0)
|
||||
schannel 0.1.29 (MIT)
|
||||
schemars 1.2.3 (MIT)
|
||||
schemars_derive 1.2.3 (MIT)
|
||||
scoped-tls 1.0.1 (MIT/Apache-2.0)
|
||||
scopeguard 1.2.0 (MIT OR Apache-2.0)
|
||||
secret-service 4.0.0 (MIT OR Apache-2.0)
|
||||
@@ -361,7 +367,9 @@ serde 1.0.229 (MIT OR Apache-2.0)
|
||||
serde-value 0.7.0 (MIT)
|
||||
serde_core 1.0.229 (MIT OR Apache-2.0)
|
||||
serde_derive 1.0.229 (MIT OR Apache-2.0)
|
||||
serde_derive_internals 0.30.0 (MIT OR Apache-2.0)
|
||||
serde_json 1.0.151 (MIT OR Apache-2.0)
|
||||
serde_path_to_error 0.1.20 (MIT OR Apache-2.0)
|
||||
serde_repr 0.1.21 (MIT OR Apache-2.0)
|
||||
serde_spanned 1.1.2 (MIT OR Apache-2.0)
|
||||
sha1 0.10.7 (MIT OR Apache-2.0)
|
||||
@@ -578,19 +586,21 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-fs 2.2.0,
|
||||
async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14,
|
||||
async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0,
|
||||
displaydoc 0.2.7, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4,
|
||||
fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0,
|
||||
lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive
|
||||
0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13,
|
||||
pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic
|
||||
1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47,
|
||||
rustc-hash 1.1.0, rustc-hash 2.1.3, rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28,
|
||||
serde 1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21,
|
||||
simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror
|
||||
2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3, unicode-ident
|
||||
1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen
|
||||
0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zbus_names
|
||||
3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils 4.2.0
|
||||
displaydoc 0.2.7, dyn-clone 1.0.20, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2,
|
||||
event-listener-strategy 0.5.4, fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3,
|
||||
itoa 1.0.18, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15,
|
||||
num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
|
||||
pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling
|
||||
3.11.0, portable-atomic 1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2
|
||||
1.0.107, quote 1.0.47, ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 1.1.0, rustc-hash 2.1.3,
|
||||
rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229,
|
||||
serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_path_to_error 0.1.20,
|
||||
serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror
|
||||
1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid
|
||||
1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2
|
||||
1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2,
|
||||
zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils
|
||||
4.2.0
|
||||
================================================================================
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
@@ -1323,12 +1333,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
allocator-api2 0.2.21, async-trait 0.1.92, enumn 0.1.14, glifo 0.2.0, itoa 1.0.18, libc 0.2.190,
|
||||
litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47, rustc-hash
|
||||
2.1.3, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229, serde_derive 1.0.229,
|
||||
serde_json 1.0.151, serde_repr 0.1.21, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21,
|
||||
thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3, unicode-ident 1.0.26,
|
||||
utf8-zero 0.8.1
|
||||
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.2.0, itoa 1.0.18,
|
||||
libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47,
|
||||
ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 2.1.3, rustversion 1.0.23, semver 1.0.28, serde
|
||||
1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json
|
||||
1.0.151, serde_path_to_error 0.1.20, serde_repr 0.1.21, syn 2.0.119, syn 3.0.6, thiserror 1.0.69,
|
||||
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3,
|
||||
unicode-ident 1.0.26, utf8-zero 0.8.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -1821,8 +1832,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.13.0,
|
||||
cgmath 0.18.0, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op
|
||||
1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
|
||||
cgmath 0.18.0, chardetng 0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1,
|
||||
multiversion_no_op 1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
|
||||
rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions
|
||||
1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize
|
||||
1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
|
||||
@@ -3324,6 +3335,50 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
||||
option. All files in the project carrying such notice may not be
|
||||
copied, modified, or distributed except according to those terms.
|
||||
|
||||
================================================================================
|
||||
chardetng 0.1.17
|
||||
================================================================================
|
||||
|
||||
chardetng is copyright Mozilla Foundation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0
|
||||
<LICENSE-APACHE or
|
||||
https://www.apache.org/licenses/LICENSE-2.0> or the MIT
|
||||
license <LICENSE-MIT or https://opensource.org/licenses/MIT>,
|
||||
at your option. All files in the project carrying such
|
||||
notice may not be copied, modified, or distributed except
|
||||
according to those terms.
|
||||
|
||||
================================================================================
|
||||
chardetng 0.1.17, encoding_rs 0.8.42, multiversion_no_op 1.0.0, utf8_iter 1.0.4
|
||||
================================================================================
|
||||
|
||||
Copyright Mozilla Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
chrono 0.4.45
|
||||
================================================================================
|
||||
@@ -5010,36 +5065,6 @@ source code, are licensed under the BSD 3-Clause License
|
||||
Test code within encoding_rs is dedicated to the Public Domain when so
|
||||
designated (see the individual files for PD/CC0-dedicated sections).
|
||||
|
||||
================================================================================
|
||||
encoding_rs 0.8.42, multiversion_no_op 1.0.0, utf8_iter 1.0.4
|
||||
================================================================================
|
||||
|
||||
Copyright Mozilla Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
enumflags2 0.7.12
|
||||
================================================================================
|
||||
@@ -11768,6 +11793,32 @@ The above copyright notice and this permission notice shall be included in all c
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
schemars 1.2.3, schemars_derive 1.2.3
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Graham Esau
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
scopeguard 1.2.0
|
||||
================================================================================
|
||||
@@ -16093,6 +16144,14 @@ Bundled fonts (SIL Open Font License, Version 1.1)
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
|
||||
|
||||
- IBMPlexSans-Variable.ttf (assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf)
|
||||
Copyright 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
Source: https://github.com/IBM/plex
|
||||
|
||||
- IBMPlexMono-Regular.ttf (assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf)
|
||||
Copyright 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
Source: https://github.com/IBM/plex
|
||||
|
||||
================================================================================
|
||||
SIL OPEN FONT LICENSE Version 1.1
|
||||
================================================================================
|
||||
|
||||
@@ -23,6 +23,7 @@ path = "src/alias.rs"
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,21 @@
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
|
||||
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//!
|
||||
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -22,7 +26,9 @@
|
||||
mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
mod render;
|
||||
mod spatial;
|
||||
@@ -47,14 +53,23 @@ const USAGE: &str = "usage:
|
||||
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
|
||||
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
|
||||
(the design's history as a command script that eval and run replay)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]
|
||||
(--ops: after opening, run each command of the script and undo it, timing the command, the
|
||||
evaluations it caused and the view it leaves: placing and meshing what changed)
|
||||
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
|
||||
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
|
||||
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
|
||||
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
|
||||
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
|
||||
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
|
||||
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;
|
||||
--body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside
|
||||
it shows) and color #rrggbb, either or both, once per body;
|
||||
--tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body
|
||||
is visible inside the material it meets; a later --body overrides it;
|
||||
--section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world
|
||||
coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)
|
||||
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
@@ -62,6 +77,7 @@ const USAGE: &str = "usage:
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
[--param-check off|warn|strict]
|
||||
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
|
||||
@@ -69,10 +85,13 @@ const USAGE: &str = "usage:
|
||||
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
|
||||
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
|
||||
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
logger::init(&mut args);
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => cmd_run(&args[1..]),
|
||||
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
||||
@@ -145,7 +164,14 @@ pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
||||
pub fn load(path: &str) -> Result<Session, String> {
|
||||
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
|
||||
}
|
||||
|
||||
/// `load`, with scripts and recipes run at the parameter-check level `check`, which the
|
||||
/// session keeps.
|
||||
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
s.param_check = check;
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
@@ -155,16 +181,22 @@ pub fn load(path: &str) -> Result<Session, String> {
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::new(doc);
|
||||
session.path = Some(path.to_owned());
|
||||
session.param_check = check;
|
||||
return Ok(session);
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
||||
let script = recipe::to_script(&v)?;
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
|
||||
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
|
||||
// (stderr, so stdout stays the caller's).
|
||||
let mut warn = |step: usize, r: &Value| {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
};
|
||||
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -405,7 +437,12 @@ fn install_renderer() {
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
|
||||
known_flags(
|
||||
"snapshot",
|
||||
args,
|
||||
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
|
||||
&["--no-grid", "--no-axes"],
|
||||
)?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
@@ -416,6 +453,9 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
};
|
||||
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
// How the bodies are drawn and where the model is cut, through the commands the app's
|
||||
// Appearance dialog and Section Analysis run (`preview`).
|
||||
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
|
||||
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
|
||||
// standard view, a view saved in the design, or an explicit camera. A script that applies a
|
||||
// view itself sets the camera too; without any, the iso view framing the model.
|
||||
@@ -519,6 +559,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
|
||||
let param_check = match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
|
||||
None => solidcraft_engine::schema::ParamCheck::Off,
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
|
||||
}
|
||||
};
|
||||
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
|
||||
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
|
||||
}
|
||||
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => {
|
||||
@@ -535,8 +586,12 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load_checked(path, param_check)?)),
|
||||
(None, None) => {
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
Box::new(headless)
|
||||
}
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
@@ -595,6 +650,36 @@ 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.solidcraft | part.step> [--parse-only]")?;
|
||||
@@ -607,37 +692,83 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let t = std::time::Instant::now();
|
||||
// The file as read, held until the timeline has evaluated it (as the import command does).
|
||||
let mut read = None;
|
||||
let doc = if step {
|
||||
let (doc, saved) = if step {
|
||||
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
read = Some(imported.import);
|
||||
doc
|
||||
(doc, None)
|
||||
} else {
|
||||
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
// As Open reads it: with the model the file keeps.
|
||||
solidcraft_engine::io::read_design_file(path, None::<fn(solidcraft_engine::io::DesignPreview)>).map_err(|e| e.to_string())?
|
||||
};
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len(), "saved_model": saved.is_some()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::new(doc);
|
||||
let s = Session::opened(doc, saved.as_ref());
|
||||
drop(read);
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway).
|
||||
let adopted = saved.is_some() && s.model.last_recomputed == 0;
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
let place_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
// As the desktop app prepares a design it opened.
|
||||
st.warm_meshes(|| true);
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "place_ms": place_ms, "bodies": st.bodies.len(), "triangles": triangles})
|
||||
);
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
// `--save PATH`: save the opened design without its model cache (to PATH.plain.solidcraft) and
|
||||
// as the app does, with it, then read the cache back.
|
||||
let s = match flag("--save").and_then(|i| args.get(i + 1)) {
|
||||
Some(out) => {
|
||||
let mut s = s;
|
||||
// Without the cache first, for comparison.
|
||||
let plain = format!("{out}.plain.solidcraft");
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": plain, "cache": false})).map_err(|e| e.to_string())?;
|
||||
let plain_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let plain_bytes = std::fs::metadata(&plain).map(|m| m.len()).unwrap_or(0);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
let save_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let bytes = std::fs::read(out).map_err(|e| format!("{out}: {e}"))?;
|
||||
let t = std::time::Instant::now();
|
||||
let cached = solidcraft_engine::io::read_design_geometry(&bytes).map(|m| m.bodies.len());
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
// Why bodies could not be kept exactly, when the cache was left out.
|
||||
let mut refused = std::collections::BTreeMap::<String, usize>::new();
|
||||
if cached.is_none() {
|
||||
for b in &s.model.state().bodies {
|
||||
if let Err(e) = solidcraft_engine::kernel::encode_native_body(&b.body) {
|
||||
*refused.entry(e.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "save", "ms": save_ms, "bytes": bytes.len(), "plain_ms": plain_ms, "plain_bytes": plain_bytes, "cached_bodies": cached, "cache_read_ms": read_ms, "refused": refused})
|
||||
);
|
||||
s
|
||||
}
|
||||
None => s,
|
||||
};
|
||||
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
|
||||
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
|
||||
// evaluation and the plans of the bodies it changed.
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
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| {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,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()
|
||||
@@ -1665,7 +1665,6 @@ 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.]));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -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 Fusion's do); M0 turned them the other way",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
|
||||
),
|
||||
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `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();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! A design opened from the model its file keeps is the design evaluated again: the same
|
||||
//! results, the same bodies (to the bit), the same face names and volumes after every feature,
|
||||
//! and the same after an edit made to both.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::{ModelBody, ModelState};
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("solidcraft-model-cache-{}-{name}.solidcraft", std::process::id()))
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
fn body(name: &str, b: &ModelBody) -> Value {
|
||||
let measured = solidcraft_engine::kernel::measure(&b.body).ok();
|
||||
json!({
|
||||
"name": b.name, "feature": b.feature, "component": b.component, "source": b.source_name, "transferred": b.transferred,
|
||||
"brep": solidcraft_engine::kernel::encode_native_body(&b.body).map(|bytes| format!("{:x}", sha(&bytes))).unwrap_or_else(|e| format!("{name}: {e}")),
|
||||
"faces": *solidcraft_engine::doc::naming::face_names(b),
|
||||
"volume": measured.as_ref().map(|m| m.volume.to_bits()), "area": measured.as_ref().map(|m| m.area.to_bits()),
|
||||
"look": b.imported.as_ref().map(|l| format!("{l:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn sha(bytes: &[u8]) -> u128 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
bytes.hash(&mut h);
|
||||
let a = h.finish();
|
||||
bytes.len().hash(&mut h);
|
||||
(u128::from(a) << 64) | u128::from(h.finish())
|
||||
}
|
||||
|
||||
fn state(name: &str, st: &Arc<ModelState>) -> Value {
|
||||
json!({
|
||||
"bodies": st.bodies.iter().map(|b| body(name, b)).collect::<Vec<_>>(),
|
||||
"sketches": st.sketches.iter().map(|s| json!([s.feature, s.name, s.plane, s.sketch, s.report, s.profiles.len()])).collect::<Vec<_>>(),
|
||||
"counter": st.body_counter, "sheets": st.sheets, "threads": st.threads, "construct": format!("{:?}", st.construct),
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything a model holds that the rest of the app reads.
|
||||
fn model(name: &str, s: &Session) -> Value {
|
||||
let results: Vec<Value> = s
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "name": r.name, "error": r.error, "warning": r.warning, "skipped": r.skipped, "state": state(name, &r.state)}))
|
||||
.collect();
|
||||
json!({"results": results, "world": state(name, &s.world_state())})
|
||||
}
|
||||
|
||||
/// The same edit on both: a parameter's value when the design has one, else a new body.
|
||||
fn edit(s: &mut Session) {
|
||||
let param = s.doc.params.first().map(|p| (p.name.clone(), p.expr.clone()));
|
||||
match param {
|
||||
Some((name, expr)) => {
|
||||
s.execute("parameters.change", &json!({"name": name, "expression": format!("({expr}) * 1.05")})).unwrap();
|
||||
}
|
||||
None => {
|
||||
s.execute("solid.box", &json!({"length": 7, "width": 8, "height": 9, "operation": "new"})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(name: &str, mut built: Session) {
|
||||
let path = temp(name);
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the save keeps the model");
|
||||
let mut cached = open(&path);
|
||||
assert_eq!(cached.model.last_recomputed, 0, "{name}: opened without evaluating");
|
||||
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
|
||||
assert!(rebuilt.model.last_recomputed > 0);
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
|
||||
edit(&mut cached);
|
||||
edit(&mut rebuilt);
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after the same edit");
|
||||
cached.execute("edit.undo", &json!({})).unwrap();
|
||||
rebuilt.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after undoing it");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_open_from_their_model_as_evaluated() {
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = open(&p);
|
||||
// Names saved before format 14 are found again through names worked out from geometry on
|
||||
// demand, which a saved model does not keep.
|
||||
let legacy = s.doc.to_json().contains(&format!("\"{}", solidcraft_engine::doc::naming::LEGACY_MARK));
|
||||
if s.model.results.iter().any(|r| r.error.is_some()) || legacy {
|
||||
// Not kept: a model with errors, or names saved before format 14.
|
||||
let path = temp(&name);
|
||||
let mut s = s;
|
||||
s.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&std::fs::read(&path).unwrap()).is_none(), "{name}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
continue;
|
||||
}
|
||||
check(&name, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_designs_open_from_their_model_as_evaluated() {
|
||||
for sample in solidcraft_engine::sample::samples() {
|
||||
let mut s = Session::default();
|
||||
for c in (sample.script)()["commands"].as_array().unwrap() {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap_or_else(|e| panic!("{}: {e}", sample.name));
|
||||
}
|
||||
check(sample.name, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_assemblies_open_from_their_model_as_evaluated() {
|
||||
// A STEP assembly of the hinge sample: parts placed several times.
|
||||
let mut source = Session::default();
|
||||
let hinge = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Hinge Assembly").unwrap();
|
||||
for c in (hinge.script)()["commands"].as_array().unwrap() {
|
||||
source.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let step = std::env::temp_dir().join(format!("solidcraft-model-cache-{}-hinge.step", std::process::id()));
|
||||
source.execute("file.export", &json!({"path": step.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("file.insert_step", &json!({"path": step.to_string_lossy()})).unwrap();
|
||||
s.execute("file.insert_step", &json!({"path": step.to_string_lossy(), "matrix": [1, 0, 0, 200, 0, 1, 0, 0, 0, 0, 1, 0]})).unwrap();
|
||||
let _ = std::fs::remove_file(&step);
|
||||
check("imported hinge", s);
|
||||
}
|
||||
|
||||
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
|
||||
/// saved states before it are kept as they were read (the same states, not evaluated again).
|
||||
#[test]
|
||||
fn an_edit_after_opening_evaluates_from_the_feature_it_changes() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let path = temp("incremental");
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let n = built.doc.features.len();
|
||||
assert!(n >= 4);
|
||||
let mut s = open(&path);
|
||||
assert_eq!(s.model.last_recomputed, 0);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
// How many of the first features still have the states read from the file.
|
||||
let kept = |s: &Session| s.model.results.iter().zip(&read).take_while(|(r, st)| Arc::ptr_eq(&r.state, st)).count();
|
||||
// The marker moved back and forth: the features before it are as read.
|
||||
s.execute("timeline.rollback", &json!({"position": n - 2})).unwrap();
|
||||
assert_eq!(kept(&s), n - 2, "rolled back");
|
||||
s.execute("timeline.rollback", &json!({})).unwrap();
|
||||
assert_eq!(kept(&s), n, "rolled forward: the features after the marker take their saved results again");
|
||||
// Suppressing the second-to-last feature keeps every state before it.
|
||||
let k = s.doc.features[n - 2].id;
|
||||
s.execute("timeline.suppress", &json!({"feature": k, "suppressed": true})).unwrap();
|
||||
assert_eq!(kept(&s), n - 2, "evaluated from the suppressed feature on");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A new feature at the end starts from the final state read from the file.
|
||||
let mut s = open(&path);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
|
||||
assert_eq!(s.model.results.len(), n + 1);
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "only the new feature evaluated");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
/// An edit made after opening and undone finds the saved results again: undo evaluates nothing.
|
||||
#[test]
|
||||
fn undoing_an_edit_after_opening_reuses_the_saved_results() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let path = temp("undo");
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut s = open(&path);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
let first = s.doc.features[1].id;
|
||||
s.execute("timeline.suppress", &json!({"feature": first, "suppressed": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.last_recomputed, 0, "taken from the saved results");
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "the very states read from the file");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 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"));
|
||||
}
|
||||
@@ -84,3 +84,42 @@ fn corpus_designs_replay_from_their_scripts() {
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! `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}");
|
||||
}
|
||||
}
|
||||
@@ -29,5 +29,6 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance",
|
||||
] }
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
//! Designs on the computer's own disk, through the File System Access API (Chrome, Edge, Opera):
|
||||
//! Save As and Open show the browser's file dialogs, and the chosen file's handle is kept (in
|
||||
//! memory, and in IndexedDB so Save still reaches the file after a reload) so Save writes back to
|
||||
//! it. To the engine such a design is the file `/disk/<id>/<name>`: it saves there like anywhere,
|
||||
//! and [`flush`](crate::files::flush) forwards what it wrote to the handle. Browsers without the
|
||||
//! API (Firefox, Safari) keep designs in the origin private file system (`files.rs`).
|
||||
//!
|
||||
//! The path logic is plain Rust and tested natively; the browser calls are wasm-only.
|
||||
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
|
||||
/// Where a design on the computer's disk appears to the engine.
|
||||
pub const PREFIX: &str = "/disk/";
|
||||
|
||||
/// Where designs kept in the browser's private storage (OPFS) appear to the engine.
|
||||
pub const OPFS_PREFIX: &str = "/opfs/";
|
||||
/// Paths written here are handed to the user as downloads.
|
||||
pub const DOWNLOADS_PREFIX: &str = "/downloads/";
|
||||
|
||||
/// Where what a command wrote to an engine path goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WriteTo {
|
||||
/// The browser's private storage: the design (or recovery file) keeps its name there.
|
||||
Opfs,
|
||||
/// A file on the computer, through its handle.
|
||||
Disk,
|
||||
/// A download.
|
||||
Download,
|
||||
/// Kept only in memory (uploads).
|
||||
Memory,
|
||||
}
|
||||
|
||||
/// Route a write by its path. OPFS paths always go to OPFS, whatever the browser can do: a design
|
||||
/// opened from the browser's storage keeps saving there until the user picks Save As.
|
||||
pub fn route(path: &str) -> WriteTo {
|
||||
if path.starts_with(OPFS_PREFIX) {
|
||||
WriteTo::Opfs
|
||||
} else if parse(path).is_some() {
|
||||
WriteTo::Disk
|
||||
} else if path.starts_with(DOWNLOADS_PREFIX) {
|
||||
WriteTo::Download
|
||||
} else {
|
||||
WriteTo::Memory
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine path of the design `name` kept under handle `id`.
|
||||
pub fn path(id: &str, name: &str) -> String {
|
||||
format!("{PREFIX}{id}/{name}")
|
||||
}
|
||||
|
||||
/// The handle id and file name of an engine path made by [`path`].
|
||||
pub fn parse(path: &str) -> Option<(&str, &str)> {
|
||||
let (id, name) = path.strip_prefix(PREFIX)?.split_once('/')?;
|
||||
(!id.is_empty() && !name.is_empty() && !name.contains('/')).then_some((id, name))
|
||||
}
|
||||
|
||||
/// A handle id from the time and a random number.
|
||||
pub fn make_id(ms: u64, random: u64) -> String {
|
||||
format!("{ms:x}-{random:x}")
|
||||
}
|
||||
|
||||
/// How long a remembered file may go unused, and unreferenced by the recent designs and the
|
||||
/// autosave, before the browser's copy of its handle is dropped.
|
||||
pub const HANDLE_MAX_AGE_MS: f64 = 90.0 * 24.0 * 3600.0 * 1000.0;
|
||||
|
||||
/// What to do with the remembered handles.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct PrunePlan {
|
||||
/// Handles to forget.
|
||||
pub delete: Vec<String>,
|
||||
/// Handles seen unused for the first time: start their clock.
|
||||
pub stamp: Vec<String>,
|
||||
}
|
||||
|
||||
/// Decide which of the remembered handles `ids` (id, when it was last stamped) to forget: those
|
||||
/// that no recent design or autosave (`keep`) names and that have gone unused for `max_age`. One
|
||||
/// never stamped starts its clock now, so a file another tab has just saved is never dropped.
|
||||
pub fn plan_prune(ids: &[(String, Option<f64>)], keep: &std::collections::HashSet<String>, now: f64, max_age: f64) -> PrunePlan {
|
||||
let mut plan = PrunePlan::default();
|
||||
for (id, stamped) in ids.iter().filter(|(id, _)| !keep.contains(id)) {
|
||||
match stamped {
|
||||
None => plan.stamp.push(id.clone()),
|
||||
Some(at) if now - at > max_age => plan.delete.push(id.clone()),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// Where a design saved with Save As goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SaveTarget {
|
||||
/// A file the user picks on their computer.
|
||||
Disk,
|
||||
/// The browser's private storage (OPFS): what browsers without the API have always done.
|
||||
Browser,
|
||||
/// Not a design (an export): handed over as a download.
|
||||
Download,
|
||||
}
|
||||
|
||||
/// Choose where a Save As goes: designs to the disk when the browser can pick a file, else into
|
||||
/// the browser's storage; anything else is a download.
|
||||
pub fn save_target(api: bool, design: bool) -> SaveTarget {
|
||||
match (design, api) {
|
||||
(false, _) => SaveTarget::Download,
|
||||
(true, true) => SaveTarget::Disk,
|
||||
(true, false) => SaveTarget::Browser,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a picker for `extensions` (what the command reads; `&[]`: anything) belong to a design?
|
||||
/// Only those go through the disk dialog; inserting a STEP or mesh keeps using the file window.
|
||||
pub fn opens_design(extensions: &[&str]) -> bool {
|
||||
extensions.is_empty() || extensions.iter().any(|e| solidcraft_engine::io::DESIGN_EXTS.contains(e))
|
||||
}
|
||||
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
|
||||
pub fn save_type(name: &str) -> (&'static str, &'static str, &'static str) {
|
||||
let json = std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
|
||||
if json { ("Plain JSON design", "application/json", ".json") } else { ("SolidCraft design", "application/octet-stream", ".solidcraft") }
|
||||
}
|
||||
|
||||
/// Is this a cancelled dialog (the browser's `AbortError`)? Not an error to show.
|
||||
pub fn is_abort(error_name: &str) -> bool {
|
||||
error_name == "AbortError"
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Promise, Reflect};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
|
||||
|
||||
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
|
||||
use crate::files::{Files, err, read_handle};
|
||||
|
||||
const DB: &str = "solidcraft-disk-handles";
|
||||
const STORE: &str = "handles";
|
||||
|
||||
thread_local! {
|
||||
/// Handles picked or looked up in this session, by id.
|
||||
static HANDLES: RefCell<HashMap<String, FileSystemFileHandle>> = RefCell::new(HashMap::new());
|
||||
/// Writes waiting for their turn (a file takes one writer at a time), latest per id.
|
||||
static QUEUE: RefCell<VecDeque<(String, Vec<u8>)>> = const { RefCell::new(VecDeque::new()) };
|
||||
static BUSY: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Why a dialog gave no file.
|
||||
enum Pick {
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
fn failed(e: JsValue) -> Pick {
|
||||
let name = Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).unwrap_or_default();
|
||||
if is_abort(&name) { Pick::Cancelled } else { Pick::Failed(err(e)) }
|
||||
}
|
||||
|
||||
/// Can this page show the browser's file dialogs and write to the files they pick?
|
||||
pub fn supported() -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let has = |name: &str| Reflect::get(&window, &name.into()).is_ok_and(|f| f.is_function());
|
||||
window.is_secure_context() && has("showSaveFilePicker") && has("showOpenFilePicker")
|
||||
}
|
||||
|
||||
fn types(description: &str, mime: &str, extension: &str) -> Array {
|
||||
let accept = Object::new();
|
||||
let _ = Reflect::set(&accept, &mime.into(), &Array::of1(&extension.into()));
|
||||
let entry = Object::new();
|
||||
let _ = Reflect::set(&entry, &"description".into(), &description.into());
|
||||
let _ = Reflect::set(&entry, &"accept".into(), &accept);
|
||||
Array::of1(&entry)
|
||||
}
|
||||
|
||||
/// Start a dialog. This must run in the click that asked for it (the browser wants a user
|
||||
/// gesture), so it is called before anything is awaited.
|
||||
fn start_picker(function: &str, options: &Object) -> Result<Promise, Pick> {
|
||||
let window = web_sys::window().ok_or_else(|| Pick::Failed("no window".into()))?;
|
||||
let f: Function = Reflect::get(&window, &function.into())
|
||||
.map_err(failed)?
|
||||
.dyn_into()
|
||||
.map_err(|_| Pick::Failed("this browser can't open file dialogs".into()))?;
|
||||
f.call1(&window, options).map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("no answer from the file dialog".into()))
|
||||
}
|
||||
|
||||
/// Remember a picked file: in memory now, in IndexedDB for the next session.
|
||||
fn register(handle: &FileSystemFileHandle) -> String {
|
||||
let id = make_id(js_sys::Date::now() as u64, (js_sys::Math::random() * 1e15) as u64);
|
||||
HANDLES.with(|h| h.borrow_mut().insert(id.clone(), handle.clone()));
|
||||
let (key, kept, name) = (id.clone(), handle.clone(), handle.name());
|
||||
spawn_local(async move {
|
||||
if let Err(e) = remember(&key, &kept).await {
|
||||
log::warn!("the file's handle isn't kept for the next session: {e}");
|
||||
}
|
||||
});
|
||||
path(&id, &name)
|
||||
}
|
||||
|
||||
fn save_options(name: &str) -> Object {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"suggestedName".into(), &name.into());
|
||||
let (description, mime, extension) = save_type(name);
|
||||
let _ = Reflect::set(&options, &"types".into(), &types(description, mime, extension));
|
||||
options
|
||||
}
|
||||
|
||||
/// "Save to computer…" for a design kept in the browser: write a copy of `bytes` to a file the
|
||||
/// user picks (call in the click), or download it where the browser can't pick one. The design
|
||||
/// itself stays where it is.
|
||||
pub fn save_copy(files: &Rc<Files>, name: &str, bytes: Vec<u8>) {
|
||||
if !supported() {
|
||||
if let Err(e) = crate::files::download(name, &bytes) {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let promise = start_picker("showSaveFilePicker", &save_options(name));
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
let h: FileSystemFileHandle =
|
||||
JsFuture::from(promise?).await.map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("not a file".into()))?;
|
||||
write_handle(&h, &bytes).await.map_err(Pick::Failed)?;
|
||||
Ok::<String, Pick>(h.name())
|
||||
};
|
||||
match r.await {
|
||||
Ok(saved) => files.say(format!("Saved a copy to {saved}"), false),
|
||||
Err(Pick::Cancelled) => {}
|
||||
Err(Pick::Failed(e)) => files.say(format!("save to computer: {e}"), true),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Save As, step one: show the browser's save dialog (call in the click). The answer is the
|
||||
/// engine path the design is saved to, `None` when the dialog was closed.
|
||||
pub fn pick_save(files: &Rc<Files>, name: &str) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
let promise = start_picker("showSaveFilePicker", &save_options(name));
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let handle = match promise {
|
||||
Ok(p) => JsFuture::from(p)
|
||||
.await
|
||||
.map_err(failed)
|
||||
.and_then(|h| h.dyn_into::<FileSystemFileHandle>().map_err(|_| Pick::Failed("not a file".into()))),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match handle {
|
||||
Ok(h) => Some(register(&h)),
|
||||
Err(Pick::Cancelled) => None,
|
||||
Err(Pick::Failed(e)) => {
|
||||
files.say(format!("save: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open, step one: show the browser's open dialog for a design (call in the click). The
|
||||
/// answer is the engine path of the file, read into the engine's files; a design keeps its
|
||||
/// handle, so Save writes back to the file. `None`: closed, or it could not be read.
|
||||
pub fn pick_open(files: &Rc<Files>) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
let promise = start_picker("showOpenFilePicker", &open_options());
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let r = async {
|
||||
let p = promise?;
|
||||
let handles: Array = JsFuture::from(p).await.map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("no file".into()))?;
|
||||
let h: FileSystemFileHandle = handles.get(0).dyn_into().map_err(|_| Pick::Cancelled)?;
|
||||
let bytes = read_handle(&h).await.map_err(Pick::Failed)?;
|
||||
Ok((h, bytes))
|
||||
};
|
||||
match r.await {
|
||||
Ok((h, bytes)) => Some(arrive(&h, bytes)),
|
||||
Err(Pick::Cancelled) => None,
|
||||
Err(Pick::Failed(e)) => {
|
||||
files.say(format!("open: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of the open dialog: a single file, designs and CAD files listed first.
|
||||
pub fn open_options() -> Object {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
|
||||
let extensions = Array::new();
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf"] {
|
||||
extensions.push(&e.into());
|
||||
}
|
||||
let accept = Object::new();
|
||||
let _ = Reflect::set(&accept, &"application/octet-stream".into(), &extensions);
|
||||
let entry = Object::new();
|
||||
let _ = Reflect::set(&entry, &"description".into(), &"Designs and CAD files".into());
|
||||
let _ = Reflect::set(&entry, &"accept".into(), &accept);
|
||||
let _ = Reflect::set(&options, &"types".into(), &Array::of1(&entry));
|
||||
options
|
||||
}
|
||||
|
||||
/// A file read from a dialog's handle: a design lives at its disk path from now on (and
|
||||
/// keeps the handle); anything else is an upload to open or insert.
|
||||
pub fn arrive(handle: &FileSystemFileHandle, bytes: Vec<u8>) -> String {
|
||||
let name = handle.name();
|
||||
let p = if solidcraft_engine::io::is_design_path(&name) { register(handle) } else { format!("{}{name}", crate::files::UPLOAD) };
|
||||
vfs::insert(&p, bytes);
|
||||
p
|
||||
}
|
||||
|
||||
/// Does an Open for `extensions` use the browser's open dialog?
|
||||
pub fn handles_open(extensions: &[&str]) -> bool {
|
||||
supported() && opens_design(extensions)
|
||||
}
|
||||
|
||||
// ---- writing ------------------------------------------------------------------------
|
||||
|
||||
/// Queue what a command wrote to a disk path. Writes go one at a time; a newer write of the
|
||||
/// same file replaces one still waiting.
|
||||
pub fn enqueue(files: &Rc<Files>, path: &str, bytes: Vec<u8>) {
|
||||
QUEUE.with(|q| {
|
||||
let mut q = q.borrow_mut();
|
||||
q.retain(|(p, _)| p != path);
|
||||
q.push_back((path.to_string(), bytes));
|
||||
});
|
||||
if BUSY.with(|b| b.replace(true)) {
|
||||
return;
|
||||
}
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
while let Some((path, bytes)) = QUEUE.with(|q| q.borrow_mut().pop_front()) {
|
||||
if let Err(e) = write(&path, &bytes).await {
|
||||
files.failed_disk(&path, &e);
|
||||
}
|
||||
}
|
||||
BUSY.with(|b| b.set(false));
|
||||
});
|
||||
}
|
||||
|
||||
async fn write(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let (id, name) = parse(path).ok_or("not a file on the computer")?;
|
||||
let handle = handle_for(id).await.map_err(|e| format!("{name}: {e}"))?;
|
||||
write_handle(&handle, bytes).await.map_err(|e| format!("{name}: {e}"))
|
||||
}
|
||||
|
||||
async fn write_handle(handle: &FileSystemFileHandle, bytes: &[u8]) -> Result<(), String> {
|
||||
ensure_readwrite(handle).await?;
|
||||
let w: web_sys::FileSystemWritableFileStream =
|
||||
JsFuture::from(handle.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
|
||||
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
|
||||
JsFuture::from(w.close()).await.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_for(id: &str) -> Result<FileSystemFileHandle, String> {
|
||||
if let Some(h) = HANDLES.with(|h| h.borrow().get(id).cloned()) {
|
||||
return Ok(h);
|
||||
}
|
||||
let h: FileSystemFileHandle = recall(id)
|
||||
.await?
|
||||
.and_then(|v| v.dyn_into().ok())
|
||||
.ok_or("the browser no longer remembers where this design was saved: use Save As to choose the file again")?;
|
||||
HANDLES.with(|m| m.borrow_mut().insert(id.to_string(), h.clone()));
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// Ask for write access if the browser doesn't hold it any more (after a reload): one prompt,
|
||||
/// from the click that asked for the save.
|
||||
async fn ensure_readwrite(handle: &FileSystemFileHandle) -> Result<(), String> {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"mode".into(), &"readwrite".into());
|
||||
for method in ["queryPermission", "requestPermission"] {
|
||||
let Some(f) = Reflect::get(handle, &method.into()).ok().and_then(|f| f.dyn_into::<Function>().ok()) else {
|
||||
// A browser without the permission calls hands out writable handles as they are.
|
||||
return Ok(());
|
||||
};
|
||||
let answer = f.call1(handle, &options).map_err(err)?;
|
||||
let state = JsFuture::from(Promise::resolve(&answer)).await.map_err(err)?;
|
||||
if state.as_string().as_deref() == Some("granted") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("the browser wasn't given permission to write the file: use Save As to choose it again".into())
|
||||
}
|
||||
|
||||
// ---- IndexedDB: the handles, by id --------------------------------------------------
|
||||
|
||||
/// The result of an IndexedDB request.
|
||||
async fn result(req: &IdbRequest) -> Result<JsValue, String> {
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let r = req.clone();
|
||||
let ok = Closure::once_into_js(move || {
|
||||
let _ = resolve.call1(&JsValue::NULL, &r.result().unwrap_or(JsValue::UNDEFINED));
|
||||
});
|
||||
let bad = Closure::once_into_js(move || {
|
||||
let _ = reject.call1(&JsValue::NULL, &"IndexedDB failed".into());
|
||||
});
|
||||
req.set_onsuccess(Some(ok.unchecked_ref()));
|
||||
req.set_onerror(Some(bad.unchecked_ref()));
|
||||
});
|
||||
JsFuture::from(promise).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn open_db() -> Result<IdbDatabase, String> {
|
||||
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(err)?.ok_or("this browser has no IndexedDB")?;
|
||||
let request: IdbOpenDbRequest = factory.open_with_u32(DB, 1).map_err(err)?;
|
||||
let target = request.clone();
|
||||
let upgrade = Closure::once_into_js(move || {
|
||||
if let Ok(db) = target.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
|
||||
let _ = db.create_object_store(STORE);
|
||||
}
|
||||
});
|
||||
request.set_onupgradeneeded(Some(upgrade.unchecked_ref()));
|
||||
result(&request).await?.dyn_into().map_err(|_| "no database".to_string())
|
||||
}
|
||||
|
||||
fn store(db: &IdbDatabase, mode: IdbTransactionMode) -> Result<IdbObjectStore, String> {
|
||||
db.transaction_with_str_and_mode(STORE, mode).map_err(err)?.object_store(STORE).map_err(err)
|
||||
}
|
||||
|
||||
/// Key of the time a handle was last used (the handle's own key is its id).
|
||||
fn stamp_key(id: &str) -> String {
|
||||
format!("at:{id}")
|
||||
}
|
||||
|
||||
async fn remember(id: &str, handle: &FileSystemFileHandle) -> Result<(), String> {
|
||||
let db = open_db().await?;
|
||||
let store = store(&db, IdbTransactionMode::Readwrite)?;
|
||||
result(&store.put_with_key(handle, &id.into()).map_err(err)?).await?;
|
||||
result(&store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into()).map_err(err)?).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Note that the file was used now.
|
||||
async fn touch(id: &str) {
|
||||
let Ok(db) = open_db().await else { return };
|
||||
if let Ok(store) = store(&db, IdbTransactionMode::Readwrite)
|
||||
&& let Ok(req) = store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into())
|
||||
{
|
||||
let _ = result(&req).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of every remembered file.
|
||||
pub async fn known_ids() -> Result<Vec<String>, String> {
|
||||
let db = open_db().await?;
|
||||
let req = store(&db, IdbTransactionMode::Readonly)?.get_all_keys().map_err(err)?;
|
||||
let keys: Array = result(&req).await?.dyn_into().map_err(|_| "no keys".to_string())?;
|
||||
Ok(keys.iter().filter_map(|k| k.as_string()).filter(|k| !k.starts_with("at:")).collect())
|
||||
}
|
||||
|
||||
async fn forget(ids: &[String]) -> Result<(), String> {
|
||||
let db = open_db().await?;
|
||||
let store = store(&db, IdbTransactionMode::Readwrite)?;
|
||||
for id in ids {
|
||||
result(&store.delete(&id.as_str().into()).map_err(err)?).await?;
|
||||
result(&store.delete(&stamp_key(id).into()).map_err(err)?).await?;
|
||||
HANDLES.with(|h| h.borrow_mut().remove(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the remembered files nothing refers to any more (`keep`: the ids the recent designs
|
||||
/// and the autosave name), once they have gone unused for a long while.
|
||||
pub fn prune(keep: std::collections::HashSet<String>) {
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
// A transaction ends when the page awaits anything but its own requests, so each
|
||||
// step opens its own.
|
||||
let known = known_ids().await?;
|
||||
let db = open_db().await?;
|
||||
let reading = store(&db, IdbTransactionMode::Readonly)?;
|
||||
let mut ids = Vec::new();
|
||||
for id in known {
|
||||
let at = result(&reading.get(&stamp_key(&id).into()).map_err(err)?).await?.as_f64();
|
||||
ids.push((id, at));
|
||||
}
|
||||
let plan = plan_prune(&ids, &keep, js_sys::Date::now(), HANDLE_MAX_AGE_MS);
|
||||
for id in &plan.stamp {
|
||||
touch(id).await;
|
||||
}
|
||||
forget(&plan.delete).await
|
||||
};
|
||||
if let Err(e) = r.await {
|
||||
log::warn!("remembered files weren't tidied: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
|
||||
/// for access to the file (call in the click), read it into the engine's files and queue it
|
||||
/// to open. A file that is gone is forgotten and dropped from the recent designs.
|
||||
pub fn restore(files: &Rc<Files>, path: &str) {
|
||||
let Some((id, name)) = parse(path) else { return };
|
||||
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
let handle = handle_for(&id).await?;
|
||||
ensure_readwrite(&handle).await?;
|
||||
read_handle(&handle).await
|
||||
};
|
||||
match r.await {
|
||||
Ok(bytes) => {
|
||||
vfs::insert(&path, bytes);
|
||||
files.opened.borrow_mut().push(path);
|
||||
files.wake();
|
||||
touch(&id).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
|
||||
if gone {
|
||||
let _ = forget(std::slice::from_ref(&id)).await;
|
||||
files.disk_known.borrow_mut().remove(&id);
|
||||
files.gone.borrow_mut().push(path);
|
||||
files.say(format!("{name} isn't where it was saved any more"), true);
|
||||
} else {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn recall(id: &str) -> Result<Option<JsValue>, String> {
|
||||
let db = open_db().await?;
|
||||
let req = store(&db, IdbTransactionMode::Readonly)?.get(&id.into()).map_err(err)?;
|
||||
Ok(Some(result(&req).await?).filter(|v| !v.is_undefined()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_disk_path_round_trips_its_handle_id_and_name() {
|
||||
let p = path("18f-2a", "Bracket v2.solidcraft");
|
||||
assert_eq!(p, "/disk/18f-2a/Bracket v2.solidcraft");
|
||||
assert_eq!(parse(&p), Some(("18f-2a", "Bracket v2.solidcraft")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_disk_paths_parse() {
|
||||
for not in [
|
||||
"/opfs/a.solidcraft",
|
||||
"/downloads/a.step",
|
||||
"/upload/a.step",
|
||||
"/disk/",
|
||||
"/disk/id",
|
||||
"/disk/id/",
|
||||
"/disk//a",
|
||||
"/disk/id/x/y",
|
||||
"a.solidcraft",
|
||||
] {
|
||||
assert_eq!(parse(not), None, "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Designs already stored in OPFS (and the recovery files) keep saving to OPFS under their own
|
||||
/// names, whether or not the browser can write to the disk.
|
||||
#[test]
|
||||
fn writes_to_opfs_paths_still_go_to_opfs() {
|
||||
for p in
|
||||
["/opfs/Bracket.solidcraft", "/opfs/Bracket (2).solidcraft", "/opfs/recovery.solidcraft", "/opfs/recovery.json", "/opfs/old.solvecraft"]
|
||||
{
|
||||
assert_eq!(route(p), WriteTo::Opfs, "{p}");
|
||||
}
|
||||
// A design opened from OPFS saves to its own path; only a Save As can pick the disk.
|
||||
assert_eq!(save_target(false, true), SaveTarget::Browser);
|
||||
assert_eq!(route(&format!("{OPFS_PREFIX}Bracket.solidcraft")), WriteTo::Opfs);
|
||||
assert_eq!(route("/disk/18f-2a/Bracket.solidcraft"), WriteTo::Disk);
|
||||
assert_eq!(route("/downloads/part.step"), WriteTo::Download);
|
||||
assert_eq!(route("/upload/part.step"), WriteTo::Memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_unreferenced_handles_unused_for_long_are_forgotten() {
|
||||
let keep: std::collections::HashSet<String> = ["recent".to_string()].into();
|
||||
let day = 24.0 * 3600.0 * 1000.0;
|
||||
let now = 1000.0 * day;
|
||||
let ids = vec![
|
||||
("recent".to_string(), Some(now - 200.0 * day)),
|
||||
("old".to_string(), Some(now - 100.0 * day)),
|
||||
("fresh".to_string(), Some(now - 5.0 * day)),
|
||||
("unstamped".to_string(), None),
|
||||
];
|
||||
let plan = plan_prune(&ids, &keep, now, HANDLE_MAX_AGE_MS);
|
||||
assert_eq!(plan, PrunePlan { delete: vec!["old".to_string()], stamp: vec!["unstamped".to_string()] });
|
||||
assert_eq!(plan_prune(&[], &keep, now, HANDLE_MAX_AGE_MS), PrunePlan::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_ids_differ_by_time_and_by_chance() {
|
||||
assert_ne!(make_id(1, 5), make_id(2, 5));
|
||||
assert_ne!(make_id(1, 5), make_id(1, 6));
|
||||
assert!(!make_id(0, 0).contains('/'));
|
||||
}
|
||||
|
||||
/// Save As: a design goes to the disk where the browser can pick files and to the browser's
|
||||
/// storage where it cannot (today's behaviour); an export is a download either way.
|
||||
#[test]
|
||||
fn save_as_chooses_between_the_disk_the_browsers_storage_and_a_download() {
|
||||
assert_eq!(save_target(true, true), SaveTarget::Disk);
|
||||
assert_eq!(save_target(false, true), SaveTarget::Browser);
|
||||
assert_eq!(save_target(true, false), SaveTarget::Download);
|
||||
assert_eq!(save_target(false, false), SaveTarget::Download);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_design_opens_use_the_disk_dialog() {
|
||||
assert!(opens_design(&[]));
|
||||
assert!(opens_design(&["solidcraft"]));
|
||||
assert!(opens_design(&["solvecraft"]));
|
||||
assert!(!opens_design(&["step", "stp"]));
|
||||
assert!(!opens_design(&["json"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_dialog_offers_the_type_of_the_name() {
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
assert_eq!(save_type("Plate.JSON").2, ".json");
|
||||
assert_eq!(save_type("Plate").2, ".solidcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_dialog_is_not_an_error() {
|
||||
assert!(is_abort("AbortError"));
|
||||
assert!(!is_abort("NotAllowedError"));
|
||||
assert!(!is_abort("SecurityError"));
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,26 @@
|
||||
//! the engine's in-memory files (`solidcraft_engine::io::vfs`) at start, written back whenever a
|
||||
//! command writes one. Files come in from the computer through a file picker (the File System
|
||||
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
|
||||
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
|
||||
//! in OPFS too and offered again after a reload or a crash.
|
||||
//! and go out as downloads (exports, and "Download design"). Where the File System Access API
|
||||
//! exists a design also lives on the computer's disk, saved and opened through the browser's file
|
||||
//! dialogs (`disk.rs`). An autosave of unsaved work is kept in OPFS and offered again after a
|
||||
//! reload or a crash.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
|
||||
use crate::disk;
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
|
||||
|
||||
/// Where OPFS files appear to the engine.
|
||||
pub const OPFS: &str = "/opfs/";
|
||||
pub const OPFS: &str = disk::OPFS_PREFIX;
|
||||
/// Paths written here are handed to the user as downloads.
|
||||
pub const DOWNLOADS: &str = "/downloads/";
|
||||
pub const DOWNLOADS: &str = disk::DOWNLOADS_PREFIX;
|
||||
/// Files brought in from the computer.
|
||||
pub const UPLOAD: &str = "/upload/";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
|
||||
@@ -51,6 +55,14 @@ 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).
|
||||
@@ -64,11 +76,17 @@ pub struct Files {
|
||||
}
|
||||
|
||||
impl Files {
|
||||
fn say(&self, text: impl Into<String>, error: bool) {
|
||||
pub fn say(&self, text: impl Into<String>, error: bool) {
|
||||
self.messages.borrow_mut().push((text.into(), error));
|
||||
self.wake();
|
||||
}
|
||||
|
||||
/// Writing the design at disk `path` failed: say so, and take its "saved" back.
|
||||
pub fn failed_disk(&self, path: &str, error: &str) {
|
||||
self.unsaved.borrow_mut().push(path.to_string());
|
||||
self.say(format!("Not saved to your computer: {error}"), true);
|
||||
}
|
||||
|
||||
/// A file arrived from the computer.
|
||||
fn arrive(&self, name: String, bytes: Vec<u8>, intent: Intent) {
|
||||
self.incoming.borrow_mut().push((name, bytes, intent));
|
||||
@@ -76,7 +94,7 @@ impl Files {
|
||||
}
|
||||
|
||||
/// Run a frame (the app sleeps until something happens).
|
||||
fn wake(&self) {
|
||||
pub fn wake(&self) {
|
||||
if let Some(c) = self.ctx.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
@@ -127,7 +145,7 @@ pub fn local_only() -> bool {
|
||||
LOCAL.with(Cell::get)
|
||||
}
|
||||
|
||||
fn err(e: JsValue) -> String {
|
||||
pub(crate) fn err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
@@ -150,7 +168,7 @@ async fn root() -> Result<FileSystemDirectoryHandle, String> {
|
||||
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
|
||||
}
|
||||
|
||||
async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
|
||||
pub(crate) async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
|
||||
let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
|
||||
blob_bytes(&file).await
|
||||
}
|
||||
@@ -253,6 +271,10 @@ 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() {
|
||||
@@ -295,26 +317,32 @@ 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() {
|
||||
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());
|
||||
match disk::route(&path) {
|
||||
disk::WriteTo::Opfs => {
|
||||
let name = path.strip_prefix(OPFS).unwrap_or(&path).to_string();
|
||||
{
|
||||
let mut e = files.entries.borrow_mut();
|
||||
e.retain(|x| x.name != name);
|
||||
if name != RECOVERY && name != RECOVERY_META {
|
||||
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
|
||||
e.sort_by_key(|a| a.name.to_lowercase());
|
||||
}
|
||||
}
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
if let Err(e) = write_file(&name, &bytes).await {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
if let Err(e) = write_file(&name, &bytes).await {
|
||||
disk::WriteTo::Disk => disk::enqueue(files, &path, bytes),
|
||||
disk::WriteTo::Download => {
|
||||
let name = path.strip_prefix(DOWNLOADS).unwrap_or(&path);
|
||||
if let Err(e) = download(name, &bytes) {
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
});
|
||||
} else if let Some(name) = path.strip_prefix(DOWNLOADS)
|
||||
&& let Err(e) = download(name, &bytes)
|
||||
{
|
||||
files.say(format!("{name}: {e}"), true);
|
||||
}
|
||||
disk::WriteTo::Memory => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,8 +455,15 @@ 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) => {
|
||||
let opts = Object::new();
|
||||
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
|
||||
// A design from the computer's disk keeps its file, so Save writes back to it.
|
||||
let on_disk = intent == Intent::Open && disk::supported();
|
||||
let opts = if on_disk {
|
||||
disk::open_options()
|
||||
} else {
|
||||
let opts = Object::new();
|
||||
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
|
||||
opts
|
||||
};
|
||||
let promise = f.call1(&window, &opts);
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
@@ -438,7 +473,13 @@ 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?;
|
||||
files.arrive(h.name(), bytes, intent);
|
||||
if on_disk && solidcraft_engine::io::is_design_path(&h.name()) {
|
||||
let path = disk::arrive(&h, bytes);
|
||||
files.opened.borrow_mut().push(path);
|
||||
files.wake();
|
||||
} else {
|
||||
files.arrive(h.name(), bytes, intent);
|
||||
}
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod disk;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -25,6 +27,7 @@ mod web {
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::disk::{self, SaveTarget};
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
@@ -101,6 +104,10 @@ mod web {
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
|
||||
cjk_bytes: Option<Vec<u8>>,
|
||||
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
}
|
||||
|
||||
@@ -117,6 +124,23 @@ mod web {
|
||||
return;
|
||||
}
|
||||
files::flush(&self.files);
|
||||
// A write to the computer's disk that failed: the design is not saved after all.
|
||||
for path in self.files.unsaved.borrow_mut().drain(..) {
|
||||
if self.app.session.path.as_deref() == Some(path.as_str()) {
|
||||
self.app.session.mark_unsaved();
|
||||
}
|
||||
}
|
||||
// Designs opened from the computer's disk (the files window): open them.
|
||||
let opened: Vec<_> = self.files.opened.borrow_mut().drain(..).collect();
|
||||
for path in opened {
|
||||
self.app.open_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
// Recent designs whose file is gone are dropped from the list.
|
||||
for path in self.files.gone.borrow_mut().drain(..) {
|
||||
self.app.home.recent.retain(|r| *r != path);
|
||||
self.app.home.opened_at.remove(&path);
|
||||
}
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
@@ -184,7 +208,9 @@ mod web {
|
||||
let mut entries = self.files.entries.borrow().clone();
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
let known = self.files.disk_known.borrow().clone();
|
||||
// A design on the computer's disk stays listed while its file is remembered.
|
||||
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
|
||||
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
@@ -192,6 +218,10 @@ mod web {
|
||||
}
|
||||
}
|
||||
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
|
||||
// Remembered files that no recent design or autosave names are forgotten in time.
|
||||
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
|
||||
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
|
||||
disk::prune(keep);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
@@ -220,6 +250,9 @@ mod web {
|
||||
act = Some(("download_open", String::new()));
|
||||
}
|
||||
});
|
||||
if !disk::supported() {
|
||||
ui.weak("This browser can't save to a folder: designs stay in this browser. Download design saves a file.");
|
||||
}
|
||||
ui.separator();
|
||||
if !self.files.ready.get() {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -266,6 +299,13 @@ mod web {
|
||||
self.renaming = Some((e.name.clone(), e.name.clone()));
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui
|
||||
.small_button("Save to computer…")
|
||||
.on_hover_text("A copy in a file you choose; the stored design stays here")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("save_copy", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Download").clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
@@ -301,6 +341,10 @@ mod web {
|
||||
}
|
||||
Err(e) => self.app.set_status(e.to_string(), true),
|
||||
},
|
||||
"save_copy" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => disk::save_copy(&self.files, &name, b),
|
||||
Err(e) => self.app.set_status(e.to_string(), true),
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let result = self
|
||||
@@ -375,7 +419,10 @@ mod web {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Ok(bytes) => {
|
||||
self.cjk_bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
@@ -387,11 +434,29 @@ mod web {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
self.apply_fonts(ctx);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
self.fetch_font(ctx);
|
||||
}
|
||||
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
|
||||
// to Ukrainian (or English) must put the Latin face back in front.
|
||||
if self.app.preferences.language != self.font_language {
|
||||
self.apply_fonts(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
|
||||
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
match self.cjk_bytes.clone() {
|
||||
Some(bytes) => {
|
||||
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
|
||||
}
|
||||
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
|
||||
}
|
||||
self.font_language.clone_from(&self.app.preferences.language);
|
||||
}
|
||||
|
||||
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
|
||||
@@ -508,18 +573,40 @@ mod web {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
|
||||
let (restorable, restorer) = (files.clone(), files.clone());
|
||||
let services = Services {
|
||||
// Open shows the browser's files (and the computer's, from there).
|
||||
pick_open: Some(Box::new(move |_: &[&str]| {
|
||||
// Open shows the browser's open dialog for a design where it has one
|
||||
// (the file keeps its handle, so Save writes back to it); otherwise,
|
||||
// and for inserting STEP or meshes, the browser's files window.
|
||||
pick_open: Some(Box::new(move |exts: &[&str]| {
|
||||
if disk::handles_open(exts) {
|
||||
return Box::pin(disk::pick_open(&opener));
|
||||
}
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
})),
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
// A design is saved to a file the user picks, or kept in the browser
|
||||
// where the browser can't pick one; everything else is downloaded.
|
||||
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
|
||||
match disk::save_target(disk::supported(), design) {
|
||||
SaveTarget::Disk => Box::pin(disk::pick_save(&saver, name)),
|
||||
SaveTarget::Browser => {
|
||||
saver.say("This browser can't save to a folder: the design is kept in this browser. File > Download design saves a file.", false);
|
||||
Box::pin(std::future::ready(Some(format!("{}{name}", files::OPFS))))
|
||||
}
|
||||
SaveTarget::Download => Box::pin(std::future::ready(Some(format!("{}{name}", files::DOWNLOADS)))),
|
||||
}
|
||||
})),
|
||||
// A recent design on the disk, from an earlier session, opens by asking
|
||||
// the browser for its remembered file.
|
||||
can_restore: Some(Box::new(move |path: &str| {
|
||||
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
|
||||
})),
|
||||
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
|
||||
download_design: Some(Box::new(files::download)),
|
||||
browser_files: Some(Box::new(move || shown.panel.set(true))),
|
||||
install_app: Some(crate::install::service(&cc.egui_ctx)),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -549,6 +636,7 @@ mod web {
|
||||
app.home.show(rev);
|
||||
}
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
|
||||
let font_language = app.preferences.language.clone();
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
api,
|
||||
@@ -560,6 +648,8 @@ mod web {
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
cjk_bytes: None,
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
|
||||
//! profile.
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
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.
|
||||
@@ -60,9 +60,43 @@ pub fn axes(values: &[f64]) -> Option<Axes> {
|
||||
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(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
|
||||
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")]
|
||||
@@ -70,29 +104,29 @@ pub use browser::SpaceMouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
|
||||
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::{axes, choose, source};
|
||||
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.
|
||||
fn read() -> Option<Axes> {
|
||||
/// 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, axes(&values)?))
|
||||
Some((source, (device_key(&pad.id()), axes(&values)?)))
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -100,6 +134,8 @@ mod browser {
|
||||
#[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,
|
||||
@@ -111,6 +147,8 @@ mod browser {
|
||||
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 {
|
||||
@@ -131,14 +169,18 @@ mod browser {
|
||||
}
|
||||
let reading = read();
|
||||
if reading.is_some() && !self.waking {
|
||||
self.waking = wake(ctx, &self.enabled);
|
||||
self.waking = wake(ctx, &self.enabled, &self.cached);
|
||||
}
|
||||
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
|
||||
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
|
||||
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -156,11 +198,11 @@ mod browser {
|
||||
|
||||
/// 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>>) -> bool {
|
||||
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) = (ctx.clone(), Rc::clone(enabled));
|
||||
let (ctx, enabled, cached) = (ctx.clone(), Rc::clone(enabled), Rc::clone(cached));
|
||||
let tick = Closure::<dyn FnMut()>::new(move || {
|
||||
if enabled.get() && read().is_some_and(Axes::moving) {
|
||||
if enabled.get() && wakes(read(), &cached.borrow()) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
@@ -192,6 +234,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibration_is_saved_under_the_ids_whichever_browser_lists_it() {
|
||||
assert_eq!(device_key("SpaceMouse Pro (Vendor: 046d Product: c62b)").as_deref(), Some("web 046d:c62b"));
|
||||
assert_eq!(device_key("46d-c62b-SpaceMouse Pro").as_deref(), Some("web 046d:c62b"));
|
||||
assert_eq!(device_key("Xbox Wireless Controller"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_page_wakes_for_motion_as_calibrated_not_as_read() {
|
||||
let axis =
|
||||
|source, rest, full| solidcraft_ui_egui::space_mouse::AxisCalibration { source, reversed: false, rest, dead_zone: 0, full: [full; 2] };
|
||||
// A short-range device: 15 of its 50 is under the raw dead zone, but 105 of 350 calibrated.
|
||||
let short = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 0, 50)) };
|
||||
let push = Axes { translation: [15, 0, 0], rotation: [0; 3] };
|
||||
assert!(!push.moving() && moving(push, Some(short)));
|
||||
// A device resting at 100 on every axis: past the raw dead zone, but at rest calibrated.
|
||||
let offset = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 100, 350)) };
|
||||
let resting = Axes { translation: [100; 3], rotation: [100; 3] };
|
||||
assert!(resting.moving() && !moving(resting, Some(offset)));
|
||||
// Without a calibration, as read.
|
||||
assert!(!moving(push, None) && moving(resting, None));
|
||||
// Kept from the last frame: only for the device it was for.
|
||||
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
|
||||
assert_eq!(moving_as_cached(&b, push, &(b.clone(), Some(short))), Some(true));
|
||||
assert_eq!(moving_as_cached(&b, push, &(a.clone(), Some(offset))), None, "A's calibration doesn't judge B");
|
||||
assert_eq!(moving_as_cached(&b, push, &(None, None)), None);
|
||||
assert_eq!(moving_as_cached(&a, resting, &(a.clone(), None)), Some(true), "A was read without a calibration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_page_wakes_once_for_a_device_change_and_stays_quiet_without_one() {
|
||||
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
|
||||
let still = Axes::default();
|
||||
let last_frame_read_a: Cached = (a.clone(), None);
|
||||
assert!(!wakes(Some((a.clone(), still)), &last_frame_read_a), "A, at rest");
|
||||
assert!(wakes(Some((b.clone(), still)), &last_frame_read_a), "A to B: a frame looks at B");
|
||||
assert!(wakes(None, &last_frame_read_a), "A to none: a frame shows it's gone");
|
||||
assert!(!wakes(None, &(None, None)), "none to none: quiet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
|
||||
for id in [
|
||||
@@ -280,6 +362,6 @@ mod tests {
|
||||
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([]), None);
|
||||
assert_eq!(choose::<Axes>([]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
@@ -178,11 +179,137 @@ struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS);
|
||||
/// 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,
|
||||
}
|
||||
|
||||
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
|
||||
/// What `control.running` says about a request: never its parameters beyond the command id.
|
||||
struct InFlight {
|
||||
key: u64,
|
||||
connection: u64,
|
||||
id: Value,
|
||||
method: String,
|
||||
command: Option<&'static str>,
|
||||
steps: Option<usize>,
|
||||
since: Instant,
|
||||
started: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The app's requests, from both of its listeners.
|
||||
static RUNNING: Running = Running::new();
|
||||
|
||||
impl Running {
|
||||
const fn new() -> Self {
|
||||
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
|
||||
self.list.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// List `req`, sent on `connection`, until the returned ticket is dropped.
|
||||
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
|
||||
let key = self.keys.fetch_add(1, Ordering::Relaxed);
|
||||
// Ids and methods come from the client: only short ones are repeated to the others.
|
||||
let id = match id {
|
||||
Value::Number(_) => id.clone(),
|
||||
Value::String(s) if s.len() <= 64 => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let p = &req.params;
|
||||
let command = match req.method.as_str() {
|
||||
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
|
||||
_ => None,
|
||||
};
|
||||
let steps = match req.method.as_str() {
|
||||
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
|
||||
_ => None,
|
||||
};
|
||||
let method = req.method.chars().take(64).collect();
|
||||
let started = req.started.clone();
|
||||
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
|
||||
Ticket { running: self, key }
|
||||
}
|
||||
|
||||
/// The `control.running` result, as `asker`'s connection sees it.
|
||||
fn result(&self, asker: u64) -> Value {
|
||||
let rows: Vec<Value> = self
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut r = json!({
|
||||
"connection": e.connection,
|
||||
"id": e.id,
|
||||
"method": e.method,
|
||||
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
|
||||
"queued": !e.started.load(Ordering::Relaxed),
|
||||
});
|
||||
if let Some(c) = e.command {
|
||||
r["command"] = json!(c);
|
||||
}
|
||||
if let Some(n) = e.steps {
|
||||
r["steps"] = json!(n);
|
||||
}
|
||||
if e.connection == asker {
|
||||
r["self"] = json!(true);
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
json!({"running": rows})
|
||||
}
|
||||
}
|
||||
|
||||
/// A listed request; dropped when its reply arrives.
|
||||
struct Ticket {
|
||||
running: &'static Running,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl Drop for Ticket {
|
||||
fn drop(&mut self) {
|
||||
let key = self.key;
|
||||
self.running.lock().retain(|e| e.key != key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
|
||||
/// until the app answers, also once its connection has closed (#3654).
|
||||
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
|
||||
let (late_tx, late_rx) = channel();
|
||||
// A small thread per timed-out request (each connection has at most one). If none can be
|
||||
// made, the reply is lost and `control.pending` says the app ended the request without one.
|
||||
// The reply is passed on before the request leaves the list: once `control.running` no longer
|
||||
// shows it, its connection's `control.pending` has it.
|
||||
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
|
||||
if let Ok(v) = reply.recv() {
|
||||
let _ = late_tx.send(v);
|
||||
}
|
||||
drop(late_tx);
|
||||
drop(ticket);
|
||||
});
|
||||
late_rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
|
||||
}
|
||||
|
||||
fn serve_with(
|
||||
stream: TcpStream,
|
||||
token: &str,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
waits: Waits,
|
||||
running: &'static Running,
|
||||
) {
|
||||
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
@@ -216,13 +343,16 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
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).
|
||||
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
|
||||
// A request that timed out may have finished since (#1618). `control.running` only
|
||||
// looks, so it leaves that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
});
|
||||
let mut r = if method == "control.pending" {
|
||||
let mut r = if method == "control.running" {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
|
||||
@@ -243,6 +373,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
let ticket = running.add(connection, &id, &req);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -251,7 +382,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
|
||||
pending = Some(Pending { id: id.clone(), method, reply: rrx });
|
||||
pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) });
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -307,9 +438,10 @@ mod tests {
|
||||
}
|
||||
});
|
||||
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);
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
@@ -349,6 +481,102 @@ mod tests {
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// #3654: `control.running` answers on every connection, at once, with what any connection
|
||||
/// has the app running or queued (command ids, never parameters); a timed-out request stays
|
||||
/// listed until the app answers it, and an idle app lists nothing.
|
||||
#[test]
|
||||
fn every_connection_sees_what_the_app_is_running() {
|
||||
static RUNNING: Running = Running::new();
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The window: one request at a time, as `drain_control` takes them; the export is held
|
||||
// until the test lets it go.
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
req.started.store(true, Ordering::Relaxed);
|
||||
if req.method == "engine.execute" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut out = s.try_clone().unwrap();
|
||||
let mut lines = BufReader::new(s);
|
||||
move |v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
}
|
||||
};
|
||||
let running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
|
||||
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
|
||||
if until(&rows) || Instant::now() > deadline {
|
||||
return (r, rows);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
let mut watcher = open();
|
||||
let (r, rows) = running(&mut watcher, &|_| true);
|
||||
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
|
||||
|
||||
let mut export = open();
|
||||
let path = "/private/part.step";
|
||||
let params = json!({"command": "file.export", "params": {"path": path}});
|
||||
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
// A batch from another connection waits behind the export.
|
||||
let mut batch = open();
|
||||
let queued = std::thread::spawn(move || {
|
||||
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
|
||||
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
|
||||
});
|
||||
// The window may not have taken the export yet, even though it timed out.
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
|
||||
assert_eq!(rows.len(), 2, "{r}");
|
||||
let (e, b) = (&rows[0], &rows[1]);
|
||||
assert_eq!(
|
||||
(&e["id"], &e["method"], &e["command"], &e["queued"]),
|
||||
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
|
||||
"{r}"
|
||||
);
|
||||
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
|
||||
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
|
||||
assert_ne!(e["connection"], b["connection"], "{r}");
|
||||
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
|
||||
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
|
||||
// The connection whose request timed out may ask too, and sees its own marked.
|
||||
let r = export(json!({"id": 8, "method": "control.running"}));
|
||||
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
|
||||
assert_eq!(own.len(), 1, "{r}");
|
||||
assert_eq!(own[0]["command"], "file.export", "{r}");
|
||||
|
||||
gate_tx.send(()).unwrap();
|
||||
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
|
||||
assert!(rows.is_empty(), "done: {r}");
|
||||
// Asking `control.running` did not take the export's reply from its connection.
|
||||
let r = export(json!({"id": 9, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
|
||||
}
|
||||
|
||||
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
|
||||
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
|
||||
/// need not be repeated on that connection.
|
||||
|
||||
@@ -91,6 +91,8 @@ impl eframe::App for App {
|
||||
fn on_exit(&mut self) {
|
||||
// The agent chat's agent and everything it started end with the app.
|
||||
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
|
||||
// A save still being written ends before the app does.
|
||||
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
@@ -179,7 +181,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
let future = file_parent.clone().add_filter(label, &all).pick_file();
|
||||
// Any other file attaches by reference, so the list is not the only choice.
|
||||
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
@@ -207,6 +210,10 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
// Already an installed app: only the browser build offers to install.
|
||||
install_app: None,
|
||||
can_restore: None,
|
||||
restore_file: None,
|
||||
download_design: None,
|
||||
browser_files: None,
|
||||
agent_http: Some(std::sync::Arc::new(agent_http)),
|
||||
secrets: Some(std::sync::Arc::new(Keychain)),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
|
||||
@@ -19,7 +19,7 @@ use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_space_mouse::Layout;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
@@ -165,8 +165,36 @@ fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
/// Where the reading comes from, which a calibration is saved under (`Source::key`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Source {
|
||||
/// The device itself, with its vendor and product ids when known.
|
||||
Hid(Option<(u16, u16)>),
|
||||
/// spacenavd, which may have swapped or inverted axes on its own (`spnavcfg`).
|
||||
#[cfg(target_os = "linux")]
|
||||
Spacenavd,
|
||||
/// The 3Dconnexion driver (macOS), which applies its own settings first.
|
||||
#[cfg(target_os = "macos")]
|
||||
Driver,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn key(self) -> String {
|
||||
match self {
|
||||
Self::Hid(Some((vendor, product))) => format!("hid {vendor:04x}:{product:04x}"),
|
||||
Self::Hid(None) => "hid".into(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Spacenavd => "spacenavd".into(),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::Driver => "3dxware".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
/// The device being read; none between devices.
|
||||
device: Option<Source>,
|
||||
axes: Axes,
|
||||
/// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
|
||||
/// translation and rotation in separate reports, or spread even one group over several.
|
||||
@@ -179,15 +207,18 @@ struct Reading {
|
||||
|
||||
impl Reading {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { axes: Axes::default(), at: [now; 6], held: false }
|
||||
Self { device: None, axes: Axes::default(), at: [now; 6], held: false }
|
||||
}
|
||||
|
||||
fn current(self, now: Instant) -> Axes {
|
||||
/// The axes at `now`. One with nothing fresh reads as `rest`: the device's own rest when it
|
||||
/// is calibrated, so a device that rests off zero calibrates to no motion rather than drifting.
|
||||
fn current(self, now: Instant, rest: Axes) -> Axes {
|
||||
let mut axes = self.axes;
|
||||
if !self.held {
|
||||
for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) {
|
||||
let rest = rest.translation.into_iter().chain(rest.rotation);
|
||||
for ((value, at), rest) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at).zip(rest) {
|
||||
if now.duration_since(at) >= STALE_AFTER {
|
||||
*value = 0;
|
||||
*value = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +226,11 @@ impl Reading {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw axes at the device's rest, which calibrate to no motion; 0 without a calibration.
|
||||
fn at_rest(calibration: Option<Calibration>) -> Axes {
|
||||
calibration.map(|calibration| calibration.raw_rest().into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
|
||||
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
@@ -233,11 +269,22 @@ fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish_held(ctx: &Context, shared: &Mutex<Reading>, axes: Axes) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading { axes, held: true, ..Reading::new(Instant::now()) };
|
||||
*reading = Reading { device: reading.device, axes, held: true, ..Reading::new(Instant::now()) };
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// A device is open: say which, so its calibration applies.
|
||||
fn connected(ctx: &Context, shared: &Mutex<Reading>, source: Source) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
// Nothing has arrived from it yet, so every axis starts out stale.
|
||||
let now = Instant::now();
|
||||
let stale = now.checked_sub(STALE_AFTER).unwrap_or(now);
|
||||
*reading = Reading { device: Some(source), at: [stale; 6], ..Reading::new(now) };
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The device is gone: stop reporting its last position.
|
||||
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
@@ -255,7 +302,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
loop {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
} else if let Some((device, layout, ids)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
let device = info
|
||||
.open_device(&api)
|
||||
.inspect_err(|error| {
|
||||
@@ -267,8 +314,9 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
})
|
||||
.ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout))
|
||||
Some((device, layout, (info.vendor_id(), info.product_id())))
|
||||
}) {
|
||||
connected(&ctx, &shared, Source::Hid(Some(ids)));
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
@@ -323,6 +371,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
loop {
|
||||
if let Some(daemon) = spacenavd::connect() {
|
||||
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
|
||||
connected(&ctx, &shared, Source::Spacenavd);
|
||||
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes));
|
||||
reset(&ctx, &shared);
|
||||
} else if let Some(device) = hidraw::find() {
|
||||
@@ -330,6 +379,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
Ok(mut file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {}", device.path.display());
|
||||
connected(&ctx, &shared, Source::Hid(device.ids));
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
@@ -362,25 +412,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
use solidcraft_3dx_client::Event;
|
||||
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
|
||||
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
|
||||
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
let started = solidcraft_3dx_client::start(
|
||||
"SolidCraft",
|
||||
move |event| match event {
|
||||
Event::DeviceAdded => {
|
||||
DRIVER_CONNECTED.store(true, Ordering::Relaxed);
|
||||
connected(&ctx, &shared, Source::Driver);
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
},
|
||||
|connected| match connected {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
},
|
||||
);
|
||||
if let Err(error) = started {
|
||||
log::info!("3D mouse: no driver client: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +451,9 @@ pub struct SpaceMouse {
|
||||
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
|
||||
wheel: WheelGuard,
|
||||
started: Instant,
|
||||
/// The calibration as of the last frame and the device it is for, for the wheel filter, which
|
||||
/// runs before the app sees the frame.
|
||||
calibration: Option<(Source, Calibration)>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -412,6 +474,7 @@ impl SpaceMouse {
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +488,15 @@ impl SpaceMouse {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
|
||||
let moving = self
|
||||
.shared
|
||||
.lock()
|
||||
.map(|reading| {
|
||||
// Only the calibration of the device being read: another's would misjudge it.
|
||||
let calibration = self.calibration.filter(|(source, _)| Some(*source) == reading.device).map(|(_, calibration)| calibration);
|
||||
calibrated(reading.current(now, at_rest(calibration)), calibration).moving()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
|
||||
}
|
||||
|
||||
@@ -445,11 +516,16 @@ impl SpaceMouse {
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
let axes = match self.shared.lock() {
|
||||
Ok(reading) => reading.current(now),
|
||||
Err(_) => return,
|
||||
};
|
||||
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
|
||||
let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return };
|
||||
app.space_mouse.device = reading.device.map(Source::key);
|
||||
let calibration = calibration(app);
|
||||
self.calibration = reading.device.zip(calibration);
|
||||
// While this device is being calibrated, an axis with nothing fresh reads 0, as a HID
|
||||
// device's silence means: the old calibration's rest isn't fed back into the new one. A
|
||||
// calibration of another device doesn't count; it ends in `navigate_frame`.
|
||||
let calibrating = app.space_mouse.calibrating.as_ref().is_some_and(|(device, _)| app.space_mouse.device.as_ref() == Some(device));
|
||||
let rest = if calibrating { Axes::default() } else { at_rest(calibration) };
|
||||
navigate_frame(app, ctx, self.driver_mapping, reading.current(now, rest), seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,14 +547,14 @@ mod tests {
|
||||
let mut reading = Reading::new(now);
|
||||
let layout = Layout::default();
|
||||
assert!(!decode_report(&[1, 1, 2], layout, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert_eq!(reading.current(now, Axes::default()), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now));
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
|
||||
assert_eq!(reading.current(now, Axes::default()), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
|
||||
// Only rotation arrives again: translation goes stale, rotation stays current.
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], layout, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] });
|
||||
assert_eq!(reading.current(later, Axes::default()), Axes { translation: [0; 3], rotation: [4, 5, 6] });
|
||||
// A device sending all six in one report updates both at once.
|
||||
let combined = solidcraft_space_mouse::Descriptor::parse(&[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, 0x19, 0x30, 0x29, 0x35, 0x81,
|
||||
@@ -488,7 +564,7 @@ mod tests {
|
||||
.expect("six axes declared");
|
||||
let mut both = Reading::new(now);
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], combined, &mut both, now));
|
||||
assert_eq!(both.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
assert_eq!(both.current(now, Axes::default()), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -507,11 +583,11 @@ mod tests {
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(decode_report(&[1, 100, 0], split_x_y, &mut reading, now));
|
||||
assert!(decode_report(&[2, 200, 0], split_x_y, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [100, 200, 0]);
|
||||
assert_eq!(reading.current(now, Axes::default()).translation, [100, 200, 0]);
|
||||
// Then only X keeps coming: Y goes stale on its own.
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert!(decode_report(&[1, 50, 0], split_x_y, &mut reading, later));
|
||||
assert_eq!(reading.current(later).translation, [50, 0, 0]);
|
||||
assert_eq!(reading.current(later, Axes::default()).translation, [50, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -524,8 +600,8 @@ mod tests {
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(decode_report(&[1, 100, 0, 0, 0, 0, 0], layout, &mut reading, now));
|
||||
// Just before the timeout X is still there; at exactly the timeout it's stale.
|
||||
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1)).translation, [100, 0, 0]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1), Axes::default()).translation, [100, 0, 0]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
|
||||
// Button reports and reports with an id the layout doesn't know keep coming, but neither
|
||||
// refreshes X: it goes stale on time all the same.
|
||||
for step in 1..=10 {
|
||||
@@ -533,7 +609,7 @@ mod tests {
|
||||
assert!(decode_report(&[3, 1], layout, &mut reading, at));
|
||||
assert!(!decode_report(&[0x17, 80], layout, &mut reading, at));
|
||||
}
|
||||
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,10 +619,10 @@ mod tests {
|
||||
let later = now + STALE_AFTER * 20;
|
||||
// spacenavd sends nothing while the values stay the same: the push lasts.
|
||||
let held = Reading { axes, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later), axes);
|
||||
assert_eq!(held.current(later, Axes::default()), axes);
|
||||
// A HID device repeats its report: silence means the cap, or the device, is gone.
|
||||
let hid = Reading { axes, ..Reading::new(now) };
|
||||
assert_eq!(hid.current(later), Axes::default());
|
||||
assert_eq!(hid.current(later, Axes::default()), Axes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -618,8 +694,12 @@ mod tests {
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
calibration: None,
|
||||
};
|
||||
mouse.tick_at(&mut app, &Context::default(), now);
|
||||
// A window that has run a frame with focus, as the app's always has by now.
|
||||
let ctx = Context::default();
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
mouse.tick_at(&mut app, &ctx, now);
|
||||
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
|
||||
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
|
||||
assert!(seconds > 0.0);
|
||||
@@ -642,9 +722,84 @@ mod tests {
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
const DEVICE: Source = Source::Hid(Some((0x1234, 0x5678)));
|
||||
|
||||
/// A device whose raw axes all rest at 100, calibrated as such.
|
||||
fn resting_at_100() -> Calibration {
|
||||
Calibration {
|
||||
dead_zone_share: 0.0,
|
||||
axes: std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration {
|
||||
source,
|
||||
reversed: false,
|
||||
rest: 100,
|
||||
dead_zone: 0,
|
||||
full: [350; 2],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibrated_device_resting_off_zero_leaves_the_wheel_alone() {
|
||||
// Read raw, X at 100 is the cap pushed past the dead zone: the wheel is dropped.
|
||||
let now = Instant::now();
|
||||
let mut resting = Reading::new(now);
|
||||
resting.device = Some(DEVICE);
|
||||
resting.axes = Axes { translation: [100; 3], rotation: [100; 3] };
|
||||
let wheel_kept = |calibration| {
|
||||
let mut mouse = mouse(resting, now);
|
||||
mouse.calibration = calibration;
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
mouse.raw_input_hook_at(true, &mut raw, now);
|
||||
raw.events.len() == 1
|
||||
};
|
||||
assert!(!wheel_kept(None));
|
||||
// Calibrated, it's at rest, so the wheel is the user's.
|
||||
assert!(wheel_kept(Some((DEVICE, resting_at_100()))));
|
||||
// Another device's calibration, kept from the last frame, isn't used for this one.
|
||||
assert!(!wheel_kept(Some((Source::Hid(Some((0x256f, 0xc63a))), resting_at_100()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibrated_device_resting_off_zero_doesnt_drift_when_nothing_fresh_arrives() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
app.preferences.space_mouse_calibrations.insert(DEVICE.key(), resting_at_100());
|
||||
// A window that has run a frame with focus, as the app's always has by now.
|
||||
let ctx = Context::default();
|
||||
let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
frame.textures_delta.clear();
|
||||
let motion = |app: &mut SolveApp, reading: Reading, now: Instant| {
|
||||
let mut mouse = mouse(reading, now);
|
||||
mouse.last_frame = now - Duration::from_millis(20);
|
||||
app.viewport.device_motion = None;
|
||||
mouse.tick_at(app, &ctx, now);
|
||||
app.viewport.device_motion.is_some()
|
||||
};
|
||||
// Connected, and nothing reported yet.
|
||||
let shared = Mutex::new(Reading::new(Instant::now()));
|
||||
connected(&Context::default(), &shared, DEVICE);
|
||||
let fresh = *shared.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
assert!(!motion(&mut app, fresh, now), "connected, nothing reported yet");
|
||||
// The same, while another device's calibration window was open: it ends, and this one
|
||||
// doesn't move on the first frame either.
|
||||
app.space_mouse.calibrating = Some(("hid 256f:c63a".into(), solidcraft_space_mouse::Wizard::default()));
|
||||
assert!(!motion(&mut app, fresh, now), "connected mid-calibration of another device");
|
||||
assert!(app.space_mouse.calibrating.is_none());
|
||||
// Translation reported at rest, rotation not for a while.
|
||||
let old = now - STALE_AFTER - Duration::from_millis(1);
|
||||
let partly = Reading { axes: Axes { translation: [100; 3], rotation: [0; 3] }, at: [now, now, now, old, old, old], ..fresh };
|
||||
assert!(!motion(&mut app, partly, now), "translation at rest, rotation stale");
|
||||
// Pushed, then the reports stopped.
|
||||
let pushed = Reading { axes: Axes { translation: [450, 100, 100], rotation: [100; 3] }, at: [old; 6], ..fresh };
|
||||
assert!(!motion(&mut app, pushed, now), "the push timed out");
|
||||
assert!(motion(&mut app, Reading { at: [now; 6], ..pushed }, now), "a fresh push moves the view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
|
||||
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading it is plain file
|
||||
//! I/O, so no udev or hidapi library is needed.
|
||||
//! 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.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -12,6 +12,8 @@ const DEV: &str = "/dev";
|
||||
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.
|
||||
@@ -25,5 +27,27 @@ fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
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()?;
|
||||
Some(Device { path, layout })
|
||||
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()??))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -29,3 +29,9 @@ request files under target, and do not retry them. The server never invokes a mo
|
||||
|
||||
Canonical paths, launch command and export schema: [README.md](README.md).
|
||||
There are no numbered bulk authoring scripts or generated inventory files to maintain.
|
||||
|
||||
Runtime discovery is also checked by `crates/ui-egui/tests/icon_audit.rs`. New menu
|
||||
surfaces must extend that Harness traversal. Regenerate the committed
|
||||
`design/requested_icons.txt` with `UPDATE_ICON_MANIFEST=1 cargo test -p
|
||||
solidcraft-ui-egui --test icon_audit`; review the sorted id/placeholder diff. The
|
||||
review tool reads this fixture in addition to source scans, masters and aliases.
|
||||
|
||||
@@ -18,8 +18,8 @@ artwork changes trigger the live revision check. The server binds only to loopba
|
||||
|
||||
Every opening scans SVG masters, aliases and source references. New, unused,
|
||||
missing and changed icons are discoverable. **All discovered icons without a valid
|
||||
acceptance are Open, including those with notes.** No inventory file or allowlist
|
||||
is needed. Rescan sees additions immediately; a running page also polls revisions.
|
||||
acceptance are Open, including those with notes.** Discovery also reads `design/requested_icons.txt`, the sorted runtime audit
|
||||
manifest; it supplements source scans and never restricts discovery. Rescan sees additions immediately; a running page also polls revisions.
|
||||
The internal placeholder glyph is the only excluded card. Shared masters are shown
|
||||
separately even when an accepted alias already uses them; reopen that alias before
|
||||
requesting a redraw of its protected artwork. Unassigned commands
|
||||
@@ -82,6 +82,7 @@ Palette changes preserve accepted artwork and never update review hashes.
|
||||
| `design/palette.toml`, `aliases.toml` | Shared theme roles, weights and semantic aliases |
|
||||
| `design/review.json` | Owner acceptance hashes and current notes |
|
||||
| `design/references.toml` | Curated style references; does not control discovery |
|
||||
| `design/requested_icons.txt` | Sorted runtime requests and placeholder flags, checked by the Harness audit |
|
||||
| `design/tools/author/{lib,bases}.py` | Reusable geometry and canonical cube/slab/arrow bases |
|
||||
| `design/tools/author/new_icon.py` | Safe new-master scaffold, never overwrites an id |
|
||||
| `design/tools/icon_status.py`, `review_state.py` | Discovery, review backend and state |
|
||||
@@ -104,5 +105,25 @@ python3 -B assets/icons/ui/design/tools/icon_status.py --csv
|
||||
```
|
||||
|
||||
Reports, screenshots, requests, generated contact sheets and previews stay under
|
||||
ignored `target/`. Do not commit generated inventories or historical authoring
|
||||
batches. Every non-Markdown resource has an exact row in `ATTRIBUTION.md`.
|
||||
ignored `target/`. The runtime request manifest is a committed regression fixture; other generated
|
||||
inventories and historical authoring batches do not belong in the repository. Every non-Markdown resource has an exact row in `ATTRIBUTION.md`.
|
||||
|
||||
## Runtime menu audit
|
||||
|
||||
`cargo test -p solidcraft-ui-egui --test icon_audit` drives the real UI through the
|
||||
headless scenario Harness and rejects placeholder requests and unexpected blank
|
||||
context-menu icons. It compares sorted requested ids with
|
||||
`design/requested_icons.txt` (`id<TAB>placeholder`, one per line). After deliberately
|
||||
changing menu coverage or assignments, regenerate and review its diff:
|
||||
|
||||
```sh
|
||||
UPDATE_ICON_MANIFEST=1 cargo test -p solidcraft-ui-egui --test icon_audit
|
||||
```
|
||||
|
||||
The review page includes every manifest id, even ids used only in tables or helper
|
||||
arguments. A true placeholder flag, missing master or broken alias is reported as
|
||||
missing; runtime collection never changes acceptance hashes. The live control
|
||||
method `ui.icon_audit` can diagnose requests in a running UI (see
|
||||
[control protocol](../../../docs/control-protocol.md)). Keep the Harness traversal
|
||||
current when adding menus. A successful traversal covers only the contexts it
|
||||
actually opens; document unreachable or untested contexts in the audit report.
|
||||
|
||||
@@ -207,3 +207,9 @@ All artwork must be original SolidCraft work or an explicitly licensed resource
|
||||
with provenance. Commercial CAD artwork, screenshots and extracted assets are
|
||||
forbidden even as hidden references. This guide and the local finished set provide
|
||||
all required visual context for future agents.
|
||||
|
||||
Runtime discovery is also checked by `crates/ui-egui/tests/icon_audit.rs`. New menu
|
||||
surfaces must extend that Harness traversal. Regenerate the committed
|
||||
`design/requested_icons.txt` with `UPDATE_ICON_MANIFEST=1 cargo test -p
|
||||
solidcraft-ui-egui --test icon_audit`; review the sorted id/placeholder diff. The
|
||||
review tool reads this fixture in addition to source scans, masters and aliases.
|
||||
|
||||
@@ -35,3 +35,12 @@ sketch_scale = { master = "scale", accent = "sketch" }
|
||||
sketch_text = { master = "text", accent = "sketch" }
|
||||
surface_fair = { master = "spline_cv", accent = "surface" }
|
||||
mesh_export = { master = "export", accent = "mesh" }
|
||||
|
||||
# Legacy feature-history id for the same Split Body operation.
|
||||
split = "split_body"
|
||||
|
||||
# Sketch dialog choices share the corresponding finished tool artwork.
|
||||
sketch_chamfer_equal = { master = "sketch_chamfer", accent = "sketch" }
|
||||
sketch_chamfer_two = { master = "sketch_chamfer_two_distances", accent = "sketch" }
|
||||
offset_one_side = { master = "offset", accent = "sketch" }
|
||||
bom = "list"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[roles.ink] # outlines and details drawn over a face
|
||||
source = "#34465b"
|
||||
light = "#34465b"
|
||||
dark = "#d8dfeb"
|
||||
dark = "#1f2021"
|
||||
[roles.line] # line art that sits directly on the UI background (SVG: currentColor)
|
||||
source = "#34465b"
|
||||
light = "#34465b"
|
||||
@@ -66,13 +66,13 @@ parameters = 4.2
|
||||
|
||||
[accents.surface.active-1]
|
||||
light = "#f1d2af"
|
||||
dark = "#ba8f6a"
|
||||
dark = "#cc9362"
|
||||
[accents.surface.active-2]
|
||||
light = "#c06f00"
|
||||
dark = "#b86f04"
|
||||
dark = "#bd750d"
|
||||
[accents.surface.active-3]
|
||||
light = "#a45000"
|
||||
dark = "#95682d"
|
||||
dark = "#955a0e"
|
||||
|
||||
[accents.form.active-1]
|
||||
light = "#decff8"
|
||||
@@ -85,41 +85,41 @@ light = "#7349cd"
|
||||
dark = "#7b63a6"
|
||||
|
||||
[accents.mesh.active-1]
|
||||
light = "#e5d99b"
|
||||
dark = "#a9985f"
|
||||
light = "#ffec8e"
|
||||
dark = "#ffe282"
|
||||
[accents.mesh.active-2]
|
||||
light = "#a28200"
|
||||
dark = "#9a8100"
|
||||
light = "#bb9600"
|
||||
dark = "#ab8f00"
|
||||
[accents.mesh.active-3]
|
||||
light = "#826700"
|
||||
dark = "#857100"
|
||||
light = "#e0b200"
|
||||
dark = "#c6a800"
|
||||
|
||||
[accents.sketch.active-1]
|
||||
light = "#bfe2cc"
|
||||
dark = "#81a28e"
|
||||
dark = "#7ab792"
|
||||
[accents.sketch.active-2]
|
||||
light = "#439a65"
|
||||
dark = "#459665"
|
||||
dark = "#3a965e"
|
||||
[accents.sketch.active-3]
|
||||
light = "#057f45"
|
||||
dark = "#27864e"
|
||||
dark = "#128d45"
|
||||
|
||||
[accents.constraint.active-1]
|
||||
light = "#f0cfca"
|
||||
dark = "#b98c88"
|
||||
[accents.constraint.active-2]
|
||||
light = "#c1685f"
|
||||
dark = "#b66b61"
|
||||
dark = "#c27267"
|
||||
[accents.constraint.active-3]
|
||||
light = "#af4038"
|
||||
dark = "#a05c54"
|
||||
light = "#ca473d"
|
||||
dark = "#c16358"
|
||||
|
||||
[accents.construction.active-1]
|
||||
light = "#ecd3b6"
|
||||
dark = "#b19373"
|
||||
dark = "#b99a78"
|
||||
[accents.construction.active-2]
|
||||
light = "#b8741f"
|
||||
dark = "#b4711c"
|
||||
dark = "#ad7023"
|
||||
[accents.construction.active-3]
|
||||
light = "#9c5600"
|
||||
dark = "#a75d00"
|
||||
@@ -134,12 +134,16 @@ dark = "#1d7f89"
|
||||
[variants.red-green.construction.active-3]
|
||||
light = "#b97300"
|
||||
dark = "#a75d00"
|
||||
# The constraint red stays darker than the sketch green for a weak red sense.
|
||||
[variants.red-green.constraint.active-3]
|
||||
light = "#af4038"
|
||||
dark = "#c16358"
|
||||
[variants.blue-yellow.construction.active-3]
|
||||
light = "#b97300"
|
||||
dark = "#c37d00"
|
||||
[variants.blue-yellow.constraint.active-3]
|
||||
light = "#af4038"
|
||||
dark = "#986255"
|
||||
dark = "#85554a"
|
||||
|
||||
# Default accent by requested icon id. Explicit alias accents in aliases.toml win.
|
||||
# Deliberately not inherited from a master: ground reuses c_fix but is not a constraint.
|
||||
@@ -153,9 +157,9 @@ sketch = [
|
||||
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
|
||||
"blend", "circle", "circle2", "circle3", "circle_tt",
|
||||
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
|
||||
"extend", "finish", "fit_section", "include", "infinite",
|
||||
"extend", "finish", "fit_section", "include",
|
||||
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
|
||||
"offset", "point", "polygon", "polygon_circumscribed", "polygon_edge",
|
||||
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
|
||||
"project", "project_surface", "rect", "rect3", "rect_center",
|
||||
"rect_rounded", "sketch", "sketch_chamfer", "sketch_chamfer_angle", "sketch_chamfer_two_distances",
|
||||
"sketch_fillet", "sketch_mirror", "slot", "slot_center", "slot_overall",
|
||||
@@ -168,5 +172,5 @@ constraint = [
|
||||
"c_polygon", "c_smooth", "c_symmetry", "c_tangent",
|
||||
]
|
||||
construction = [
|
||||
"centerline", "construction",
|
||||
"centerline", "construction", "infinite",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
accessibility false
|
||||
activate false
|
||||
align false
|
||||
appearance false
|
||||
arc3 false
|
||||
arc_center false
|
||||
arc_slot false
|
||||
arc_slot_center false
|
||||
arc_tangent false
|
||||
as_built false
|
||||
assistant false
|
||||
auto_constrain false
|
||||
axis false
|
||||
axis_edge false
|
||||
axis_normal_to_face false
|
||||
axis_through_cylinder false
|
||||
axis_two_planes false
|
||||
axis_two_points false
|
||||
bendtable false
|
||||
blend false
|
||||
body false
|
||||
bom false
|
||||
boss false
|
||||
boundary_fill false
|
||||
bounding_solid false
|
||||
box false
|
||||
break false
|
||||
bug_report false
|
||||
c_coincident false
|
||||
c_collinear false
|
||||
c_concentric false
|
||||
c_equal false
|
||||
c_fix false
|
||||
c_hv false
|
||||
c_midpoint false
|
||||
c_parallel false
|
||||
c_perpendicular false
|
||||
c_polygon false
|
||||
c_smooth false
|
||||
c_symmetry false
|
||||
c_tangent false
|
||||
cam false
|
||||
camera false
|
||||
canvas false
|
||||
capture false
|
||||
center_of_mass false
|
||||
centerline false
|
||||
chain false
|
||||
chamfer false
|
||||
checkpoint false
|
||||
chevron false
|
||||
chevron_right false
|
||||
circle false
|
||||
circle2 false
|
||||
circle3 false
|
||||
circle_tt false
|
||||
circle_ttt false
|
||||
close false
|
||||
coil false
|
||||
combine false
|
||||
compare false
|
||||
component false
|
||||
compute false
|
||||
configuration_table false
|
||||
conic false
|
||||
constrainer false
|
||||
construction false
|
||||
contact_all false
|
||||
contact_new false
|
||||
contact_off false
|
||||
contact_sets false
|
||||
continuity false
|
||||
convert_sheet false
|
||||
copy false
|
||||
curvature_comb false
|
||||
curvature_map false
|
||||
cut false
|
||||
cylinder false
|
||||
decal false
|
||||
delete false
|
||||
delete_face false
|
||||
dimension false
|
||||
draft false
|
||||
draft_analysis false
|
||||
drive false
|
||||
dxf false
|
||||
edit false
|
||||
ellipse false
|
||||
emboss false
|
||||
environment_map false
|
||||
error false
|
||||
explode false
|
||||
export false
|
||||
export_tray false
|
||||
extend false
|
||||
extrude false
|
||||
eye false
|
||||
fastener false
|
||||
fillet false
|
||||
finish false
|
||||
fit false
|
||||
fit_section false
|
||||
flange false
|
||||
flat false
|
||||
fold false
|
||||
folder false
|
||||
gear false
|
||||
graph false
|
||||
ground false
|
||||
help false
|
||||
hem false
|
||||
hole false
|
||||
home false
|
||||
import false
|
||||
import_tray false
|
||||
include false
|
||||
infinite false
|
||||
inspect false
|
||||
interference false
|
||||
intersect false
|
||||
intersection_curve false
|
||||
iso_analysis false
|
||||
iso_curve false
|
||||
joint false
|
||||
joint_origin false
|
||||
library false
|
||||
line false
|
||||
line_mid false
|
||||
lip false
|
||||
list false
|
||||
loft false
|
||||
lookat false
|
||||
material false
|
||||
measure false
|
||||
mesh_export false
|
||||
min_radius false
|
||||
mirror false
|
||||
motion_link false
|
||||
move false
|
||||
new false
|
||||
normal false
|
||||
offset false
|
||||
offset_face false
|
||||
open false
|
||||
orbit false
|
||||
origin false
|
||||
pan false
|
||||
params false
|
||||
paste false
|
||||
pattern_circ false
|
||||
pattern_path false
|
||||
pattern_rect false
|
||||
perforate false
|
||||
perspective false
|
||||
pipe false
|
||||
plane false
|
||||
plane_along_path false
|
||||
plane_angle false
|
||||
plane_midplane false
|
||||
plane_perpendicular false
|
||||
plane_tangent false
|
||||
plane_three_points false
|
||||
plane_two_edges false
|
||||
plastic_assign false
|
||||
plastic_rule false
|
||||
play false
|
||||
plus false
|
||||
point false
|
||||
point_along_path false
|
||||
point_center false
|
||||
point_edge_plane false
|
||||
point_three_planes false
|
||||
point_two_edges false
|
||||
point_vertex false
|
||||
polygon false
|
||||
polygon_circumscribed false
|
||||
polygon_edge false
|
||||
presspull false
|
||||
project false
|
||||
project_surface false
|
||||
rack_pinion false
|
||||
reach false
|
||||
rect false
|
||||
rect3 false
|
||||
rect_center false
|
||||
rect_rounded false
|
||||
redo false
|
||||
refold false
|
||||
rename false
|
||||
render_image false
|
||||
render_scene false
|
||||
reorder false
|
||||
replace_face false
|
||||
rest false
|
||||
restore false
|
||||
revert false
|
||||
revolve false
|
||||
rib false
|
||||
rigid_group false
|
||||
rollback false
|
||||
save false
|
||||
scale false
|
||||
screw false
|
||||
section false
|
||||
select false
|
||||
settings false
|
||||
sheet_rules false
|
||||
shell false
|
||||
silhouette false
|
||||
sketch false
|
||||
sketch_break false
|
||||
sketch_chamfer false
|
||||
sketch_chamfer_angle false
|
||||
sketch_chamfer_equal false
|
||||
sketch_chamfer_two false
|
||||
sketch_chamfer_two_distances false
|
||||
sketch_circ_pattern false
|
||||
sketch_dxf false
|
||||
sketch_fillet false
|
||||
sketch_mirror false
|
||||
sketch_move false
|
||||
sketch_rect_pattern false
|
||||
sketch_scale false
|
||||
sketch_text false
|
||||
slot false
|
||||
slot_center false
|
||||
slot_overall false
|
||||
snap false
|
||||
snap_fit false
|
||||
sphere false
|
||||
spin false
|
||||
spline false
|
||||
spline_cv false
|
||||
split false
|
||||
split_body false
|
||||
split_face false
|
||||
spun false
|
||||
star false
|
||||
status_error false
|
||||
style false
|
||||
suppress false
|
||||
surface_blend false
|
||||
surface_boundary false
|
||||
surface_extend false
|
||||
surface_extrude false
|
||||
surface_fair false
|
||||
surface_fillet false
|
||||
surface_loft false
|
||||
surface_offset false
|
||||
surface_patch false
|
||||
surface_reverse_normal false
|
||||
surface_revolve false
|
||||
surface_ruled false
|
||||
surface_stitch false
|
||||
surface_sweep false
|
||||
surface_thicken false
|
||||
surface_trim false
|
||||
surface_unstitch false
|
||||
surface_untrim false
|
||||
svg false
|
||||
sweep false
|
||||
text false
|
||||
thread false
|
||||
torus false
|
||||
trace false
|
||||
trim false
|
||||
undo false
|
||||
unfold false
|
||||
update false
|
||||
warning false
|
||||
web false
|
||||
ws_animate false
|
||||
ws_design false
|
||||
ws_drawing false
|
||||
ws_render false
|
||||
zebra false
|
||||
zoom false
|
||||
@@ -4,6 +4,7 @@
|
||||
"accessibility": "012aa26ece9ffa8bd6a850b7a19279c3675164b12438f5fbfe560e575c8fe2eb",
|
||||
"activate": "9d28c47dcb0b733095eadc840a30fa898a19f9cf9eee203496386546666cb1f8",
|
||||
"agent_chat": "4a9ae0e7e2c360b4858b3175674c93d096705c92318fecd481e849f3ae650bf4",
|
||||
"agent_link": "2f20d89a0e3e9b28744910dddf7f282a1099ccd91aa160c9841c5b1d9ea9ed0f",
|
||||
"align": "05615db7dd9e4be56ae898e59a68c548b8a8447794442a9a394940651204bc4f",
|
||||
"appearance": "b3162edd92291a5ef402e8499cd52be05beb2fa7eda641163c3f6e98325de2bf",
|
||||
"arc3": "fdca3d501fd557ac989376a12e9c4fa8231fde2b455c20724d1079a466153e9c",
|
||||
@@ -12,10 +13,11 @@
|
||||
"arc_slot_center": "ba93e773e06e948fcf3c6dab28df341c07d35c6a3afa33a840b261bb82bcf32b",
|
||||
"arc_tangent": "d6910c8d42878cc34617b96e26c3018f4a6e4e793443ac81f2c294f88d70fa28",
|
||||
"as_built": "0f481932deca5586a8a8ea3d707efca0747f5c59559d53830b694d3458531013",
|
||||
"assistant": "8fac392b16b4f13c6a399c59962b97c1912282e0ec909976496959472f1d642d",
|
||||
"auto_constrain": "012e5da4ff83449f6a3a92ee2d9924998cbeea545281bdf97873710b7436183d",
|
||||
"axis": "66ebabe43a7b1b73b59a3a03b7bd92ab54d899947d0cc2d93ad6e6aac4d76501",
|
||||
"axis": "acdab0eb7b99b1579d64ae190082df48b65174e7b8ad3104967bcc59f6154206",
|
||||
"axis_edge": "5a65898bc38bdaaaf5b04f2c2f7556915b5b7b1fdc4de9708fcc30f64aa1ab18",
|
||||
"axis_normal_to_face": "0824bb37e80928e9ac880af5ca5fdb3b52084af9e73db73a5a5a7bc8f1f899f7",
|
||||
"axis_normal_to_face": "597b5b96ce787b70791133267108c5196aca09d5e3f94e7dc92199a63fe49ca6",
|
||||
"axis_through_cylinder": "804eb75631f4ee71ac8db51209237bb19d82bafd326abdc81b124b875c0874b4",
|
||||
"axis_two_planes": "80162aa79ca7baf6d60490414a4fb53d87e6f2b85e901c452875a5f3ed86d35a",
|
||||
"axis_two_points": "5064f7544ce41ae7c1f05a90c958222bbcee03723df67d34cd3e209d25a1f71f",
|
||||
@@ -28,36 +30,38 @@
|
||||
"boundary_fill": "17ccb419a46c04a44ec864af968a32984dc81f6b3196a821bfff7f54141dfdd1",
|
||||
"bounding_solid": "e2deb6fff292f0f295022583ba7ddb86896eb79d9dd7268e653713b9c62f0580",
|
||||
"box": "72cd44c3c4fc5ae71242fca8c60af47371930201276df5b7db9d2f16a2739c31",
|
||||
"break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c",
|
||||
"break": "67a5fab332616e6d6a9aeae3bedc04ee0768179143ba34b795bdd98cea04bb24",
|
||||
"bug_report": "8cf7afbe0d3c34988fd2748de148f56a7e939fd8273ba01a79a976f879c74831",
|
||||
"c_coincident": "32fb535b3dd7631dc2e9ea284f445ad54ea0e5c8b0e1bb27a9984c849746917d",
|
||||
"c_coincident": "a93cc58acc5090c24c973cb558920bff4e35c8f99071135f16a115256d41bf98",
|
||||
"c_collinear": "3bcf1dc6b221a1fa9a4445ee8afc7d858dc28a5270d22d10fef86fa4997f1bdf",
|
||||
"c_concentric": "90e9b35b6186ea4fcb3debc24808f302c0bf7354b7afae86e339574ef8e5103a",
|
||||
"c_equal": "c0f50c02db7d15a6392d2178e28df5c100e67fab7732f8e8474b888d218d0673",
|
||||
"c_fix": "b91c6af7ea9740efb8cf196409b11d16fad52c51b732a356e5f844bd4e2f08e0",
|
||||
"c_fix": "23e2471a9d6872e83028e0e219e330cef1930a4d89456a0fefa1bda8a7e3eb81",
|
||||
"c_hv": "b5c44d60a4b03b9942fbcf3c706f797ce4dbddf9a67eb1c98d6aaf3234e9bc18",
|
||||
"c_midpoint": "fd4efc26da1336f811a9016b02d7675af2a7c95cd1ffa2f091507b22434f5435",
|
||||
"c_midpoint": "aaea530b8a04aa80efc99ed2cf42347a91c65b160286bae3e2882f5a0bd09a51",
|
||||
"c_parallel": "aaa80258502af091b596511662aca392546dd0d7bf69cec4db98b6e0ae7591f0",
|
||||
"c_perpendicular": "692ba7a77cd77108c33c101386b60b7c957ce7d2202937d9696d18dcf3d4cf8e",
|
||||
"c_polygon": "defac40b4c6ef323494f49acbb94b896502f78f6a99e750975693c602164a44b",
|
||||
"c_smooth": "dccf5effdb8977ac04e6c437c280c83ed0f117d81b64c57fae03f5f7f7f2b9c0",
|
||||
"c_perpendicular": "0ccf0383176ace2d4ab30c2efe853815ca1df20f2537020f369f496d8bf124b5",
|
||||
"c_polygon": "02c76b288b42d6be8eb77f502af95bb840557f43fed3def2b1a381f6ac920375",
|
||||
"c_smooth": "87aeb504aa330a191a8680d9eab3e86844f6d7be0a9caa10fdf0a657e76f45ee",
|
||||
"c_symmetry": "8646a7deb7447514bcc455562d8639f8b4c482df8c971fd2870c05965ce71f78",
|
||||
"c_tangent": "a011f5d43d5e09f68de332c60c5acee737a3ebc94d107f521871225c3841a064",
|
||||
"c_tangent": "ef8b1e502c2a939958b5e8de9ef2716442e32b2a394555eb7b51101c415b966e",
|
||||
"cam": "a35342e36ca4deae55e98c54df630b5c2da591c06d979eb2b8ce6a13f6db66d0",
|
||||
"camera": "fcd867877ea4984318dbff36a166b48682f26bb9d44e70383dde63d9a7ab87c8",
|
||||
"canvas": "512d7721f0b8b14a46f22b9588b437b0cd8c2c425330d31b3b156c0d1334bcef",
|
||||
"capture": "23ef84e50b45c06532b3e842b72f6da5498d0e6b8391e1bff55e84afa8359f37",
|
||||
"center_of_mass": "24401b1a19cb45ddb028de0acf3c9815b47abecbde9e133f473adac3d27ae346",
|
||||
"centerline": "a1889b9092ef5a7aa9592df994b6541b53ea9afb03b2c56c7678a61df335f4d7",
|
||||
"centerline": "850283497bad77e233f934e8effc0d0d7b01ccde83fa66f6abb539a33a8a2c80",
|
||||
"chain": "9cdc78486b5c4b5b2a378e09bbd41733eb167513f9f2bc4b1aad629dd6c4c753",
|
||||
"chamfer": "487c90ae65e87b74d7ddee7d9ce9f088b63e9b5b85c2a390364855a2649f17e8",
|
||||
"checkpoint": "a3bc0700a40b791b5f3bf176d820bdabd2cf94a526908609d50136c8d0918d6a",
|
||||
"chevron": "61887736af4b1e6bef857c4d2e3846657824e84f1c8026a56525ddba2d2a8083",
|
||||
"chevron_right": "d28a52dcc51fd9eb988f8f83e89d74370a31100049c910a886732a48ec1342ca",
|
||||
"circle": "145544ff1cd133971f537dd247092126ef8d91a88fe31b795222e76840d0c4d0",
|
||||
"circle2": "f1b6e8e2157279cfe1e608f913b377e44a70f7555f5010298138de8a887bf586",
|
||||
"circle3": "aaf6b914e630688675abf4dcbf7d43096317881ced0e22520a4ef229013e0de2",
|
||||
"circle_tt": "94996320b671c8c48f239b8c48f1b2f1cd5cc33a7d8ea1daaa61aa9203803cae",
|
||||
"circle_ttt": "ac3c211a6fe17c3625060f695ecd93937730e2bc2dd57921bcbbcaa984b9badf",
|
||||
"close": "5833954dfba3d70a9a3a7519f2796a7213899548b0345bd8fe8ea9a321d82be2",
|
||||
"coil": "4c549fd63506457165f53a7940cc0f3dcacdbe075e935ea299edbf4985104411",
|
||||
"combine": "f9d4211f0153779369c9da68b266c2c9a857d11bc033ff4a96ede794e470e38e",
|
||||
"compare": "c24b6c18f18413ae6f3344aaa6e04b42fae8b63e7cd1552a81398462eb68eb96",
|
||||
@@ -66,7 +70,7 @@
|
||||
"configuration_table": "1a142e76b9e4cd47412a307cc0ad9b8c589bb97c8930fc445dd5bb8d74ecf707",
|
||||
"conic": "808445fd697ff76f9074e7bbf877146e634995c295dce045604991fa4cec26a0",
|
||||
"constrainer": "774cb158897eb91767541b5b1a25a46f3a731cbea0661006ce5472634ab6b0e3",
|
||||
"construction": "ad962cbcd0aaef9167744abfd0bfe1d7ef55e48b4ff42f2b4b05f66204b58acb",
|
||||
"construction": "f13e3851cdf7b30a7118802a1c17e8852fcb9893fd1d65c7098a5c3fc35c8396",
|
||||
"contact_all": "6ad4d245592815a3e38e70ae324ad3971ab614daba7ca3cf8fcb64cb8e2be077",
|
||||
"contact_new": "603410785fff408cc12dc72bef2f09885b694c5ddb2571c7819ae1301fb89dc6",
|
||||
"contact_off": "54dc3bd2267ebb084d8dc7efee85e9f21d8c59a57ad37b96d78095bc75e73fa0",
|
||||
@@ -81,8 +85,10 @@
|
||||
"decal": "acc258e40b29ffb6b6007057f0b70f487995f573704c7ef4e4f08f209d52bddd",
|
||||
"delete": "d14203f06b8b1b87328e7f198c7aa41595e9a66b5d8a7fbd2a3ec2e35cd99afd",
|
||||
"delete_face": "dcbf8ab59bd37bd5a0cdd7a528b29c2d19daa5f3fc130ef5cff0c20034f061a4",
|
||||
"dimension": "8246dde8b0a140d1f533c1e45f2e8b5af35c05d3504dccf50bd7e7726a93389e",
|
||||
"dots": "aa38423c547594462f49cc355f3c9f5b41e28a7515f466a6a8cffda487ba1715",
|
||||
"dimension": "a926b7c64bc8ca99d8d6dcbb81b95235bd3fc34fbed049aca8b01b4d1147c15d",
|
||||
"disclosure_closed": "49a350c6f2efae78de0be96c524ebee1fd1291ea449a84224b88d37164a4e807",
|
||||
"disclosure_open": "d13ce3f97e3e362a9904fa2bf3412f3f00e4136bc23538b92d41483961d280de",
|
||||
"dots": "600f7ff209d93145fc938bc4f6a1e34473e6b9ee22a5b4718f133bc7da5ee58d",
|
||||
"draft": "9c0fc361cacd0ad95ccd54794f4825dfc9901a83a3de752a588faec365d0ee09",
|
||||
"draft_analysis": "eb39c0f90e9cf226da6307365a76015e900dd692dc3f0fdc8f02fe717226261f",
|
||||
"drive": "cf9e924acb2531efd5a1b90915d59f0cb532c844b754fd3a0e376ef6f706c47d",
|
||||
@@ -97,7 +103,7 @@
|
||||
"export": "c8c2abf007e43e896479d78798e3976ba9eda809507c820bb38538f77db696ac",
|
||||
"extend": "28c63df352c77d02feffe44cf09fb0fc1d48f7905aaabbfa6dba601dbb70f8e8",
|
||||
"extrude": "9cc1538971c5d25467aaed23f739899d9d102fd07f86472ac3197c54ca2e1144",
|
||||
"eye": "5d90a25f5d07e6e36a2a8faf5e2ed1425b2a787834ab2291b1b1c521057ec1c2",
|
||||
"eye": "2449167c8410826dc1106b687c12a309d5bea8f7817b897943e80f182a163c2d",
|
||||
"fastener": "fcff4e4be50907f712e527b373771afafd92e50096fc7a4b8aba5d86448e1e42",
|
||||
"fillet": "11ec232dc111597211e3a4fe61b69616b5d28b4b1b2732052ddcd486cdfc6129",
|
||||
"filter": "4d456a56416089574bd3732fa44c0c1b27fd2478b369448808a27f0144d15d8a",
|
||||
@@ -109,15 +115,15 @@
|
||||
"fold": "90344fb5e5799378117fbd79ef28a84f27fba5723af5414c4ea30a2ac3a4f21e",
|
||||
"folder": "c19f5230df45a1bda1e92e89de38bba21c91e6eca0517eb15847a7712012d1e5",
|
||||
"gear": "25a1ca48a587850da1104a46005a330873ed196246e3d04e12cf61f26ad2b0d5",
|
||||
"graph": "25646bdf4d1a534c51e1897a8ae641a21f871ed70b24ff25652e1fe957c9fa00",
|
||||
"ground": "b91c6af7ea9740efb8cf196409b11d16fad52c51b732a356e5f844bd4e2f08e0",
|
||||
"graph": "7156caedd2dbfa2ae53da18b7a2204b8db66c45126859d36caf049a3fd4335a4",
|
||||
"ground": "23e2471a9d6872e83028e0e219e330cef1930a4d89456a0fefa1bda8a7e3eb81",
|
||||
"help": "8ce4060577483dbdb510be147d2827de50ac20029354008fca55abc0c7edf05d",
|
||||
"hem": "f9b1aafbedcf3dc90c26be308814638e0095fbe31b0647092507343fa60af011",
|
||||
"hole": "f82d47751f826e08682db847a270897474661bfe9f35b6d3678f49a203172563",
|
||||
"home": "ab5f1c41d377687e4c5a808dfdccf2757b33dd43f934d44df43d6e9118176853",
|
||||
"import": "e343540eb49fdb1e53c4d54b9e60ae513b3103df6c9133048bed878e58067455",
|
||||
"include": "f5b80ad3e06e3b863fcfbe415ec912e4e09653dd519f9ac207819339fe34730f",
|
||||
"infinite": "bff57a1c56d9b47131d7c8ff85f008a53c612c63706052e7e19d7aaea93cf5b1",
|
||||
"infinite": "53fb781131078d2f8e71be23aa610d9f9d9c8bb79ba5f97fa397475e9ff8c3bd",
|
||||
"inspect": "d2b4644b2d5f1aa50cb576b9265e275fa91201e296c2bc79865f810067d538e9",
|
||||
"interference": "b3d729d0e822e8d95485c8ef50649847572def10d4c713fff0e073b8031c5f5d",
|
||||
"intersect": "9ab4f6551848a2be546f55502a77075bed2d3994f89d91b721eab92bcee61d61",
|
||||
@@ -125,7 +131,7 @@
|
||||
"iso_analysis": "dacd34ac254ec4fbb6932350f62d4d92626d922a2bfc62d2caecdeb17303e896",
|
||||
"iso_curve": "015ff2e26346ccaf149f75cf6a0652d9201c1b23a14e6bb62625acfa599dc002",
|
||||
"joint": "72b71a608e713f6cf51a1cb84edb011d4ccbd8bfe41d917fa2a1d13345dc0b2b",
|
||||
"joint_origin": "5b0761122eb4295ea220314e8db1efd1251e3482653d6cdcc47252d0268864a8",
|
||||
"joint_origin": "e12529af1fce8c5ffdf746e71ce2f5db5eb170e53778469cea8fd30344291574",
|
||||
"library": "ac0b1798e4c94340f9c854daa3829371c1a9f3ffea7679d955e6cfa139cafdc8",
|
||||
"line": "cd1177ea659548f03d43c7fa7dcc209a0542b1ba4bd0153c5113576cb7a96a81",
|
||||
"line_mid": "edb8ebc463479ae9878e54b930c124153ca880270b7cf6c0e4edba6336ba2854",
|
||||
@@ -139,17 +145,20 @@
|
||||
"min_radius": "92bd79b7d557aba6ddfccc76a4c6d0cfb579061b3c57172f9171d28af675711c",
|
||||
"mirror": "56637068a319dc2972c5552b25e4f43151aa9bd5055c2fef08204dd7cc0829cf",
|
||||
"moon": "3ba5adb92bf0f49e92d46682dbf6905e555aa6eebc8e2b3c5288f949cda29322",
|
||||
"motion_link": "19698bbe15b7c2d2601208bbcdc26627d1aa7724aee017b7da8874ad87fb9d1f",
|
||||
"motion_link": "27832f827146446557609336a9b9e48e649ed84e58c45bd9032a93abbd454104",
|
||||
"move": "4132e270ada8d0c79b477095c84d656d6d354e200c1748ce7fc425f36d0b7031",
|
||||
"nav_settings": "b93dd46d3a3d074e0d8834167f977dab417fbc1a721bc4db5e840732304e4193",
|
||||
"new": "c64c78fd8445bc7b2aa8765d0507b9adf23e35cbf52828d3f1987edccce0389f",
|
||||
"normal": "1148cef2d60a858b69406a2e2527d4ea126f45846de7cc2fb3a96640e9ee226f",
|
||||
"normal": "3c000d707837d4e9c865aefa43c1e610bfce5a06a38510f82e4db11fc13ab31f",
|
||||
"offset": "094607afcfdcb9c1410114d9f8d22ca367f8e32ab4c61f71affe44ad5d09adbe",
|
||||
"offset_face": "83d39308ec9f3ead78329c62841c8899bbc76ce08e17d539016d18c91d9d4ea7",
|
||||
"offset_one_side": "094607afcfdcb9c1410114d9f8d22ca367f8e32ab4c61f71affe44ad5d09adbe",
|
||||
"offset_surface": "b501cfb56b8404cdb2275935d780ce7dbb67e87e29ade3782f0859c59070a2b1",
|
||||
"offset_symmetric": "67f3489402af3afa055181bbdb2a32b9fe2e0d96dccbe0f28df087577978ac9c",
|
||||
"offset_two_sides": "ef69f955c5bef15471e9d77a4498e9337bf938ad75dcbc1e95af2515b6fd13da",
|
||||
"open": "f1880d25489753f7e40390902037bbd3195fbd099a5e9dd175797d832abff727",
|
||||
"orbit": "4adcd400cdde909139386e78579433354f65f5a77a0594447bf6dda663b3b1b4",
|
||||
"origin": "9b9360edc16ef9f059bff7d863f1bc0e3abac6acf559d76c4ea39dc374ab2844",
|
||||
"origin": "636f9f41654a6da7a5a708bcddef85672f2d77bfec2267b0134e8c5a59768b40",
|
||||
"pan": "2465eda7a39d4458d7818f2eb2b033992a68f80d9505bff538b41eb4511bb632",
|
||||
"params": "d0d9d0b3e786880e59b985dc2e3a7d593cf3771484a8fd2924d69155195c230f",
|
||||
"paste": "e1b3c089adafacb6f7b78b86ec7aba0876b189c81d01031045dfee2c47ae8edc",
|
||||
@@ -164,22 +173,23 @@
|
||||
"perspective": "5fa45ba9b75dbe9fdad9eae331ca7ff35c6ad018b8a1fc4c8ae7bcfc13ed0c3f",
|
||||
"pipe": "b9ecf1ae9f6790e2ae6c677f290446f70eba1773a10247fe33d5283b360b0403",
|
||||
"plane": "3e7fa0a9d57891f3c1ec03d7422bdb29353d3487deed12e8ec5737aeb33c2f3a",
|
||||
"plane_along_path": "020e25969a7f4e7e3358003addee7df8b1991cde8401df0b4df8295b19497994",
|
||||
"plane_along_path": "db4686ea02c3c3b8ad0d9eac203cc050c395cff6358358922a79c00e955e6852",
|
||||
"plane_angle": "2f0914955663b393a377fda080bc03dc5a2133d42c506c1908c53b2fd609b156",
|
||||
"plane_midplane": "66bd880821e7b122f130824313b7070d72d79168a52df6a126106b6eb5816474",
|
||||
"plane_perpendicular": "90e1d9b8e75743821aa1082596d9c3f288ab3baddf087fbae3f2025481e58a6e",
|
||||
"plane_perpendicular": "0055fb18ee39044bfc9f2ac53ba55fe73d8fe59e8f4cf225ee2c723a883d3085",
|
||||
"plane_tangent": "c3872b9f1d0f0b2493a5488dfdad50049c3422c29023250088eeda9bbcc6e795",
|
||||
"plane_three_points": "08e2dbaf606d165ef4f788869fc794a4fbbbb2ee86a60017b76e467af6da338a",
|
||||
"plane_two_edges": "1588b8d4741382d9e8fec38212419ac1c05b1bc6036f276d3b5c62db66f8ebdb",
|
||||
"plane_two_edges": "21de401ec5bab8ae37510da6fa0a6910325f858c17460da0041fd26897338d30",
|
||||
"plastic_assign": "bbfdb2d2c07629c047e8e671c7c032654668a67b4b8a934f74cbb3d7a00cc5fe",
|
||||
"plastic_rule": "51b8591d92907a3ffb76a47a0503d7ba86706d129dd990515a364dbf1ab8cb48",
|
||||
"play": "d1c17ebc16e9a4ee5ae2a8cc37b515ffcc8c53a2ab17117959e9b21278df8def",
|
||||
"plus": "4f324157bb2348ac9f09346b669b08d66ed96cb1c694f33db6f21134f18904b5",
|
||||
"point": "fd19dbcd26e2a898267bab54b9d89779d97894209c1771317d7debef5a6f8f5d",
|
||||
"point_along_path": "1fd58d45285fb5cabeddbd35bc8485af1b6d7f1414e302202b4e73de593d1b7a",
|
||||
"point_along_path": "17abd16dec5f70e9f8449c927aa995cfed10fca552ec7cd8f51b209c52012595",
|
||||
"point_center": "0ad755896f7d0154a854bda16d3059da78d1fae57962e13a3bb1b31e284b7e79",
|
||||
"point_edge_plane": "548a6c1c944caabe44ab0318e85ae82f39c11f74fbedda35f57bc6615755f249",
|
||||
"point_edge_plane": "a277bc0b87f2dd39eb613835d0f961b3372483e22053e6a522a06c535ad530c4",
|
||||
"point_three_planes": "b33c7508ffa67b66a57b72c6ea7900e93ff16362a29d0b2688e0b1db9bfad041",
|
||||
"point_two_edges": "35dcb67b36c8e41041cc836b01056731ab6f14c5e3b4bdd836fbee2a1dd897e3",
|
||||
"point_two_edges": "da329241706e405b877cec20639dc0a78505ca7212c7c47ad1f1f34aa26f4666",
|
||||
"point_vertex": "649bb63caf65a3fbc00a1222cc642294832b5e5d8c4d33f812e09a70684c4abe",
|
||||
"polygon": "4919268cca7d21ce98dbf4ca01ee832350e793afe4213e257d3c46e94ea88ad1",
|
||||
"polygon_circumscribed": "7e4ad16cc1f42c599287b186f26f4ff3c108736877a571a5513bfc72d0cbb23f",
|
||||
@@ -213,16 +223,19 @@
|
||||
"scale": "a66f69d9a1d46c8be30da0a15827bc7e38cba72d6271537763b888c2c7e2b3fa",
|
||||
"screw": "5e30570785360d6a591ea22862a82f1cf62228385c1e96382536db8a453ac791",
|
||||
"section": "cdd13f99f15f6d59bfa21c747810608d6afd00cb8207faf0337928c8521e7eae",
|
||||
"select": "a0c716234ef1d599be9a7654d6b2911ebe4b2de479383ba33aeab14992443a2f",
|
||||
"select": "fdf6431270006ca7409c60c37cfed59572de04229f104ec723e6010ffa2b7163",
|
||||
"settings": "9193dd3a1007c43660d0d47b6499cb2571338b67949e1a99df112057b9bf2b8a",
|
||||
"sheet": "774e30923ef6f37dd9c0b2bc3e45705e83b3a76700bf071cd41155a365d0daa4",
|
||||
"sheet_rules": "b8c3a67c82be20fc00c109decc566e8755d70167e04e0a9fb92941f7b27aa993",
|
||||
"sheet_rules": "5d3a2f37d3b4a4262b3ddbdae53d61f29e0f8e284dd54006da25a7acebfa1114",
|
||||
"shell": "a1959ea0d0441a479bf654b6e549e2e44a0bfdc9cda4eb46fe1fbfd884de7e38",
|
||||
"silhouette": "2a848aed2f247b1c0439f830a34a513c93bc5493c40044c821d493d863ad996e",
|
||||
"sketch": "b52e5862b1709a9a7e3ca00d7436d126fbfc01b38080006fa4e17dc581dfa103",
|
||||
"sketch_break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c",
|
||||
"sketch_break": "67a5fab332616e6d6a9aeae3bedc04ee0768179143ba34b795bdd98cea04bb24",
|
||||
"sketch_chamfer": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4",
|
||||
"sketch_chamfer_angle": "b853f5e7c3b008c0d56ac445a9a28f713987c919ea284945113ad35e7665e2a7",
|
||||
"sketch_chamfer_two_distances": "2b70d658e341f4388838f3c8c579ec5f4f3a7fddcfa2ce7a3f89fc3db6422d6e",
|
||||
"sketch_chamfer_angle": "6750268d05e0b0fd74c2d0368bf4703b09da825def668dc3dfd05deef3ba9adb",
|
||||
"sketch_chamfer_equal": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4",
|
||||
"sketch_chamfer_two": "1f5ecf1283e2b021e067ed2e4e2e97aa4e1ad3c7c98d8609bb81c5f811988f78",
|
||||
"sketch_chamfer_two_distances": "1f5ecf1283e2b021e067ed2e4e2e97aa4e1ad3c7c98d8609bb81c5f811988f78",
|
||||
"sketch_circ_pattern": "c5ff904237a3072f858f66c2777febc4facd2fee630bbf979047c5685d9a05ce",
|
||||
"sketch_dxf": "dddce44d4dd10f75a221208fcf431b36b3f0cafce6611fc695ac73afb4bf5789",
|
||||
"sketch_fillet": "e8fa170e5f6899abf75788b9384d49f2b9d77ba9b644f79309284a9e65629fe1",
|
||||
@@ -240,6 +253,7 @@
|
||||
"spin": "a5bebeff8276dd12ab7e42ddf0051370b9063fb602607298acf22e77044434d1",
|
||||
"spline": "a0dcb25614e5fe1a8f17bc3bddbb11b61cf82b461dd46443ab28cddd6e4e32e3",
|
||||
"spline_cv": "353f6e411c0ce611f660cef2e4fc4f8f55edc2c82288de91948dc638cd2b5f15",
|
||||
"split": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b",
|
||||
"split_body": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b",
|
||||
"split_face": "c8b89072118bb7a28b243d38e26b81a796781a631f30b3b1fc296ba23e4d908d",
|
||||
"spun": "b4cfa093ffb43611ee124f4a39fb44c609deb9cde5e05e4dfbaf893fe197c27b",
|
||||
@@ -248,7 +262,7 @@
|
||||
"status_warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
|
||||
"stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d",
|
||||
"style": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578",
|
||||
"suppress": "6c033a6672a3b6ca926e3d7a75a758c9db7e1144210cd360be043a9fdf999ede",
|
||||
"suppress": "c54fbc01c12c0175d78cd63f692e8aaf73fc2cac79752b2a3ad821ac32734343",
|
||||
"surface": "ba0e839ce0ee03e7ff95a2389a5ecb20bd80c713064d9b1bd5f461f4a80d1921",
|
||||
"surface_blend": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc",
|
||||
"surface_boundary": "fb8501bae192e2416118cc2e88f544e0e29197f6720bccc541c967ca0848d8d3",
|
||||
@@ -264,14 +278,14 @@
|
||||
"surface_ruled": "c3f0202d3beaacef0368765ce771eb9e3b0e3e157f86564c72d81f0fad9a3cab",
|
||||
"surface_stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d",
|
||||
"surface_sweep": "6d7f18c12b2e4c1dbe3632fe08239687f6f027b29b6dddbf8c8e2d9151c7baf3",
|
||||
"surface_thicken": "676eb73a99d708214e8951ebe2c4d88d60de2d752c725d8106d12fbd812e461a",
|
||||
"surface_thicken": "35aaa8e4fdbcd220a3a05165d5230a7ce576fbf5a6a5772d27162ab61bb71538",
|
||||
"surface_trim": "ae269721b386bd1264184b9838df3240b5b6ba02b99c9993384ef21200fb7398",
|
||||
"surface_unstitch": "bd8302fe95a89eb6f9bbf35ef0ee98c8a2186e160cff3d091f8cf2a0a75d4473",
|
||||
"surface_untrim": "24de5d558519e0629280d11919ba5661c92ccdccdce03f8f3d83f2d7867c2173",
|
||||
"svg": "7da77d53c84db18ce95737ad633c105d1009444b3192a5e0aae76873cfc9cc12",
|
||||
"sweep": "6d7f18c12b2e4c1dbe3632fe08239687f6f027b29b6dddbf8c8e2d9151c7baf3",
|
||||
"text": "9d31b72b16644681c8a86ac257511847ad69ce4351cf6960442222e94b168c4a",
|
||||
"thicken": "676eb73a99d708214e8951ebe2c4d88d60de2d752c725d8106d12fbd812e461a",
|
||||
"thicken": "35aaa8e4fdbcd220a3a05165d5230a7ce576fbf5a6a5772d27162ab61bb71538",
|
||||
"thread": "158728c3dcf6623bb1584d162398bb12c28d18770e0d47f5e6328355ce31b37c",
|
||||
"torus": "5ae13fc213582e0844118b910a3a26efd9e4b177d21500363fbae230803cb4fd",
|
||||
"trace": "5f2ecc61d4591d3c3b0ecccd35296b8613fce7beb2ae214fb906160c31d89187",
|
||||
@@ -283,7 +297,7 @@
|
||||
"update": "c675e16df4c78589a00be4a34f98627500681672ee8a668e929aec50982da3cc",
|
||||
"warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
|
||||
"web": "be5cd95bebcd0ebe257f9eb862112124429b381ef4a2f0849938fac7c9ccc619",
|
||||
"ws_animate": "d36a0834ad989a6e16e2b0db493ad8867c590caedf0442c51eff19e934c887f8",
|
||||
"ws_animate": "f34f187850789cc4f141b1ace114082be04edec8e7771d6730fec1cef19c7f64",
|
||||
"ws_design": "7b1dd678a2d018892b965a78932c047bbfff6b4439d7bd9fe85c09a995f97581",
|
||||
"ws_drawing": "61e99c7179f3448a31b8c76392334352ec3d5e39b0b92fe5244940146d0986cb",
|
||||
"ws_render": "1bfe474d1742623ad2142129e4d6a40015e674813eb1db401d2e88e55b1f2a03",
|
||||
@@ -291,6 +305,9 @@
|
||||
"zoom": "89c121fa85df034d70bd9f06e20cbd54d163691d4df03b13a9ac5372e951aeda"
|
||||
},
|
||||
"notes": {
|
||||
"pattern_flip": "This feels sparse. Improve the composition"
|
||||
"construction": "Fix the bottom dot's position",
|
||||
"infinite": "Make the infinity symbol use the 'Line art' colour",
|
||||
"pattern_flip": "This feels sparse. Improve the composition",
|
||||
"sheet_rules": "Make the top line also blue"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Connected agent</title>
|
||||
<path d="M25 7L25 24M39 7L39 24" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M16 25L48 25L48 31Q48 41 38 41L36 41L36 47L28 47L28 41L26 41Q16 41 16 31Z" fill="currentColor" stroke="currentColor" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 47L32 57" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Assistant</title>
|
||||
<defs>
|
||||
<mask id="assistant-sparkle" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -32L96 -32L96 96L-32 96Z" fill="white"/>
|
||||
<path d="M28 18.5Q30.3 27.7 39.5 30Q30.3 32.3 28 41.5Q25.7 32.3 16.5 30Q25.7 27.7 28 18.5Z" fill="black"/>
|
||||
<path d="M43.5 15.5Q44.6 19.9 49 21Q44.6 22.1 43.5 26.5Q42.4 22.1 38 21Q42.4 19.9 43.5 15.5Z" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M15 11.5L49 11.5Q55 11.5 55 17.5L55 40.5Q55 46.5 49 46.5L30 46.5L18 55L18 46.5L15 46.5Q9 46.5 9 40.5L9 17.5Q9 11.5 15 11.5Z" fill="currentColor" stroke="currentColor" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" mask="url(#assistant-sparkle)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Chevron right</title>
|
||||
<path d="M24 16L40 32L24 48" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Close</title>
|
||||
<path d="M19 19L45 45M45 19L19 45" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disclosure closed</title>
|
||||
<path d="M24 17L46 32L24 47Z" fill="currentColor" stroke="currentColor" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disclosure open</title>
|
||||
<path d="M17 24L47 24L32 46Z" fill="currentColor" stroke="currentColor" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Export Tray</title>
|
||||
<path d="M32 38L32 10M20 22L32 10L44 22M10 43L10 54L54 54L54 43" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Eye</title>
|
||||
<path d="M6 32C16 18 48 18 58 32C48 46 16 46 6 32Z" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M24 32A8 8 0 1 0 40 32A8 8 0 1 0 24 32Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Import Tray</title>
|
||||
<path d="M32 10L32 38M20 26L32 38L44 26M10 43L10 54L54 54L54 43" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Plus</title>
|
||||
<path d="M32 14L32 50M14 32L50 32" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Suppress</title>
|
||||
<defs>
|
||||
<mask id="suppress-slash" maskUnits="userSpaceOnUse" x="-32" y="-32" width="128" height="128">
|
||||
<path d="M-32 -32L96 -32L96 96L-32 96Z" fill="white"/>
|
||||
<path d="M13 51L51 13" fill="none" stroke="black" stroke-width="13" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<path d="M6 32C16 18 48 18 58 32C48 46 16 46 6 32Z" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round" mask="url(#suppress-slash)"/>
|
||||
<path d="M24 32A8 8 0 1 0 40 32A8 8 0 1 0 24 32Z" fill="currentColor" mask="url(#suppress-slash)"/>
|
||||
<path d="M13 51L51 13" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
@@ -4,5 +4,5 @@
|
||||
<path d="M12 52L52 52" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 8L16.5 17L7.5 17Z" fill="#236ead"/>
|
||||
<path d="M56 52L47 56.5L47 47.5Z" fill="#236ead"/>
|
||||
<circle cx="12" cy="52" r="4" fill="#34465b"/>
|
||||
<circle cx="12" cy="52" r="4" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 489 B After Width: | Height: | Size: 494 B |
@@ -6,5 +6,5 @@
|
||||
<path d="M32 6L32 33" fill="none" stroke="#ffffff" stroke-width="6.45" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 6L32 33" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 27.6L36.68 30.3L36.68 35.7" fill="none" stroke="#ffffff" stroke-width="4.05" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 27.6L36.68 30.3L36.68 35.7" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 27.6L36.68 30.3L36.68 35.7" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -3,5 +3,5 @@
|
||||
<path d="M7 32L23 32M41 32L57 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="23" cy="32" r="3.07" fill="#236ead"/>
|
||||
<circle cx="41" cy="32" r="3.07" fill="#236ead"/>
|
||||
<path d="M32 22L32 42" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 22L32 42" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 452 B After Width: | Height: | Size: 457 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Coincident</title>
|
||||
<path d="M10.4 42.32L32 27.92" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 27.92L53.6 37.52" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.4 42.32L32 27.92" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 27.92L53.6 37.52" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M25.76 27.92A6.24 6.24 0 1 0 38.24 27.92A6.24 6.24 0 1 0 25.76 27.92Z" fill="#b9ddf8" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 541 B After Width: | Height: | Size: 551 B |
@@ -2,5 +2,5 @@
|
||||
<title>Fixed</title>
|
||||
<path d="M22.4 22.4A9.6 9.6 0 1 0 41.6 22.4A9.6 9.6 0 1 0 22.4 22.4Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32L32 51.2" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M24.8 51.2L39.2 51.2" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M24.8 51.2L39.2 51.2" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 517 B After Width: | Height: | Size: 522 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Midpoint</title>
|
||||
<path d="M10.4 39.2L53.6 24.8" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.4 39.2L53.6 24.8" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="7.3" fill="#ffffff"/>
|
||||
<circle cx="32" cy="32" r="5.6" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 332 B |
@@ -2,5 +2,5 @@
|
||||
<title>Perpendicular</title>
|
||||
<path d="M12.8 48.8L51.2 48.8" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 48.8L32 15.2" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 39.2L41.6 39.2L41.6 48.8" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 39.2L41.6 39.2L41.6 48.8" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 501 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Regular polygon</title>
|
||||
<path d="M52.4 32L42.2 49.67L21.8 49.67L11.6 32L21.8 14.33L42.2 14.33Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32L52.4 32" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="3.4" fill="#34465b"/>
|
||||
<path d="M32 32L52.4 32" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="3.4" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 449 B After Width: | Height: | Size: 459 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Curvature</title>
|
||||
<path d="M10.4 32L12.2 27.65L14 23.6L15.8 20.12L17.6 17.45L19.4 15.77L21.2 15.2L23 15.77L24.8 17.45L26.6 20.12L28.4 23.6L30.2 27.65L32 32" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.4 32L12.2 27.65L14 23.6L15.8 20.12L17.6 17.45L19.4 15.77L21.2 15.2L23 15.77L24.8 17.45L26.6 20.12L28.4 23.6L30.2 27.65L32 32" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32L33.8 36.35L35.6 40.4L37.4 43.88L39.2 46.55L41 48.23L42.8 48.8L44.6 48.23L46.4 46.55L48.2 43.88L50 40.4L51.8 36.35L53.6 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="3.4" fill="#34465b"/>
|
||||
<circle cx="32" cy="32" r="3.4" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 634 B |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Tangent</title>
|
||||
<path d="M17.6 32A14.4 14.4 0 1 0 46.4 32A14.4 14.4 0 1 0 17.6 32Z" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M17.6 32A14.4 14.4 0 1 0 46.4 32A14.4 14.4 0 1 0 17.6 32Z" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.4 17.6L53.6 17.6" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 397 B |
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Centerline</title>
|
||||
<path d="M10 32L23 32M41 32L54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M11 53L23.41 40.59M40.59 23.41L53 11" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="3.6" fill="#236ead"/>
|
||||
<circle cx="10" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="10" cy="32" r="3.4" fill="#236ead"/>
|
||||
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
|
||||
<circle cx="11" cy="53" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="11" cy="53" r="3.4" fill="#236ead"/>
|
||||
<circle cx="53" cy="11" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="53" cy="11" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 486 B After Width: | Height: | Size: 498 B |
@@ -1,7 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Construction</title>
|
||||
<path d="M13 51L19 45M25 39L31 33M37 27L43 21M49 15L51 13" fill="none" stroke="#6f879c" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 54L12 52" fill="none" stroke="#6f879c" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="52" r="3.07" fill="#6f879c"/>
|
||||
<circle cx="52" cy="12" r="3.07" fill="#6f879c"/>
|
||||
<path d="M17 47L23 41M29 35L35 29M41 23L47 17" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="11" cy="53" r="3.07" fill="#236ead"/>
|
||||
<circle cx="53" cy="11" r="3.07" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 349 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Dimension</title>
|
||||
<path d="M10 12L10 52M54 12L54 52" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L10 52M54 12L54 52" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M19 32L45 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M13 32L24 26L24 38Z" fill="#236ead"/>
|
||||
<path d="M51 32L40 38L40 26Z" fill="#236ead"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 451 B After Width: | Height: | Size: 456 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>More</title>
|
||||
<circle cx="13" cy="32" r="5" fill="#34465b"/>
|
||||
<circle cx="32" cy="32" r="5" fill="#34465b"/>
|
||||
<circle cx="51" cy="32" r="5" fill="#34465b"/>
|
||||
<circle cx="13" cy="32" r="5" fill="currentColor"/>
|
||||
<circle cx="32" cy="32" r="5" fill="currentColor"/>
|
||||
<circle cx="51" cy="32" r="5" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 252 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Eye</title>
|
||||
<path d="M4 32C16 14 48 14 60 32C48 50 16 50 4 32Z" fill="#e2e8ef"/>
|
||||
<path d="M4 32C16 14 48 14 60 32C48 50 16 50 4 32Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 32A10 10 0 1 0 42 32A10 10 0 1 0 22 32Z" fill="#328bd3"/>
|
||||
<path d="M22 32A10 10 0 1 0 42 32A10 10 0 1 0 22 32Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="4" fill="#34465b"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 588 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Dependency graph</title>
|
||||
<path d="M16.5 17L47.5 32L16.5 47" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M16.5 17L47.5 32L16.5 47" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8.6 12.44L16.5 17L16.5 26.12L8.6 21.56Z" fill="#b8c3cf"/>
|
||||
<path d="M24.4 12.44L16.5 17L16.5 26.12L24.4 21.56Z" fill="#8e9faf"/>
|
||||
<path d="M16.5 7.88L24.4 12.44L16.5 17L8.6 12.44Z" fill="#e2e8ef"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Infinite line</title>
|
||||
<path d="M32 32L15.66 48.34" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 54L13.18 44.45L19.55 50.82Z" fill="#34465b"/>
|
||||
<path d="M32 32L48.34 15.66" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M54 10L50.82 19.55L44.45 13.18Z" fill="#34465b"/>
|
||||
<path d="M32 32L15.66 48.34" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 54L13.18 44.45L19.55 50.82Z" fill="#236ead"/>
|
||||
<path d="M32 32L48.34 15.66" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M54 10L50.82 19.55L44.45 13.18Z" fill="#236ead"/>
|
||||
<path d="M32 32C26.86 22.57 20 23.43 20 32C20 40.57 26.86 41.43 32 32C37.14 22.57 44 23.43 44 32C44 40.57 37.14 41.43 32 32" fill="none" stroke="#ffffff" stroke-width="10.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32C26.86 22.57 20 23.43 20 32C20 40.57 26.86 41.43 32 32C37.14 22.57 44 23.43 44 32C44 40.57 37.14 41.43 32 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32C26.86 22.57 20 23.43 20 32C20 40.57 26.86 41.43 32 32C37.14 22.57 44 23.43 44 32C44 40.57 37.14 41.43 32 32" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 929 B After Width: | Height: | Size: 934 B |
@@ -13,5 +13,5 @@
|
||||
<path d="M26.03 34.5L17.85 53.09" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M15.03 59.5L13.97 47.02L24.95 51.85Z" fill="#236ead"/>
|
||||
<circle cx="26.03" cy="34.5" r="9" fill="#ffffff"/>
|
||||
<circle cx="26.03" cy="34.5" r="6.3" fill="#34465b"/>
|
||||
<circle cx="26.03" cy="34.5" r="6.3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -14,6 +14,6 @@
|
||||
<path d="M44.97 12.22L40.5 14.72L41.55 8.4Z" fill="#236ead" />
|
||||
<path d="M30.8 33.02L30.38 32.94L29.95 32.91L29.51 32.9L29.05 32.94L28.59 33.01L28.11 33.12L27.63 33.27L27.14 33.45L26.64 33.67L26.15 33.93L25.65 34.21L25.16 34.53L24.66 34.88L24.17 35.26L23.69 35.68L23.21 36.12L22.75 36.58L22.29 37.07L21.85 37.59L21.42 38.12L21 38.68L20.6 39.25L20.22 39.84L19.86 40.44L19.52 41.05L19.2 41.68L18.91 42.31L18.63 42.94L18.38 43.58L18.16 44.23L17.96 44.87L17.8 45.5L17.65 46.13L17.54 46.76L17.45 47.37L17.39 47.98L17.37 48.57L17.37 49.14L17.39 49.69L17.45 50.23L17.54 50.75L17.65 51.24L17.8 51.71L17.96 52.15L18.16 52.56L18.38 52.94L18.63 53.3L18.91 53.62" fill="none" stroke="#ffffff" stroke-width="4.8" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<path d="M20.23 55.13L15.18 54.22L20 50.01Z" fill="#ffffff" stroke="#ffffff" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<path d="M30.8 33.02L30.38 32.94L29.95 32.91L29.51 32.9L29.05 32.94L28.59 33.01L28.11 33.12L27.63 33.27L27.14 33.45L26.64 33.67L26.15 33.93L25.65 34.21L25.16 34.53L24.66 34.88L24.17 35.26L23.69 35.68L23.21 36.12L22.75 36.58L22.29 37.07L21.85 37.59L21.42 38.12L21 38.68L20.6 39.25L20.22 39.84L19.86 40.44L19.52 41.05L19.2 41.68L18.91 42.31L18.63 42.94L18.38 43.58L18.16 44.23L17.96 44.87L17.8 45.5L17.65 46.13L17.54 46.76L17.45 47.37L17.39 47.98L17.37 48.57L17.37 49.14L17.39 49.69L17.45 50.23L17.54 50.75L17.65 51.24L17.8 51.71L17.96 52.15L18.16 52.56L18.38 52.94L18.63 53.3L18.91 53.62" fill="none" stroke="#34465b" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<path d="M20.23 55.13L15.18 54.22L20 50.01Z" fill="#34465b" />
|
||||
<path d="M30.8 33.02L30.38 32.94L29.95 32.91L29.51 32.9L29.05 32.94L28.59 33.01L28.11 33.12L27.63 33.27L27.14 33.45L26.64 33.67L26.15 33.93L25.65 34.21L25.16 34.53L24.66 34.88L24.17 35.26L23.69 35.68L23.21 36.12L22.75 36.58L22.29 37.07L21.85 37.59L21.42 38.12L21 38.68L20.6 39.25L20.22 39.84L19.86 40.44L19.52 41.05L19.2 41.68L18.91 42.31L18.63 42.94L18.38 43.58L18.16 44.23L17.96 44.87L17.8 45.5L17.65 46.13L17.54 46.76L17.45 47.37L17.39 47.98L17.37 48.57L17.37 49.14L17.39 49.69L17.45 50.23L17.54 50.75L17.65 51.24L17.8 51.71L17.96 52.15L18.16 52.56L18.38 52.94L18.63 53.3L18.91 53.62" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<path d="M20.23 55.13L15.18 54.22L20 50.01Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Normal line</title>
|
||||
<path d="M11 53L53 11" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M11 53L53 11" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 220 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Symmetric offset</title>
|
||||
<path d="M8 56L8 40A32 32 0 0 1 40 8L56 8" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M20 56L20 40A20 20 0 0 1 40 20L56 20" fill="none" stroke="#8e9faf" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 56L32 40A8 8 0 0 1 40 32L56 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 537 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Two independent offset sides</title>
|
||||
<path d="M8 56L8 40A32 32 0 0 1 40 8L56 8" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M24 56L24 40A16 16 0 0 1 40 24L56 24" fill="none" stroke="#8e9faf" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M34 56L34 40A6 6 0 0 1 40 34L56 34" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -2,10 +2,10 @@
|
||||
<title>Origin</title>
|
||||
<path d="M32 39L32 19" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 11L37 20L27 20Z" fill="#236ead"/>
|
||||
<path d="M32 39L49.09 48.97" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M56 53L45.71 52.78L50.75 44.15Z" fill="#34465b"/>
|
||||
<path d="M32 39L14.91 48.97" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8 53L13.25 44.15L18.29 52.78Z" fill="#34465b"/>
|
||||
<path d="M32 39L49.09 48.97" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M56 53L45.71 52.78L50.75 44.15Z" fill="currentColor"/>
|
||||
<path d="M32 39L14.91 48.97" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8 53L13.25 44.15L18.29 52.78Z" fill="currentColor"/>
|
||||
<path d="M26.3 35.71L32 39L32 45.58L26.3 42.29Z" fill="#b8c3cf"/>
|
||||
<path d="M37.7 35.71L32 39L32 45.58L37.7 42.29Z" fill="#8e9faf"/>
|
||||
<path d="M32 32.42L37.7 35.71L32 39L26.3 35.71Z" fill="#e2e8ef"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Plane along path</title>
|
||||
<path d="M7 48C18 48 19 32 32 32C45 32 46 16 57 16" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7 48C18 48 19 32 32 32C45 32 46 16 57 16" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 13L42 25L42 51L22 39Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
|
||||
<path d="M32 32C45 32 46 16 57 16" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32C45 32 46 16 57 16" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 551 B After Width: | Height: | Size: 561 B |
@@ -3,5 +3,5 @@
|
||||
<path d="M28.71 27.48L56.68 43.63L35.29 55.98L7.32 39.84Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18.01 33.66L45.99 49.81L45.99 24.16L18.01 8.01Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
|
||||
<path d="M25.42 45.53L25.42 37.94L32 34.13" fill="none" stroke="#ffffff" stroke-width="4.05" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M25.42 45.53L25.42 37.94L32 34.13" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M25.42 45.53L25.42 37.94L32 34.13" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 747 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Plane through two edges</title>
|
||||
<path d="M25.9 17.92L56.39 35.52L38.1 46.08L7.61 28.48Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
|
||||
<path d="M18.28 17.92L56.39 39.92" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7.61 24.08L45.72 46.08" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18.28 17.92L56.39 39.92" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7.61 24.08L45.72 46.08" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 561 B After Width: | Height: | Size: 571 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Point along path</title>
|
||||
<path d="M8 48.8C16 48.8 17 19.8 32 19.8C47 19.8 48 44.8 56 44.8" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8 48.8C16 48.8 17 19.8 32 19.8C47 19.8 48 44.8 56 44.8" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="19.8" r="6.2" fill="#ffffff"/>
|
||||
<circle cx="32" cy="19.8" r="4.5" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 373 B After Width: | Height: | Size: 378 B |
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Point at edge and plane</title>
|
||||
<path d="M22 6.55L40 58.55" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 6.55L40 58.55" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M29.66 21.6L56.16 36.9L34.34 49.5L7.84 34.2Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 6.55L32 35.44" fill="none" stroke="#ffffff" stroke-width="5.4" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 6.55L32 35.44" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 6.55L32 35.44" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="35.44" r="6.2" fill="#ffffff"/>
|
||||
<circle cx="32" cy="35.44" r="4.5" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 756 B After Width: | Height: | Size: 766 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Point through two edges</title>
|
||||
<path d="M9 39L55 25M15 13L49 51" fill="none" stroke="#34465b" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M9 39L55 25M15 13L49 51" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="6.2" fill="#ffffff"/>
|
||||
<circle cx="32" cy="32" r="4.5" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 349 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Select</title>
|
||||
<path d="M18 7L18 50L28.5 40.5L35.5 56L43 52.5L36 37.5L50 37.5Z" fill="#34465b" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18 7L18 50L28.5 40.5L35.5 56L43 52.5L36 37.5L50 37.5Z" fill="currentColor" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 260 B After Width: | Height: | Size: 270 B |
@@ -6,7 +6,7 @@
|
||||
<path d="M27.98 26L58.77 43.77L58.77 46.86L36.02 60L5.23 42.23L5.23 39.14Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M5.23 39.14L36.02 56.91L58.77 43.77" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M36.02 56.91L36.02 60" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L54 12" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L54 12" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M14 12L14 20" fill="none" stroke="#236ead" stroke-width="2.97" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22 12L22 17" fill="none" stroke="#236ead" stroke-width="2.97" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M30 12L30 20" fill="none" stroke="#236ead" stroke-width="2.97" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Silhouette</title>
|
||||
<path d="M8.62 18.5L32 32L32 59L8.62 45.5Z" fill="#b8c3cf"/>
|
||||
<path d="M55.38 18.5L32 32L32 59L55.38 45.5Z" fill="#8e9faf"/>
|
||||
<path d="M32 5L55.38 18.5L32 32L8.62 18.5Z" fill="#e2e8ef"/>
|
||||
<path d="M8.62 18.5L32 32L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#ffffff" stroke-width="7.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 878 B |
@@ -2,7 +2,7 @@
|
||||
<title>Distance and angle sketch chamfer</title>
|
||||
<path d="M12 36L12 12L42 12" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 54L12 36L42 12L54 12" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 25A11 11 0 0 1 20.59 29.13" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 25A11 11 0 0 1 20.59 29.13" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="54" r="3.07" fill="#236ead"/>
|
||||
<circle cx="54" cy="12" r="3.07" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 628 B After Width: | Height: | Size: 633 B |
@@ -2,7 +2,7 @@
|
||||
<title>Two distance sketch chamfer</title>
|
||||
<path d="M12 43L12 12L31 12" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 54L12 43L31 12L54 12" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18 9L18 15M9 25L15 25M9 31L15 31" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M18 9L18 15M9 25L15 25M9 31L15 31" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="54" r="3.07" fill="#236ead"/>
|
||||
<circle cx="54" cy="12" r="3.07" fill="#236ead"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 625 B After Width: | Height: | Size: 630 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Suppress</title>
|
||||
<path d="M4 32C16 14 48 14 60 32C48 50 16 50 4 32Z" fill="#e2e8ef"/>
|
||||
<path d="M4 32C16 14 48 14 60 32C48 50 16 50 4 32Z" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23 32A9 9 0 1 0 41 32A9 9 0 1 0 23 32Z" fill="#b8c3cf"/>
|
||||
<path d="M23 32A9 9 0 1 0 41 32A9 9 0 1 0 23 32Z" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 54L54 10" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 659 B |
@@ -2,8 +2,8 @@
|
||||
<title>Thicken</title>
|
||||
<path d="M32 40L32 53" fill="none" stroke="#ffffff" stroke-width="6.45" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 60L27 51.5L37 51.5Z" fill="#ffffff" stroke="#ffffff" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 40L32 53" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 60L27 51.5L37 51.5Z" fill="#34465b"/>
|
||||
<path d="M32 40L32 53" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 60L27 51.5L37 51.5Z" fill="currentColor"/>
|
||||
<path d="M7.75 23L32 37L32 53L7.75 39Z" fill="#328bd3"/>
|
||||
<path d="M56.25 23L32 37L32 53L56.25 39Z" fill="#236ead"/>
|
||||
<path d="M32 9L56.25 23L32 37L7.75 23Z" fill="#b9ddf8"/>
|
||||
@@ -11,9 +11,9 @@
|
||||
<path d="M7.75 23L32 37L56.25 23" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 37L32 53" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7.75 31L32 45L56.25 31" fill="none" stroke="#ffffff" stroke-width="4.75" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7.75 31L32 45L56.25 31" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M7.75 31L32 45L56.25 31" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 20.88L32 11" fill="none" stroke="#ffffff" stroke-width="6.45" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 4L37 12.5L27 12.5Z" fill="#ffffff" stroke="#ffffff" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 20.88L32 11" fill="none" stroke="#34465b" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 4L37 12.5L27 12.5Z" fill="#34465b"/>
|
||||
<path d="M32 20.88L32 11" fill="none" stroke="currentColor" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 4L37 12.5L27 12.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |