Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a06bdf5cfc | |||
| 030187452a | |||
| 198fbeb814 | |||
| 186096cbe6 | |||
| 3b982ca371 | |||
| aaa110218b | |||
| 46f80b769f | |||
| 26ca82735b | |||
| 408d30ea56 | |||
| 75261cd797 | |||
| 6f5d0038ab | |||
| 1814adb06a | |||
| 971b28c982 | |||
| f79cbc216c | |||
| 4dc1828138 | |||
| f843a3f83c | |||
| f0fd5c10b7 | |||
| 981d23d1d2 | |||
| 01648da72d | |||
| 3d3dd27439 | |||
| 6c60f1a9ed | |||
| 33fdcdb3a0 | |||
| 5899e5b6c5 | |||
| e23a5bf243 | |||
| 4b23bb3a03 | |||
| 920398ac53 | |||
| 688124937d | |||
| b49557c294 | |||
| dfd58293a6 | |||
| ba4f7864ff | |||
| b289dc2421 | |||
| 2daf22923c | |||
| 2fdc105d87 | |||
| 95b23f28d6 | |||
| 14b27f323a | |||
| 4ab3a85323 | |||
| c54b711345 | |||
| 84ec512d7a | |||
| 17ece2edc0 | |||
| 63f210b49f | |||
| 9acff62422 | |||
| 915ee8a178 | |||
| ac1768e08b | |||
| e1daf0a8b6 | |||
| 57c3e94df6 | |||
| 5e05d925e0 | |||
| 50313d147f | |||
| c07ab5f8f9 | |||
| 728f753b80 | |||
| f22a026c27 | |||
| 4b37c046e4 | |||
| 54072b7014 | |||
| cb3cabeda1 | |||
| 775e5155bb | |||
| e570c0b4eb | |||
| d9059d8da5 | |||
| 463cea0615 | |||
| 0225e4d2a5 | |||
| 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 | |||
| 23b0120759 | |||
| 76b17f2663 | |||
| 83790139b1 | |||
| 7bda28e786 | |||
| 2f5f65d6e3 | |||
| 9e78e86b92 |
@@ -1,6 +1,5 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
|
||||
# (.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 twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
@@ -34,12 +33,14 @@ 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
|
||||
|
||||
@@ -7,4 +7,5 @@ self-hosted-runner:
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
# 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 once and run in shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
|
||||
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
|
||||
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
|
||||
# main saves the build caches; pull requests restore them. On pull requests the test build and the
|
||||
# shards start only after the format and repository checks pass.
|
||||
# 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.
|
||||
@@ -29,14 +30,13 @@
|
||||
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
|
||||
# per job: 16 vCPU for Build tests, Clippy and the merge queue's test shards, 8 for compiles, the
|
||||
# other test shards, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# 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. The tests are built once (Build tests) into an archive the shards
|
||||
# run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
|
||||
# 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
|
||||
|
||||
@@ -178,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-*|tests/robustness/*) 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.
|
||||
@@ -277,6 +280,10 @@ jobs:
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Universal UI containers
|
||||
run: |
|
||||
cargo xtask ui-containers
|
||||
cargo test -p xtask ui_containers --locked
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Design format pending changes
|
||||
@@ -287,8 +294,8 @@ jobs:
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
- 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:
|
||||
@@ -337,25 +344,33 @@ jobs:
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
# Fail fast on pull requests: the test archive (and so the shards) waits for the repository
|
||||
# checks, the most common cheap failure; heavy jobs used to run on to the end beside a failed
|
||||
# format check. Main, nightly and Run workflow build regardless, so the action matrix and the
|
||||
# property tests that hang off this job keep their signal.
|
||||
needs: [changes, checks]
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') }}
|
||||
# The archive is for the action matrix and the property tests, which run only nightly and by
|
||||
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
# Fork builds start from main's cache, read-only; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_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
|
||||
@@ -387,63 +402,94 @@ jobs:
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, checks, build-tests]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') && needs.build-tests.result == 'success' }}
|
||||
runs-on: ${{ github.event_name == 'merge_group' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
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)
|
||||
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
|
||||
# Each shard runs the archive the Build tests job made.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GOLDENS: ${{ needs.changes.outputs.goldens }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
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).
|
||||
filter=()
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
|
||||
# below is that filter, so a pull request still runs only what it ran before.
|
||||
base='default()'
|
||||
if [ "$EVENT_NAME" = pull_request ]; then
|
||||
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
|
||||
# main and nightly, not on every pull request push.
|
||||
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
|
||||
fi
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
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.
|
||||
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
|
||||
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"
|
||||
# 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.
|
||||
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
|
||||
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
|
||||
# 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:"
|
||||
@@ -451,11 +497,14 @@ jobs:
|
||||
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 }}
|
||||
name: junit
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
@@ -560,13 +609,23 @@ jobs:
|
||||
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-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:
|
||||
@@ -608,13 +667,23 @@ jobs:
|
||||
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-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:
|
||||
@@ -746,6 +815,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -760,7 +830,10 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
@@ -33,9 +33,11 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
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
|
||||
records crashes inside native code such as graphics drivers), the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
test of the window without OS decorations) and the Windows-only
|
||||
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
|
||||
mice, so no other device is sent anything).
|
||||
- **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
|
||||
@@ -88,6 +90,14 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **One outer UI container**: all panels, floating sheets, menus, overlays and platform UI
|
||||
windows use `ui_kit::Container` (directly or through `Panel`, `Window`, `Confirm`, `Menu`).
|
||||
Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
|
||||
System/report windows use `ui_kit::Window`; its low-level host builder is kit-private.
|
||||
Do not construct raw egui hosts, outer popup/window frames or shadows in feature modules.
|
||||
`cargo xtask ui-containers` recursively checks source/examples in all current and future crates
|
||||
and apps. It runs in the fast repository CI job and `cargo xtask ci`, with no caller allowances.
|
||||
See `docs/ui-container-audit.md` for the inventory and legitimate content/native exceptions.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
|
||||
@@ -52,9 +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 |
|
||||
@@ -75,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 |
|
||||
@@ -98,21 +118,28 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| 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 |
|
||||
@@ -129,6 +156,20 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/platinum-9.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/cupertino.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/green-phosphor.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/amber-phosphor.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/blueprint.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/one-dark.json | SolidCraft contributors | original SolidCraft theme file with colours adapted from the One Dark palette (Atom One Dark by GitHub Atom, https://github.com/atom/atom, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/tokyo-night.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Tokyo Night by enkia, https://github.com/enkia/tokyo-night-vscode-theme, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solarized-light.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Solarized by Ethan Schoonover, https://github.com/altercation/solarized, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/nord.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Nord by Sven Greb and the Nord contributors, https://github.com/nordtheme/nord, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/high-contrast.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/graphite.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/nosferatu.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Dracula by Zeno Rocha and the Dracula Theme contributors, https://github.com/dracula/dracula-theme, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/catppuccin-mocha.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Catppuccin by the Catppuccin organisation, https://github.com/catppuccin/catppuccin, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
@@ -137,6 +178,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 |
|
||||
@@ -145,11 +190,17 @@ 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 |
|
||||
| assets/icons/ui/design/svg/flat/save.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_keyboard.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_monitor.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_mouse.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_palette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/settings_plug.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/spin.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/style.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/undo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -230,7 +281,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 |
|
||||
@@ -281,6 +332,16 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/assistant.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/agent_link.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_two_sides.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/offset_symmetric.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/import_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/export_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/send.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/stop.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/shield.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/status_recovery.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/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 |
|
||||
@@ -332,7 +393,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 |
|
||||
@@ -416,6 +477,7 @@ 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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -199,7 +208,7 @@ 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 but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
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
|
||||
|
||||
@@ -4162,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4193,13 +4193,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
@@ -4208,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4217,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4234,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4262,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4279,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4302,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4322,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4331,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4345,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4356,14 +4357,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4372,6 +4373,7 @@ dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"pollster 0.4.0",
|
||||
"proc-macro2",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4379,6 +4381,7 @@ dependencies = [
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"syn 2.0.119",
|
||||
"tiny-skia",
|
||||
"toml",
|
||||
"ttf-parser",
|
||||
@@ -4389,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5919,10 +5922,12 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
<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).*
|
||||
|
||||
@@ -50,12 +50,12 @@ 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; 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 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
@@ -70,7 +70,12 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current
|
||||
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
|
||||
action tiles, recent rows and sample cards, with recovery review and Save As/Export
|
||||
entry points. Document loading, sample caches and recovery safety retain their
|
||||
existing command paths.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
@@ -329,8 +334,25 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## Workspace layouts (#2029)
|
||||
|
||||
- General preferences save Floating or Compact using the universal UI containers.
|
||||
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
|
||||
flush beneath them with a square join, and opens tool sheets below the compass.
|
||||
Sheets remain draggable, rounding all corners away from their right dock; footer
|
||||
backgrounds follow the same corners. A separate preference hides ribbon icon labels
|
||||
for a shorter ribbon in either layout.
|
||||
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
|
||||
retains the active tool, camera, selection and design; system windows and menus
|
||||
keep their existing behavior.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
|
||||
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
|
||||
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
|
||||
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
|
||||
@@ -16144,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,7 +4,7 @@
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] 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]
|
||||
@@ -17,6 +17,8 @@
|
||||
//! 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)]
|
||||
@@ -24,6 +26,7 @@
|
||||
mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
@@ -50,7 +53,9 @@ 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] [--save OUT.solidcraft] [--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]
|
||||
@@ -80,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..]),
|
||||
@@ -132,6 +140,12 @@ fn main() -> ExitCode {
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
// Asked-for help is not an error: usage on stdout, exit 0.
|
||||
Some("--help" | "-h" | "help") => {
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(USAGE.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
};
|
||||
match r {
|
||||
@@ -642,6 +656,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]")?;
|
||||
@@ -727,6 +771,10 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
|
||||
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
|
||||
// evaluation and the plans of the bodies it changed.
|
||||
let s = match flag("--ops").and_then(|i| args.get(i + 1)) {
|
||||
Some(ops) => bench_ops(s, ops)?,
|
||||
None => s,
|
||||
};
|
||||
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
|
||||
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
|
||||
let plans = |st: &solidcraft_engine::doc::ModelState| {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! An explicit help request prints usage to stdout and succeeds; an unknown command still fails.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_succeeds() {
|
||||
for arg in ["--help", "-h", "help"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg(arg).output().unwrap();
|
||||
assert!(output.status.success(), "{arg}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.starts_with("usage:"), "{arg}: {stdout}");
|
||||
assert!(stdout.contains("solidcraft-cli run"), "{arg}: {stdout}");
|
||||
assert!(output.stderr.is_empty(), "{arg}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_still_fails_with_usage() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--not-a-command").output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).starts_with("error: usage:"));
|
||||
}
|
||||
@@ -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")]
|
||||
@@ -21,10 +23,11 @@ mod web {
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::disk::{self, SaveTarget};
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
@@ -121,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);
|
||||
}
|
||||
@@ -188,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) {
|
||||
@@ -196,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.
|
||||
@@ -203,27 +229,27 @@ mod web {
|
||||
if !self.files.panel.get() {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
let mut act: Option<(&'static str, String)> = None;
|
||||
egui::Window::new("Files in this browser")
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_width(520.0)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
|
||||
.cards(3.0)
|
||||
.show_body(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Open from computer…").clicked() {
|
||||
if ui_kit::button(ui, "Open from computer…", ui_kit::Button::Normal).clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.on_hover_text("STEP, 3MF or STL into the open design")
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
}
|
||||
if ui.button("Download open design").clicked() {
|
||||
if ui_kit::button(ui, "Download open design", ui_kit::Button::Normal).clicked() {
|
||||
act = Some(("download_open", String::new()));
|
||||
}
|
||||
});
|
||||
if !disk::supported() {
|
||||
ui.weak("This browser can't save to a folder: designs stay in this browser. Download design saves a file.");
|
||||
}
|
||||
ui.separator();
|
||||
if !self.files.ready.get() {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -241,7 +267,7 @@ mod web {
|
||||
for e in &entries {
|
||||
match &mut self.renaming {
|
||||
Some((from, to)) if *from == e.name => {
|
||||
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
|
||||
let r = ui_kit::cell_editor(ui, ui.make_persistent_id(("stored_file_name", from.as_str())), to, "", false);
|
||||
if std::mem::take(&mut self.rename_focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -259,21 +285,31 @@ mod web {
|
||||
}
|
||||
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Open").clicked() {
|
||||
if ui_kit::button(ui, "Open", ui_kit::Button::Small).clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.on_hover_text("Into the open design (STEP, 3MF, STL)")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Rename").clicked() {
|
||||
if ui_kit::button(ui, "Rename", ui_kit::Button::Small).clicked() {
|
||||
self.renaming = Some((e.name.clone(), e.name.clone()));
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui.small_button("Download").clicked() {
|
||||
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
|
||||
.on_hover_text("A copy in a file you choose; the stored design stays here")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("save_copy", e.name.clone()));
|
||||
}
|
||||
if ui_kit::button(ui, "Download", ui_kit::Button::Small).clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
if ui_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
|
||||
act = Some(("delete", e.name.clone()));
|
||||
}
|
||||
});
|
||||
@@ -282,7 +318,7 @@ mod web {
|
||||
});
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
if closed {
|
||||
self.files.panel.set(false);
|
||||
self.renaming = None;
|
||||
}
|
||||
@@ -305,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
|
||||
@@ -346,20 +386,19 @@ mod web {
|
||||
fn recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
|
||||
let mut answer = None;
|
||||
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_recovery"), "restore", "Recover unsaved work?")
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Recover").clicked() {
|
||||
if ui_kit::button(ui, "Recover", ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
if ui_kit::button(ui, "Discard", ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
let Some(recover) = answer else { return };
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
if recover {
|
||||
@@ -533,18 +572,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()
|
||||
};
|
||||
|
||||
@@ -50,8 +50,13 @@ hidapi = { version = "2.6.7", default-features = false, features = ["windows-nat
|
||||
raw-window-handle = "0.6"
|
||||
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
|
||||
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
|
||||
# Finding 3D mice through Plug and Play without asking other devices anything (src/space_mouse/windows_hid.rs).
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Devices_DeviceAndDriverInstallation",
|
||||
"Win32_Devices_HumanInterfaceDevice",
|
||||
"Win32_Devices_Properties",
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,16 +28,54 @@ mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
mod space_mouse;
|
||||
mod startup;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod window_fit;
|
||||
|
||||
use solidcraft_engine::{Session, io};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
/// Windows: put the restored window back inside the monitor's work area if it is out of reach
|
||||
/// (a gone monitor, another scale, a taskbar edge). Sizes are in points, the work area in pixels.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn fit_window_to_work_area(ctx: &egui::Context, frame: &eframe::Frame) {
|
||||
use window_fit::Area;
|
||||
let Some((x, y, w, h)) = native_frame::work_area(frame) else { return };
|
||||
let (outer, inner, scale, maximized) = ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
(v.outer_rect, v.inner_rect, v.native_pixels_per_point, v.maximized.unwrap_or(false) || v.fullscreen.unwrap_or(false))
|
||||
});
|
||||
let (Some(outer), Some(inner), Some(scale)) = (outer, inner, scale.filter(|s| s.is_finite() && *s > 0.0)) else { return };
|
||||
if maximized {
|
||||
return;
|
||||
}
|
||||
let work = Area::new(x as f32 / scale, y as f32 / scale, w as f32 / scale, h as f32 / scale);
|
||||
let now = Area::new(outer.min.x, outer.min.y, outer.width(), outer.height());
|
||||
let fitted = window_fit::fit(now, work, (960.0, 600.0));
|
||||
if fitted != now {
|
||||
// The frame (title bar, borders) keeps its size; only the inner size follows.
|
||||
let inner_size = egui::vec2((inner.width() - (now.w - fitted.w)).max(1.0), (inner.height() - (now.h - fitted.h)).max(1.0));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner_size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(fitted.x, fitted.y)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `SOLIDCRAFT_PROFILE_STARTUP=1` prints the time since launch at each startup step.
|
||||
fn mark(step: &str) {
|
||||
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
|
||||
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
|
||||
if *on {
|
||||
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
/// The settings folder whose start marker the first frame clears (`startup::started`).
|
||||
starting: Option<std::path::PathBuf>,
|
||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||
frames: u32,
|
||||
/// Started over the control channel: the window doesn't ask for the foreground.
|
||||
take_focus_unwanted: bool,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
@@ -46,9 +84,23 @@ impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.frames < 2 {
|
||||
self.frames += 1;
|
||||
// Before the window is shown: its title bar must be within reach (window_fit.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
if self.frames == 1 {
|
||||
fit_window_to_work_area(ctx, _frame);
|
||||
}
|
||||
mark(if self.frames == 1 { "first frame begins" } else { "second frame begins (first presented)" });
|
||||
// A second frame right away, which means the first was presented: the app is up.
|
||||
ctx.request_repaint();
|
||||
if self.frames == 2 {
|
||||
// The window is shown only once its first frame is presented, and Windows then
|
||||
// keeps a window of a process started from a shortcut or an installer behind the
|
||||
// others. Ask for the foreground once, now that it is on screen; never again, so
|
||||
// later frames don't take the focus from whatever the user moved to. Automation
|
||||
// over the control channel keeps the focus where it is.
|
||||
if !self.take_focus_unwanted {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
|
||||
if let Some(dir) = self.starting.take() {
|
||||
startup::started(&dir);
|
||||
@@ -210,6 +262,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| {
|
||||
@@ -473,6 +529,7 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
mark("main");
|
||||
adopt_legacy_settings();
|
||||
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
||||
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
||||
@@ -583,13 +640,17 @@ fn main() -> eframe::Result {
|
||||
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_app = created.clone();
|
||||
let starting = settings.clone();
|
||||
mark("run_native");
|
||||
let result = eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
solidcraft_engine::crash::phase("app setup");
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
mark("window and device ready");
|
||||
solidcraft_ui_egui::fonts::setup_bundled_fonts(&cc.egui_ctx);
|
||||
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
|
||||
mark("fonts");
|
||||
let mut app = SolveApp::new(Session::default(), services(cc));
|
||||
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
@@ -607,6 +668,7 @@ fn main() -> eframe::Result {
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
mark("app state and prefs");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
@@ -624,6 +686,7 @@ fn main() -> eframe::Result {
|
||||
info.backend, info.name, info.driver, rs.msaa_samples
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -676,11 +739,13 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
mark("app created");
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
take_focus_unwanted: control_port.is_some(),
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
}))
|
||||
|
||||
@@ -26,6 +26,7 @@ use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use solidcraft_ui_egui::titlebar;
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
|
||||
use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow};
|
||||
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
|
||||
@@ -33,6 +34,26 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
|
||||
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
|
||||
/// pixels: left, top, width, height.
|
||||
pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)> {
|
||||
let handle = window.window_handle().ok()?;
|
||||
let RawWindowHandle::Win32(w) = handle.as_raw() else { return None };
|
||||
let hwnd = w.hwnd.get() as HWND;
|
||||
// SAFETY: `hwnd` is the live main window; `info` is a MONITORINFO with its size set, as
|
||||
// GetMonitorInfoW requires, and the monitor handle comes from the call before it.
|
||||
let rc = unsafe {
|
||||
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
let mut info: MONITORINFO = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||
if monitor.is_null() || GetMonitorInfoW(monitor, &mut info) == 0 {
|
||||
return None;
|
||||
}
|
||||
info.rcWork
|
||||
};
|
||||
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
|
||||
}
|
||||
|
||||
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
|
||||
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
|
||||
@@ -7,21 +7,42 @@
|
||||
mod hidraw;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spacenavd;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_hid;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use std::ffi::CString;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
#[cfg(target_os = "macos")]
|
||||
use hidapi::DeviceInfo;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use hidapi::HidApi;
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_space_mouse::Layout;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
#[cfg(target_os = "linux")]
|
||||
use solidcraft_ui_egui::prefs::SpaceMouseDriver;
|
||||
#[cfg(target_os = "linux")]
|
||||
use solidcraft_ui_egui::space_mouse::DriversPresent;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// How long a held (spacenavd) reading that is nearly at rest may go without a new record before
|
||||
/// it reads as rest: spacenavd sends no final record when the cap springs back, only the last
|
||||
/// small leftover. Longer than `STALE_AFTER` so a light, steady hold gets a moment before it ends.
|
||||
const HELD_SETTLE_AFTER: Duration = Duration::from_millis(500);
|
||||
/// How far from rest (raw units, full deflection is 350) every axis of a held reading must be for
|
||||
/// it to settle. The dead zone is 20, but the last records of real releases reach 129 on one axis
|
||||
/// (tangency-labs/solidcraft#3523), so this sits well above it. A firmer push is a deliberate
|
||||
/// hold and stays.
|
||||
const HELD_SETTLE_BAND: u16 = 150;
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
/// the HID device would fail or report it twice.
|
||||
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
|
||||
@@ -199,9 +220,11 @@ struct Reading {
|
||||
/// 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.
|
||||
at: [Instant; 6],
|
||||
/// The source says when the cap is back at rest, so a reading stays until the next one.
|
||||
/// A HID device repeats its report while the cap is held; spacenavd sends a record only when
|
||||
/// the values change, and a steady hold can go seconds without one.
|
||||
/// The source sends a record only when the values change, so a reading stays until the next
|
||||
/// one: a steady hold can go seconds without one. It never says when the cap springs back,
|
||||
/// though, so a reading within `HELD_SETTLE_BAND` of rest on every axis settles after
|
||||
/// `HELD_SETTLE_AFTER`. A HID device repeats its report while the cap is held, so it ages out
|
||||
/// per axis instead.
|
||||
held: bool,
|
||||
}
|
||||
|
||||
@@ -214,7 +237,14 @@ impl Reading {
|
||||
/// 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 {
|
||||
if self.held {
|
||||
let values = axes.translation.into_iter().chain(axes.rotation);
|
||||
let rests = rest.translation.into_iter().chain(rest.rotation);
|
||||
let near_rest = values.zip(rests).all(|(value, rest)| value.abs_diff(rest) <= HELD_SETTLE_BAND);
|
||||
if near_rest && now.duration_since(self.at[0]) >= HELD_SETTLE_AFTER {
|
||||
return rest;
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
@@ -232,8 +262,9 @@ fn at_rest(calibration: Option<Calibration>) -> Axes {
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice. (Windows asks Plug
|
||||
/// and Play for the same usage: `windows_hid`.)
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
@@ -293,18 +324,48 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The HID interfaces that are 3D mice, by path. Windows asks Plug and Play, which sends nothing to
|
||||
/// the devices: hidapi's enumeration would ask every keyboard and mouse for its strings on each scan
|
||||
/// (#2614, `windows_hid`).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn candidates(_api: &mut HidApi) -> Vec<CString> {
|
||||
windows_hid::multi_axis_controllers()
|
||||
}
|
||||
|
||||
/// The HID interfaces that are 3D mice, by path, from hidapi's enumeration (IOKit's registry).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn candidates(api: &mut HidApi) -> Vec<CString> {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
return Vec::new();
|
||||
}
|
||||
api.device_list().filter(|info| is_3d_mouse(info)).map(|info| info.path().to_owned()).collect()
|
||||
}
|
||||
|
||||
/// The HID context. On Windows hidapi's own device discovery is off, so making it asks no device
|
||||
/// anything either (`candidates` finds the 3D mice). Turning discovery off panics only once a
|
||||
/// context with discovery exists; the reader is the one place the app makes a context, once.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn hid_api() -> hidapi::HidResult<HidApi> {
|
||||
HidApi::disable_device_discovery();
|
||||
HidApi::new()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn hid_api() -> hidapi::HidResult<HidApi> {
|
||||
HidApi::new()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
let Ok(mut api) = hid_api() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some((device, layout, ids)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
let device = info
|
||||
.open_device(&api)
|
||||
if let Some((device, layout, ids)) = candidates(&mut api).into_iter().find_map(|path| {
|
||||
let device = api
|
||||
.open_path(&path)
|
||||
.inspect_err(|error| {
|
||||
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
|
||||
// The driver having the device is the reason it cannot be opened, not a problem.
|
||||
@@ -313,6 +374,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
// Read when the device was opened, from this device alone.
|
||||
let info = device.get_device_info().ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout, (info.vendor_id(), info.product_id())))
|
||||
}) {
|
||||
@@ -359,39 +422,130 @@ fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse through spacenavd when it runs, so what `spnavcfg` sets (swapped and inverted
|
||||
/// axes, sensitivity, dead zones) applies here as in other programs. Without the daemon it reads
|
||||
/// `/dev/hidrawN` with plain blocking reads, which end with an error when the device is unplugged.
|
||||
/// Opening needs permission: most distributions grant it to the logged-in user for devices that
|
||||
/// carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
|
||||
/// What the user chose for the reader and what the reader found, shared with the egui thread.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
use std::io::Read;
|
||||
#[derive(Default)]
|
||||
struct Drivers {
|
||||
/// `SpaceMouseDriver::to_u8` of the preference.
|
||||
choice: AtomicU8,
|
||||
/// `PRESENT_SPACENAVD` and `PRESENT_DIRECT`, as of the reader's last look.
|
||||
found: AtomicU8,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PRESENT_SPACENAVD: u8 = 1;
|
||||
#[cfg(target_os = "linux")]
|
||||
const PRESENT_DIRECT: u8 = 2;
|
||||
/// How often a connected reader looks again at what is there, and how long a read of spacenavd
|
||||
/// waits for a record before it checks the choice.
|
||||
#[cfg(target_os = "linux")]
|
||||
const LOOK_EVERY: Duration = Duration::from_secs(1);
|
||||
#[cfg(target_os = "linux")]
|
||||
const SPACENAVD_WAKE: Duration = Duration::from_millis(250);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drivers {
|
||||
fn choice(&self) -> SpaceMouseDriver {
|
||||
SpaceMouseDriver::from_u8(self.choice.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn present(&self) -> DriversPresent {
|
||||
let found = self.found.load(Ordering::Relaxed);
|
||||
DriversPresent { spacenavd: found & PRESENT_SPACENAVD != 0, direct: found & PRESENT_DIRECT != 0 }
|
||||
}
|
||||
|
||||
fn publish(&self, present: DriversPresent) {
|
||||
let found = (u8::from(present.spacenavd) * PRESENT_SPACENAVD) | (u8::from(present.direct) * PRESENT_DIRECT);
|
||||
self.found.store(found, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reader that was picked.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Pick {
|
||||
Spacenavd,
|
||||
Direct,
|
||||
}
|
||||
|
||||
/// Which reader to use: the one asked for when it is there, else the other, else none. Automatic
|
||||
/// asks for spacenavd, which is what has always been read first.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn choose(wanted: SpaceMouseDriver, spacenavd: bool, direct: bool) -> Option<Pick> {
|
||||
let first = match wanted {
|
||||
SpaceMouseDriver::Automatic | SpaceMouseDriver::Spacenavd => Pick::Spacenavd,
|
||||
SpaceMouseDriver::Direct => Pick::Direct,
|
||||
};
|
||||
let there = |pick| match pick {
|
||||
Pick::Spacenavd => spacenavd,
|
||||
Pick::Direct => direct,
|
||||
};
|
||||
let other = if first == Pick::Spacenavd { Pick::Direct } else { Pick::Spacenavd };
|
||||
[first, other].into_iter().find(|&pick| there(pick))
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse through spacenavd or `/dev/hidrawN`, as `Drivers::choice` says (Automatic
|
||||
/// is spacenavd when it runs, so what `spnavcfg` sets, swapped and inverted axes, sensitivity,
|
||||
/// dead zones, applies here as in other programs). Both reads wake up when idle to see whether
|
||||
/// the choice changed, which then moves to the other reader without a restart. hidraw is opened
|
||||
/// non-blocking and ends with an error when the device is unplugged. Opening needs permission:
|
||||
/// most distributions grant it to the logged-in user for devices that carry the `uaccess` udev
|
||||
/// tag, which a rule for the 3D mouse has to add.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>, drivers: Arc<Drivers>) {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let look = || DriversPresent { spacenavd: spacenavd::exists(), direct: hidraw::find().is_some() };
|
||||
let mut denied = None;
|
||||
let mut told = None;
|
||||
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() {
|
||||
match std::fs::File::open(&device.path) {
|
||||
Ok(mut file) => {
|
||||
let device = hidraw::find();
|
||||
let present = DriversPresent { spacenavd: spacenavd::exists(), direct: device.is_some() };
|
||||
drivers.publish(present);
|
||||
let wanted = drivers.choice();
|
||||
let mut pick = choose(wanted, present.spacenavd, present.direct);
|
||||
// A socket file with no daemon behind it is no spacenavd.
|
||||
let daemon = if pick == Some(Pick::Spacenavd) { spacenavd::connect() } else { None };
|
||||
if pick == Some(Pick::Spacenavd) && daemon.is_none() {
|
||||
pick = choose(wanted, false, present.direct);
|
||||
}
|
||||
let asked_for = match wanted {
|
||||
SpaceMouseDriver::Automatic => None,
|
||||
SpaceMouseDriver::Spacenavd => Some(Pick::Spacenavd),
|
||||
SpaceMouseDriver::Direct => Some(Pick::Direct),
|
||||
};
|
||||
if let (Some(asked), Some(pick)) = (asked_for, pick)
|
||||
&& asked != pick
|
||||
&& told != Some((wanted, pick))
|
||||
{
|
||||
log::info!("3D mouse: {asked:?} is not available, reading {pick:?} instead");
|
||||
told = Some((wanted, pick));
|
||||
}
|
||||
// Asked at every idle moment of a read: has the choice changed, and what is there now.
|
||||
let mut looked = Instant::now();
|
||||
let mut stop = || {
|
||||
if looked.elapsed() >= LOOK_EVERY {
|
||||
looked = Instant::now();
|
||||
drivers.publish(look());
|
||||
}
|
||||
drivers.choice() != wanted
|
||||
};
|
||||
match (pick, daemon, device) {
|
||||
(Some(Pick::Spacenavd), Some(daemon), _) => {
|
||||
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
|
||||
if let Err(error) = daemon.set_read_timeout(Some(SPACENAVD_WAKE)) {
|
||||
log::warn!("3D mouse: spacenavd reads cannot time out ({error}); a change of driver waits for the next event");
|
||||
}
|
||||
connected(&ctx, &shared, Source::Spacenavd);
|
||||
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes), &mut stop);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
(Some(Pick::Direct), _, Some(device)) => match std::fs::OpenOptions::new().read(true).custom_flags(hidraw::O_NONBLOCK).open(&device.path)
|
||||
{
|
||||
Ok(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) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
hidraw::read_reports(file, |report| publish(&ctx, &shared, device.layout, report), &mut stop);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
// Say so once per device node, not every retry.
|
||||
@@ -400,9 +554,13 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
denied = Some(device.path);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
// A new choice is taken up at once; anything else waits to try again.
|
||||
if drivers.choice() == wanted {
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +603,8 @@ fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers: Arc<Drivers>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
@@ -462,13 +622,25 @@ impl SpaceMouse {
|
||||
let shared = Arc::new(Mutex::new(Reading::new(now)));
|
||||
let worker_state = Arc::clone(&shared);
|
||||
let worker_ctx = ctx.clone();
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let drivers = Arc::new(Drivers::default());
|
||||
#[cfg(target_os = "linux")]
|
||||
let worker_drivers = Arc::clone(&drivers);
|
||||
let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || {
|
||||
#[cfg(target_os = "linux")]
|
||||
reader(worker_ctx, worker_state, worker_drivers);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
reader(worker_ctx, worker_state);
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
start_driver_client(ctx, &shared);
|
||||
Self {
|
||||
shared,
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers,
|
||||
last_frame: now,
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(without_profile),
|
||||
mapping_checked_at: now,
|
||||
@@ -516,6 +688,11 @@ impl SpaceMouse {
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.drivers.choice.store(app.preferences.space_mouse_driver.to_u8(), Ordering::Relaxed);
|
||||
app.space_mouse.drivers = Some(self.drivers.present());
|
||||
}
|
||||
let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return };
|
||||
app.space_mouse.device = reading.device.map(Source::key);
|
||||
let calibration = calibration(app);
|
||||
@@ -529,11 +706,108 @@ impl SpaceMouse {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream that answers reads from a script, for the timeout paths.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod fake {
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{Error, ErrorKind, Read, Result};
|
||||
|
||||
enum Step {
|
||||
Data(Vec<u8>),
|
||||
Fail(ErrorKind),
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Script(VecDeque<Step>);
|
||||
|
||||
impl Script {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn data(&mut self, bytes: &[u8]) -> &mut Self {
|
||||
self.0.push_back(Step::Data(bytes.to_vec()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn timeout(&mut self, kind: ErrorKind) -> &mut Self {
|
||||
self.fail(kind)
|
||||
}
|
||||
|
||||
pub fn fail(&mut self, kind: ErrorKind) -> &mut Self {
|
||||
self.0.push_back(Step::Fail(kind));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn end(&mut self) -> &mut Self {
|
||||
self.0.push_back(Step::End);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the script's last step the stream has ended.
|
||||
impl Read for Script {
|
||||
fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
|
||||
match self.0.pop_front() {
|
||||
Some(Step::Data(bytes)) => {
|
||||
let size = bytes.len().min(buf.len());
|
||||
buf[..size].copy_from_slice(&bytes[..size]);
|
||||
Ok(size)
|
||||
}
|
||||
Some(Step::Fail(kind)) => Err(Error::from(kind)),
|
||||
Some(Step::End) | None => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_reader_asked_for_is_used_when_it_is_there_and_the_other_when_it_is_not() {
|
||||
use Pick::{Direct, Spacenavd};
|
||||
use SpaceMouseDriver::{Automatic, Direct as AskDirect, Spacenavd as AskSpacenavd};
|
||||
// (asked, spacenavd there, device there) and what is read.
|
||||
let cases = [
|
||||
(Automatic, true, true, Some(Spacenavd)),
|
||||
(Automatic, true, false, Some(Spacenavd)),
|
||||
(Automatic, false, true, Some(Direct)),
|
||||
(Automatic, false, false, None),
|
||||
(AskSpacenavd, true, true, Some(Spacenavd)),
|
||||
(AskSpacenavd, true, false, Some(Spacenavd)),
|
||||
(AskSpacenavd, false, true, Some(Direct)),
|
||||
(AskSpacenavd, false, false, None),
|
||||
(AskDirect, true, true, Some(Direct)),
|
||||
(AskDirect, true, false, Some(Spacenavd)),
|
||||
(AskDirect, false, true, Some(Direct)),
|
||||
(AskDirect, false, false, None),
|
||||
];
|
||||
for (asked, spacenavd, direct, expected) in cases {
|
||||
assert_eq!(choose(asked, spacenavd, direct), expected, "{asked:?}, spacenavd {spacenavd}, device {direct}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn what_the_reader_found_and_the_choice_pass_between_threads() {
|
||||
let drivers = Drivers::default();
|
||||
assert_eq!(drivers.present(), DriversPresent::default());
|
||||
for (spacenavd, direct) in [(true, false), (false, true), (true, true), (false, false)] {
|
||||
drivers.publish(DriversPresent { spacenavd, direct });
|
||||
assert_eq!(drivers.present(), DriversPresent { spacenavd, direct });
|
||||
}
|
||||
assert_eq!(drivers.choice(), SpaceMouseDriver::Automatic);
|
||||
for choice in SpaceMouseDriver::ALL {
|
||||
drivers.choice.store(choice.to_u8(), Ordering::Relaxed);
|
||||
assert_eq!(drivers.choice(), choice);
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
|
||||
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
|
||||
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
|
||||
@@ -625,6 +899,55 @@ mod tests {
|
||||
assert_eq!(hid.current(later, Axes::default()), Axes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_reading_near_rest_settles_after_the_timeout() {
|
||||
// The last records of seven releases from tangency-labs/solidcraft#3523: spacenavd sends
|
||||
// nothing after them.
|
||||
let leftovers = [
|
||||
[15, -60, 13, 59, 34, 129],
|
||||
[-12, -69, 4, -26, 7, -6],
|
||||
[0, -45, -3, 16, 8, -60],
|
||||
[-31, 27, 22, -5, 6, -10],
|
||||
[0, -30, -3, 3, -6, 0],
|
||||
[5, 32, -2, 43, -8, 12],
|
||||
[3, 2, -22, 3, 6, 5],
|
||||
];
|
||||
let now = Instant::now();
|
||||
for [x, y, z, rx, ry, rz] in leftovers {
|
||||
let axes = Axes { translation: [x, y, z], rotation: [rx, ry, rz] };
|
||||
let held = Reading { axes, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(now + HELD_SETTLE_AFTER - Duration::from_millis(1), Axes::default()), axes);
|
||||
assert_eq!(held.current(now + HELD_SETTLE_AFTER, Axes::default()), Axes::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settled_held_reading_reads_as_the_devices_own_rest() {
|
||||
let now = Instant::now();
|
||||
let rest = Axes { translation: [10, -8, 0], rotation: [0, 5, 0] };
|
||||
// 40 from zero on X, but only 30 from this device's rest.
|
||||
let held = Reading { axes: Axes { translation: [40, -8, 0], rotation: [0, 5, 0] }, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(now + HELD_SETTLE_AFTER, rest), rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_firm_held_push_stays_however_long_it_lasts() {
|
||||
let now = Instant::now();
|
||||
let later = now + Duration::from_secs(10);
|
||||
let push = Axes { translation: [0, 350, 0], rotation: [0; 3] };
|
||||
let held = Reading { axes: push, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later, Axes::default()), push);
|
||||
// One firm axis keeps the small ones with it.
|
||||
let mixed = Axes { translation: [5, 300, -10], rotation: [3, 0, 20] };
|
||||
let held = Reading { axes: mixed, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later, Axes::default()), mixed);
|
||||
// The band is per reading, edge included: at it is a leftover, one past it is a hold.
|
||||
let band = HELD_SETTLE_BAND as i16;
|
||||
let twist = |value| Reading { axes: Axes { translation: [0; 3], rotation: [0, 0, value] }, held: true, ..Reading::new(now) };
|
||||
assert_eq!(twist(band).current(later, Axes::default()), Axes::default());
|
||||
assert_eq!(twist(band + 1).current(later, Axes::default()).rotation, [0, 0, band + 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
@@ -689,6 +1012,8 @@ mod tests {
|
||||
reading.axes.rotation = [0, 350, 0];
|
||||
let mut mouse = SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers: Arc::default(),
|
||||
last_frame: now - Duration::from_millis(20),
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
@@ -717,6 +1042,8 @@ mod tests {
|
||||
fn mouse(reading: Reading, now: Instant) -> SpaceMouse {
|
||||
SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers: Arc::default(),
|
||||
last_frame: now,
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
//! 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::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use solidcraft_space_mouse::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
/// `O_NONBLOCK` on Linux's common architectures (x86, ARM, RISC-V). The device is opened with it so
|
||||
/// a read with nothing to read returns at once and the reader can see whether it should stop.
|
||||
pub const O_NONBLOCK: i32 = 0o4000;
|
||||
|
||||
/// How long the reader waits when the device has nothing to say, which is also the most a report
|
||||
/// can be delayed.
|
||||
const IDLE_POLL: Duration = Duration::from_millis(5);
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
@@ -23,6 +33,30 @@ pub fn find() -> Option<Device> {
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
/// Hands every report to `publish` until the device is gone (end of file or an error) or `stop`
|
||||
/// says so. `source` is non-blocking: when it has nothing, `stop` is asked and the reader waits
|
||||
/// [`IDLE_POLL`]. Reports are read and handed over exactly as they were with blocking reads.
|
||||
pub fn read_reports(mut source: impl Read, mut publish: impl FnMut(&[u8]), mut stop: impl FnMut() -> bool) {
|
||||
let mut report = [0_u8; super::REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match source.read(&mut report) {
|
||||
Ok(0) => return,
|
||||
Ok(size) => publish(report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(IDLE_POLL);
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let 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.
|
||||
@@ -42,6 +76,35 @@ fn ids(uevent: &str) -> Option<(u16, u16)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_come_through_idle_gaps_until_the_device_ends_or_a_stop() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
let mut script = Script::new();
|
||||
script.timeout(std::io::ErrorKind::WouldBlock).data(&[1, 2, 3]).timeout(std::io::ErrorKind::WouldBlock).data(&[4]).end();
|
||||
let (mut seen, mut asked) = (Vec::new(), 0);
|
||||
read_reports(
|
||||
script,
|
||||
|report| seen.push(report.to_vec()),
|
||||
|| {
|
||||
asked += 1;
|
||||
false
|
||||
},
|
||||
);
|
||||
assert_eq!(seen, [vec![1, 2, 3], vec![4]]);
|
||||
assert_eq!(asked, 2);
|
||||
let mut script = Script::new();
|
||||
script.data(&[1]).timeout(std::io::ErrorKind::WouldBlock).data(&[2]);
|
||||
let mut seen = Vec::new();
|
||||
read_reports(script, |report| seen.push(report.to_vec()), || true);
|
||||
assert_eq!(seen, [vec![1]], "a stop at an idle moment ends the read");
|
||||
// An unplugged device is an error, which ends the read as before.
|
||||
let mut script = Script::new();
|
||||
script.data(&[1]).fail(std::io::ErrorKind::BrokenPipe).data(&[2]);
|
||||
let mut seen = Vec::new();
|
||||
read_reports(script, |report| seen.push(report.to_vec()), || false);
|
||||
assert_eq!(seen, [vec![1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_come_from_the_uevent() {
|
||||
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
|
||||
|
||||
@@ -23,6 +23,11 @@ const RECORD_BYTES: usize = 32;
|
||||
/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`].
|
||||
const DAEMON_FULL_SCALE: i64 = 500;
|
||||
|
||||
/// Whether the daemon's socket is there, without connecting to it.
|
||||
pub fn exists() -> bool {
|
||||
SOCKETS.iter().any(|path| Path::new(path).exists())
|
||||
}
|
||||
|
||||
pub fn connect() -> Option<UnixStream> {
|
||||
SOCKETS.iter().find_map(|path| connect_to(Path::new(path)))
|
||||
}
|
||||
@@ -56,18 +61,34 @@ fn unit(value: impl Into<i64>) -> i16 {
|
||||
i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Hands every motion record to `publish` until the daemon closes the connection or fails. A
|
||||
/// partial record at the end is dropped.
|
||||
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes)) {
|
||||
/// Hands every motion record to `publish` until the daemon closes the connection or fails, or
|
||||
/// `stop` says so. A stream with a read timeout (`set_read_timeout`) times out when the daemon is
|
||||
/// quiet, which is no record and no disconnect: `stop` is asked, and the read goes on, with what
|
||||
/// of a record had arrived kept. A partial record at the end is dropped.
|
||||
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes), mut stop: impl FnMut() -> bool) {
|
||||
let mut record = [0_u8; RECORD_BYTES];
|
||||
let mut filled = 0;
|
||||
loop {
|
||||
match stream.read_exact(&mut record) {
|
||||
Ok(()) => {
|
||||
if let Some(axes) = decode(&record) {
|
||||
publish(axes);
|
||||
match stream.read(record.get_mut(filled..).unwrap_or_default()) {
|
||||
Ok(0) => {
|
||||
log::info!("3D mouse: spacenavd connection ended");
|
||||
return;
|
||||
}
|
||||
Ok(size) => {
|
||||
filled += size;
|
||||
if filled == RECORD_BYTES {
|
||||
filled = 0;
|
||||
if let Some(axes) = decode(&record) {
|
||||
publish(axes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: spacenavd connection ended: {error}");
|
||||
return;
|
||||
@@ -132,10 +153,41 @@ mod tests {
|
||||
daemon.write_all(&bytes).expect("written");
|
||||
drop(daemon);
|
||||
let mut seen = Vec::new();
|
||||
read(client, |axes| seen.push(axes.translation));
|
||||
read(client, |axes| seen.push(axes.translation), || false);
|
||||
assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quiet_daemon_times_out_without_losing_the_record_it_was_sending() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
use std::io::ErrorKind::{TimedOut, WouldBlock};
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let mut script = Script::new();
|
||||
script.timeout(WouldBlock).data(&bytes[..10]).timeout(TimedOut).timeout(WouldBlock).data(&bytes[10..]).timeout(WouldBlock).end();
|
||||
let (mut seen, mut asked) = (Vec::new(), 0);
|
||||
read(
|
||||
script,
|
||||
|axes| seen.push(axes.translation),
|
||||
|| {
|
||||
asked += 1;
|
||||
false
|
||||
},
|
||||
);
|
||||
assert_eq!(seen, [[70, 0, 0]]);
|
||||
assert_eq!(asked, 4, "each timeout asks whether to stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_ends_the_read_at_the_next_timeout_only() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let mut script = Script::new();
|
||||
script.data(&bytes).timeout(std::io::ErrorKind::WouldBlock).data(&bytes);
|
||||
let mut seen = 0;
|
||||
read(script, |_| seen += 1, || true);
|
||||
assert_eq!(seen, 1, "the record before the timeout arrived; the one after never was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_daemon_means_no_connection() {
|
||||
let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Windows: finding 3D mice without asking any other device anything (#2614).
|
||||
//!
|
||||
//! hidapi's enumeration opens every HID interface and reads its serial number, manufacturer and
|
||||
//! product strings: on USB, three control requests to every keyboard, mouse and touchscreen. The
|
||||
//! reader repeated it every two seconds while no 3D mouse was plugged in, and on some machines the
|
||||
//! keyboard then kept dropping off the bus and the cursor jumped. Plug and Play already names each
|
||||
//! interface's top-level usage in its hardware IDs (`HID_DEVICE_UP:0001_U:0008`: Generic Desktop,
|
||||
//! Multi-Axis Controller), so the reader asks it, which sends nothing to any device, and opens only
|
||||
//! the interfaces that are 3D mice.
|
||||
//!
|
||||
//! The configuration manager has no safe API, hence the `unsafe_code` allowance on this module
|
||||
//! (the app crate denies it; see `native_crash.rs`).
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::ptr::{null, null_mut};
|
||||
|
||||
use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_Get_DevNode_PropertyW, CM_Get_Device_Interface_List_SizeW, CM_Get_Device_Interface_ListW,
|
||||
CM_Get_Device_Interface_PropertyW, CM_LOCATE_DEVNODE_NORMAL, CM_Locate_DevNodeW, CONFIGRET, CR_BUFFER_SMALL, CR_SUCCESS,
|
||||
};
|
||||
use windows_sys::Win32::Devices::HumanInterfaceDevice::GUID_DEVINTERFACE_HID;
|
||||
use windows_sys::Win32::Devices::Properties::{
|
||||
DEVPKEY_Device_HardwareIds, DEVPKEY_Device_InstanceId, DEVPROP_TYPE_STRING, DEVPROP_TYPE_STRING_LIST, DEVPROPTYPE,
|
||||
};
|
||||
use windows_sys::Win32::Foundation::DEVPROPKEY;
|
||||
|
||||
/// The hardware ID Windows gives a top-level collection with the Generic Desktop page's
|
||||
/// Multi-Axis Controller usage.
|
||||
const MULTI_AXIS_CONTROLLER: &str = "HID_DEVICE_UP:0001_U:0008";
|
||||
/// Largest property read, in bytes: hardware IDs are a few hundred.
|
||||
const MAX_PROPERTY_BYTES: u32 = 64 * 1024;
|
||||
/// Tries at reading the interface list, which can grow between asking its size and reading it.
|
||||
const LIST_TRIES: usize = 4;
|
||||
|
||||
/// The paths of the present HID interfaces whose top-level collection is a multi-axis controller.
|
||||
pub fn multi_axis_controllers() -> Vec<CString> {
|
||||
interfaces()
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
let Some(instance) = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()) else {
|
||||
return false;
|
||||
};
|
||||
devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids))
|
||||
})
|
||||
.filter_map(|path| CString::new(path).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a device with these hardware IDs is a multi-axis controller.
|
||||
fn is_multi_axis(hardware_ids: &[String]) -> bool {
|
||||
hardware_ids.iter().any(|id| id.eq_ignore_ascii_case(MULTI_AXIS_CONTROLLER))
|
||||
}
|
||||
|
||||
/// The strings of a nul-separated list (`REG_MULTI_SZ`, or one string), without empty ones.
|
||||
fn split_multi_sz(buffer: &[u16]) -> Vec<String> {
|
||||
buffer.split(|c| *c == 0).filter(|s| !s.is_empty()).map(String::from_utf16_lossy).collect()
|
||||
}
|
||||
|
||||
/// `s` as a nul-terminated wide string.
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// The paths of the present HID interfaces.
|
||||
fn interfaces() -> Vec<String> {
|
||||
for _ in 0..LIST_TRIES {
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `len` is a valid place for the size; the class GUID is a static; no device filter.
|
||||
let sized = unsafe { CM_Get_Device_Interface_List_SizeW(&mut len, &GUID_DEVINTERFACE_HID, null(), CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
|
||||
if sized != CR_SUCCESS || len == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut buffer = vec![0u16; len as usize];
|
||||
// SAFETY: `buffer` holds `len` wide characters, the length the call is given.
|
||||
let listed =
|
||||
unsafe { CM_Get_Device_Interface_ListW(&GUID_DEVINTERFACE_HID, null(), buffer.as_mut_ptr(), len, CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
|
||||
match listed {
|
||||
CR_SUCCESS => return split_multi_sz(&buffer),
|
||||
CR_BUFFER_SMALL => continue,
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A string or string-list property, read by `get` (property type, buffer, its size in bytes) in
|
||||
/// two calls: the size, then the value.
|
||||
fn property_strings(get: impl Fn(*mut DEVPROPTYPE, *mut u8, *mut u32) -> CONFIGRET) -> Option<Vec<String>> {
|
||||
let mut kind: DEVPROPTYPE = 0;
|
||||
let mut size = 0u32;
|
||||
if get(&mut kind, null_mut(), &mut size) != CR_BUFFER_SMALL || size == 0 || size > MAX_PROPERTY_BYTES {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u16; (size as usize).div_ceil(2)];
|
||||
let mut size = (buffer.len() * 2) as u32;
|
||||
if get(&mut kind, buffer.as_mut_ptr().cast(), &mut size) != CR_SUCCESS || (kind != DEVPROP_TYPE_STRING && kind != DEVPROP_TYPE_STRING_LIST) {
|
||||
return None;
|
||||
}
|
||||
Some(split_multi_sz(&buffer))
|
||||
}
|
||||
|
||||
/// A string property of the device interface at `path`.
|
||||
fn interface_strings(path: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
|
||||
let path = wide(path);
|
||||
property_strings(|kind, buffer, size| {
|
||||
// SAFETY: `path` is nul-terminated and outlives the call; `buffer` is null (asking the size)
|
||||
// or holds `*size` bytes, as `property_strings` sets up.
|
||||
unsafe { CM_Get_Device_Interface_PropertyW(path.as_ptr(), key, kind, buffer, size, 0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// A string property of the device with instance ID `instance`.
|
||||
fn devnode_strings(instance: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
|
||||
let instance = wide(instance);
|
||||
let mut devnode = 0u32;
|
||||
// SAFETY: `instance` is nul-terminated and outlives the call; `devnode` is a valid place for the result.
|
||||
if unsafe { CM_Locate_DevNodeW(&mut devnode, instance.as_ptr(), CM_LOCATE_DEVNODE_NORMAL) } != CR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
property_strings(|kind, buffer, size| {
|
||||
// SAFETY: `buffer` is null (asking the size) or holds `*size` bytes, as `property_strings` sets up.
|
||||
unsafe { CM_Get_DevNode_PropertyW(devnode, key, kind, buffer, size, 0) }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The hardware IDs Windows gives a wired 3Dconnexion device's motion interface, and a
|
||||
/// keyboard's: only the first is a multi-axis controller.
|
||||
#[test]
|
||||
fn only_the_multi_axis_usage_is_a_3d_mouse() {
|
||||
let ids = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let space_mouse = ids(&["HID\\VID_256F&PID_C635&REV_0437&MI_00", "HID\\VID_256F&UP:0001_U:0008", "HID_DEVICE_UP:0001_U:0008", "HID_DEVICE"]);
|
||||
let keyboard = ids(&[
|
||||
"HID\\VID_046D&PID_C31C&REV_6400&MI_00",
|
||||
"HID\\VID_046D&UP:0001_U:0006",
|
||||
"HID_DEVICE_SYSTEM_KEYBOARD",
|
||||
"HID_DEVICE_UP:0001_U:0006",
|
||||
"HID_DEVICE",
|
||||
]);
|
||||
let vendor = ids(&["HID\\VID_256F&UP:FF00_U:0001", "HID_DEVICE_UP:FF00_U:0001", "HID_DEVICE_UPR:FF00-FFFF", "HID_DEVICE"]);
|
||||
assert!(is_multi_axis(&space_mouse));
|
||||
assert!(is_multi_axis(&ids(&["hid_device_up:0001_u:0008"])), "IDs compare without regard to case");
|
||||
assert!(!is_multi_axis(&keyboard) && !is_multi_axis(&vendor) && !is_multi_axis(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nul_separated_lists_split_into_their_strings() {
|
||||
let buffer: Vec<u16> = "a\\b\0HID_DEVICE\0\0".encode_utf16().collect();
|
||||
assert_eq!(split_multi_sz(&buffer), ["a\\b", "HID_DEVICE"]);
|
||||
assert_eq!(split_multi_sz(&wide("one")), ["one"]);
|
||||
assert!(split_multi_sz(&[0, 0]).is_empty() && split_multi_sz(&[]).is_empty());
|
||||
}
|
||||
|
||||
/// The scan asks Plug and Play only: on any machine it returns, and every path it returns is a
|
||||
/// present HID interface whose hardware IDs name the multi-axis usage.
|
||||
#[test]
|
||||
fn the_scan_returns_only_multi_axis_interfaces() {
|
||||
let found = multi_axis_controllers();
|
||||
let all = interfaces();
|
||||
for path in &found {
|
||||
let path = path.to_str().unwrap_or_default();
|
||||
assert!(all.iter().any(|p| p == path), "{path}");
|
||||
let instance = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()).unwrap_or_default();
|
||||
assert!(devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids)), "{path}");
|
||||
}
|
||||
eprintln!("{} HID interfaces present, {} multi-axis controllers", all.len(), found.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Keeping the restored window reachable.
|
||||
//!
|
||||
//! The window comes back where it was left. After a monitor was unplugged, the scale changed or
|
||||
//! the settings came from a larger screen, that can put its title bar above the top edge or
|
||||
//! under the taskbar, out of reach of the mouse. [`fit`] pulls a window back into the monitor's
|
||||
//! work area (the screen without the taskbar), in whatever unit the caller measures both, as
|
||||
//! long as it is the same one.
|
||||
|
||||
/// A rectangle: left, top, width, height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Area {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Area {
|
||||
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Area { x, y, w, h }
|
||||
}
|
||||
fn right(self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
fn bottom(self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
fn sane(self) -> bool {
|
||||
[self.x, self.y, self.w, self.h].iter().all(|v| v.is_finite()) && self.w > 0.0 && self.h > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the title bar must stay over the work area at least, to grab it.
|
||||
pub const REACHABLE: f32 = 100.0;
|
||||
/// The size a window gets when its saved geometry is unusable, before it is fitted to the screen.
|
||||
pub const DEFAULT_SIZE: (f32, f32) = (1600.0, 1000.0);
|
||||
|
||||
/// `window` moved and shrunk so that it can be reached on `work`: no larger than the work area
|
||||
/// (but not below `min`), the title bar not above the work area's top, the window not below its
|
||||
/// bottom, and at least [`REACHABLE`] of it over the work area horizontally. A window that is
|
||||
/// fine is returned unchanged. Geometry that makes no sense (not finite, empty), or a work
|
||||
/// area that does not, gives the default size centred on the work area.
|
||||
pub fn fit(window: Area, work: Area, min: (f32, f32)) -> Area {
|
||||
if !work.sane() {
|
||||
return window;
|
||||
}
|
||||
let min = (min.0.min(work.w), min.1.min(work.h));
|
||||
if !window.sane() {
|
||||
let w = DEFAULT_SIZE.0.min(work.w * 0.9).max(min.0);
|
||||
let h = DEFAULT_SIZE.1.min(work.h * 0.9).max(min.1);
|
||||
return Area::new(work.x + (work.w - w) / 2.0, work.y + (work.h - h) / 2.0, w, h);
|
||||
}
|
||||
let w = window.w.clamp(min.0, work.w);
|
||||
let h = window.h.clamp(min.1, work.h);
|
||||
let reach = REACHABLE.min(w);
|
||||
// A window that had to shrink goes fully onto the screen; one that fits may keep straddling two.
|
||||
let x = if w < window.w { window.x.max(work.x).min(work.right() - w) } else { window.x.max(work.x + reach - w).min(work.right() - reach) };
|
||||
let y = window.y.min(work.bottom() - h).max(work.y);
|
||||
Area::new(x, y, w, h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WORK: Area = Area::new(0.0, 0.0, 2560.0, 1400.0);
|
||||
const MIN: (f32, f32) = (960.0, 600.0);
|
||||
|
||||
#[test]
|
||||
fn a_window_on_screen_is_left_alone() {
|
||||
let w = Area::new(300.0, 120.0, 1600.0, 1000.0);
|
||||
assert_eq!(fit(w, WORK, MIN), w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_bar_above_the_top_or_under_the_taskbar_comes_back() {
|
||||
let fitted = fit(Area::new(300.0, -400.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!((fitted.y, fitted.h), (0.0, 1000.0));
|
||||
let fitted = fit(Area::new(300.0, 1300.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(fitted.y + fitted.h, 1400.0);
|
||||
// A taskbar at the top moves the work area down.
|
||||
let below_bar = Area::new(0.0, 48.0, 2560.0, 1352.0);
|
||||
assert_eq!(fit(Area::new(300.0, 0.0, 1600.0, 1000.0), below_bar, MIN).y, 48.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_from_a_monitor_that_is_gone_keeps_a_grabbable_part_on_screen() {
|
||||
let right = fit(Area::new(5000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(right.x, 2560.0 - REACHABLE);
|
||||
let left = fit(Area::new(-4000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(left.x + left.w, REACHABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_larger_than_the_screen_shrinks_to_it() {
|
||||
let small = Area::new(0.0, 0.0, 1280.0, 720.0);
|
||||
let fitted = fit(Area::new(-20.0, -30.0, 3200.0, 2000.0), small, MIN);
|
||||
assert_eq!(fitted, small);
|
||||
// Never below the minimum, unless the screen itself is smaller.
|
||||
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), WORK, MIN).w, 960.0);
|
||||
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), Area::new(0.0, 0.0, 800.0, 500.0), MIN).h, 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_geometry_is_centred_at_a_sensible_size() {
|
||||
for bad in [Area::new(f32::NAN, 0.0, 1600.0, 1000.0), Area::new(0.0, 0.0, 0.0, 0.0), Area::new(0.0, 0.0, f32::INFINITY, 10.0)] {
|
||||
let fitted = fit(bad, WORK, MIN);
|
||||
assert_eq!(fitted, Area::new(480.0, 200.0, 1600.0, 1000.0));
|
||||
}
|
||||
let small = Area::new(100.0, 50.0, 1366.0, 728.0);
|
||||
let fitted = fit(Area::new(f32::NAN, 0.0, 1.0, 1.0), small, MIN);
|
||||
assert!(fitted.w <= small.w && fitted.h <= small.h && fitted.x >= small.x && fitted.y >= small.y);
|
||||
// No work area to go by: nothing to change.
|
||||
let w = Area::new(1.0, 2.0, 3.0, 4.0);
|
||||
assert_eq!(fit(w, Area::new(0.0, 0.0, 0.0, 0.0), MIN), w);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
## Code and provenance
|
||||
|
||||
Resources remain compiled by their owning layer through existing APIs:
|
||||
`ui-egui::icons::paint` and `Tokens` at L5, model presets in L2 `doc`, and sketch
|
||||
`ui-egui::icons::paint_icon` and `Tokens` at L5, model presets in L2 `doc`, and sketch
|
||||
fonts at L1. State, projection, interaction, commands and GPU bindings stay in
|
||||
their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
|
||||
needs no runtime resource registry or repository lookup.
|
||||
|
||||
@@ -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 |
|
||||
@@ -90,7 +91,7 @@ Palette changes preserve accepted artwork and never update review hashes.
|
||||
| `source/` | Rust viewport glyphs, markers, navigation and pointer; not command icons |
|
||||
|
||||
`crates/ui-egui/build.rs` embeds SVGs, palette and aliases. `icon_svg.rs` parses and
|
||||
rasterises them for `icons::paint`; adding an icon still requires its registry or
|
||||
rasterises them for `icons::paint_icon`; adding an icon still requires its registry or
|
||||
UI assignment and an application rebuild. Review status never controls rendering.
|
||||
|
||||
## Checks and reports
|
||||
@@ -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,4 +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,280 @@
|
||||
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
|
||||
send false
|
||||
settings false
|
||||
sheet_rules false
|
||||
shell false
|
||||
shield 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
|
||||
status_recovery 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,17 +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",
|
||||
@@ -241,6 +253,7 @@
|
||||
"spin": "a5bebeff8276dd12ab7e42ddf0051370b9063fb602607298acf22e77044434d1",
|
||||
"spline": "a0dcb25614e5fe1a8f17bc3bddbb11b61cf82b461dd46443ab28cddd6e4e32e3",
|
||||
"spline_cv": "353f6e411c0ce611f660cef2e4fc4f8f55edc2c82288de91948dc638cd2b5f15",
|
||||
"split": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b",
|
||||
"split_body": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b",
|
||||
"split_face": "c8b89072118bb7a28b243d38e26b81a796781a631f30b3b1fc296ba23e4d908d",
|
||||
"spun": "b4cfa093ffb43611ee124f4a39fb44c609deb9cde5e05e4dfbaf893fe197c27b",
|
||||
@@ -249,7 +262,7 @@
|
||||
"status_warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
|
||||
"stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d",
|
||||
"style": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578",
|
||||
"suppress": "6c033a6672a3b6ca926e3d7a75a758c9db7e1144210cd360be043a9fdf999ede",
|
||||
"suppress": "c54fbc01c12c0175d78cd63f692e8aaf73fc2cac79752b2a3ad821ac32734343",
|
||||
"surface": "ba0e839ce0ee03e7ff95a2389a5ecb20bd80c713064d9b1bd5f461f4a80d1921",
|
||||
"surface_blend": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc",
|
||||
"surface_boundary": "fb8501bae192e2416118cc2e88f544e0e29197f6720bccc541c967ca0848d8d3",
|
||||
@@ -265,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",
|
||||
@@ -284,7 +297,7 @@
|
||||
"update": "c675e16df4c78589a00be4a34f98627500681672ee8a668e929aec50982da3cc",
|
||||
"warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
|
||||
"web": "be5cd95bebcd0ebe257f9eb862112124429b381ef4a2f0849938fac7c9ccc619",
|
||||
"ws_animate": "d36a0834ad989a6e16e2b0db493ad8867c590caedf0442c51eff19e934c887f8",
|
||||
"ws_animate": "f34f187850789cc4f141b1ace114082be04edec8e7771d6730fec1cef19c7f64",
|
||||
"ws_design": "7b1dd678a2d018892b965a78932c047bbfff6b4439d7bd9fe85c09a995f97581",
|
||||
"ws_drawing": "61e99c7179f3448a31b8c76392334352ec3d5e39b0b92fe5244940146d0986cb",
|
||||
"ws_render": "1bfe474d1742623ad2142129e4d6a40015e674813eb1db401d2e88e55b1f2a03",
|
||||
@@ -292,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,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Send message</title>
|
||||
<path d="M32 51L32 13M17 28L32 13L47 28" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Keyboard shortcuts</title>
|
||||
<path d="M13 16L51 16A5 5 0 0 1 56 21L56 43A5 5 0 0 1 51 48L13 48A5 5 0 0 1 8 43L8 21A5 5 0 0 1 13 16Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="27" r="2.8" fill="currentColor"/>
|
||||
<circle cx="32" cy="27" r="2.8" fill="currentColor"/>
|
||||
<circle cx="44" cy="27" r="2.8" fill="currentColor"/>
|
||||
<path d="M20 39L44 39" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 607 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Graphics display</title>
|
||||
<path d="M13 10L51 10A5 5 0 0 1 56 15L56 37A5 5 0 0 1 51 42L13 42A5 5 0 0 1 8 37L8 15A5 5 0 0 1 13 10ZM32 42L32 54M24 54L40 54" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Mouse navigation</title>
|
||||
<path d="M32 8A13 13 0 0 1 45 21L45 43A13 13 0 0 1 19 43L19 21A13 13 0 0 1 32 8ZM32 8L32 24" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 299 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Interface palette</title>
|
||||
<path d="M32 8C18.75 8 8 18.75 8 32C8 45.25 18.75 56 32 56C37 56 39 52.5 36.5 49C34 45.5 36 42 40 42L45 42C51.1 42 56 37.1 56 31C56 19 45 8 32 8Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="21" cy="29" r="2.8" fill="currentColor"/>
|
||||
<circle cx="29" cy="20" r="2.8" fill="currentColor"/>
|
||||
<circle cx="41" cy="23" r="2.8" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 522 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Plugins</title>
|
||||
<path d="M24 8L24 21M40 8L40 21M16 21L48 21L48 29A16 16 0 0 1 16 29L16 21ZM32 45L32 56" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Approval required</title>
|
||||
<path d="M32 9L51 16L51 31C51 43 43 51 32 56C21 51 13 43 13 31L13 16Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Stop response</title>
|
||||
<path d="M20 17L44 17A3 3 0 0 1 47 20L47 44A3 3 0 0 1 44 47L20 47A3 3 0 0 1 17 44L17 20A3 3 0 0 1 20 17Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 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 |
@@ -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 |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Recover unsaved design</title>
|
||||
<circle cx="32" cy="33" r="22" fill="#b9ddf8"/>
|
||||
<path d="M10 33A22 22 0 1 0 16.44 17.44L10 24" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10 12L10 24L22 24" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 21L32 33L41 39" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 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 |
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Animate workspace</title>
|
||||
<path d="M10.46 24.87L3.55 28.86L3.55 47.86L10.46 51.85M3.55 28.86L10.46 32.85" fill="none" stroke="#ffffff" stroke-width="7.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.46 24.87L3.55 28.86L3.55 47.86L10.46 51.85M3.55 28.86L10.46 32.85" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.46 24.87L3.55 28.86L3.55 47.86L10.46 51.85M3.55 28.86L10.46 32.85" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.46 24.87L15.55 28.86L15.55 47.86L22.46 51.85M15.55 28.86L22.46 32.85" fill="none" stroke="#ffffff" stroke-width="7.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.46 24.87L15.55 28.86L15.55 47.86L22.46 51.85M15.55 28.86L22.46 32.85" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M22.46 24.87L15.55 28.86L15.55 47.86L22.46 51.85M15.55 28.86L22.46 32.85" fill="none" stroke="currentColor" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M44 19.36L60.45 28.86L44 38.36L44 57.36L27.55 47.86L27.55 28.86Z" fill="#ffffff" stroke="#ffffff" stroke-width="7.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M27.55 28.86L44 38.36L44 57.36L27.55 47.86Z" fill="#b8c3cf"/>
|
||||
<path d="M60.45 28.86L44 38.36L44 57.36L60.45 47.86Z" fill="#8e9faf"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -9,7 +9,7 @@
|
||||
--out FILE Self-contained static review page (default target/icon-status.html)
|
||||
|
||||
Read assets/icons/ui/STYLE_GUIDE.md before editing artwork. SVGs are authoritative.
|
||||
Every discovered non-final icon is Open; no inventory file controls discovery.
|
||||
Every discovered non-final icon is Open; artwork, source and runtime requests are discovery sources.
|
||||
"""
|
||||
import sys, os, re, glob, html, json, hashlib, collections, tomllib, uuid, datetime
|
||||
|
||||
@@ -36,6 +36,7 @@ def art_rev():
|
||||
paths += glob.glob(ROOT + '/crates/ui-egui/src/**/*.rs', recursive=True)
|
||||
paths += glob.glob(HERE + '/*.py')
|
||||
paths += [D + '/palette.toml', D + '/aliases.toml', __file__]
|
||||
paths += glob.glob(D + '/requested_icons.txt')
|
||||
for path in sorted(paths):
|
||||
# Equal-length edits can share an mtime. Only the content identifies what
|
||||
# the reviewer saw; file metadata must never authorize stale acceptance.
|
||||
@@ -56,8 +57,31 @@ def ui_icon_names(source):
|
||||
if icon: names.add(icon.group(1))
|
||||
for table in re.finditer(r'for\s*\(i,\s*\(label,\s*icon,\s*tip\)\)\s*in\s*\[(.*?)\]\s*\.iter\(', source, re.S):
|
||||
names.update(re.findall(r'\("[^"]*",\s*"([a-z][a-z0-9_]*)",\s*"[^"]*"\)', table.group(1)))
|
||||
# System-window wrappers and declarative page lists own their icon arguments.
|
||||
for call in re.finditer(r'\b(?:Window|Confirm)::new\([^;]*?,\s*"([a-z][a-z0-9_]*)"\s*,', source, re.S):
|
||||
names.add(call.group(1))
|
||||
for page in re.finditer(r'\bWindowPage\s*\{[^}]*?\bicon:\s*"([a-z][a-z0-9_]*)"', source, re.S):
|
||||
names.add(page.group(1))
|
||||
return names
|
||||
|
||||
def requested_icons():
|
||||
"""Runtime ids recorded by the Harness audit; flags describe its last actual render."""
|
||||
path = os.path.join(D, 'requested_icons.txt')
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
requested = {}
|
||||
with open(path, encoding='utf-8') as manifest:
|
||||
for number, line in enumerate(manifest, 1):
|
||||
if not line.strip() or line.startswith('#'):
|
||||
continue
|
||||
fields = line.strip().split('\t')
|
||||
if len(fields) != 2 or not re.fullmatch(r'[a-z][a-z0-9_]*', fields[0]) or fields[1] not in ('true', 'false'):
|
||||
raise ValueError(f'{path}:{number}: expected icon_id<TAB>true|false')
|
||||
if fields[0] in requested:
|
||||
raise ValueError(f'{path}:{number}: duplicate requested icon {fields[0]}')
|
||||
requested[fields[0]] = fields[1] == 'true'
|
||||
return requested
|
||||
|
||||
def scan():
|
||||
"""Everything derived from the repo: rows, summary."""
|
||||
pal = tomllib.load(open(D + '/palette.toml', 'rb'))
|
||||
@@ -86,6 +110,9 @@ def scan():
|
||||
t = open(f, encoding='utf-8').read()
|
||||
for name in ui_icon_names(t):
|
||||
uses[name].append('ui:' + os.path.relpath(f, ROOT + '/crates/ui-egui/src').replace(os.sep, '/'))
|
||||
runtime = requested_icons()
|
||||
for name in runtime:
|
||||
uses[name].append('ui:runtime audit')
|
||||
mk = ROOT + '/assets/icons/ui/source/markers.rs'
|
||||
if os.path.exists(mk):
|
||||
for name in re.findall(r'=> "(c_[a-z_]+)"', open(mk, encoding='utf-8').read()): uses[name].append('ui:sketch constraint glyph')
|
||||
@@ -95,7 +122,7 @@ def scan():
|
||||
rows = []
|
||||
for n in sorted(set(uses) | set(masters) | set(alias_raw)):
|
||||
m, acc = alias_of(n)
|
||||
if m in masters and n != 'placeholder': status = 'done'
|
||||
if m in masters and n != 'placeholder' and not runtime.get(n, False): status = 'done'
|
||||
else: status, m = 'placeholder', 'placeholder'
|
||||
cmds = [u for u in uses.get(n, []) if not u.startswith('ui:')]
|
||||
rows.append(dict(id=n, status=status, master=m, accent=acc, commands=len(cmds), example=cmds[0] if cmds else '',
|
||||
|
||||
@@ -15,7 +15,7 @@ files = sorted(glob.glob(d + '/svg/*/*.svg'))
|
||||
for path in Path(d).rglob('*'):
|
||||
if not path.is_file(): continue
|
||||
rel = path.relative_to(d).as_posix()
|
||||
allowed = (rel in {'palette.toml', 'aliases.toml', 'review.json', 'references.toml'}
|
||||
allowed = (rel in {'palette.toml', 'aliases.toml', 'review.json', 'references.toml', 'requested_icons.txt'}
|
||||
or path.suffix == '.md'
|
||||
or (rel.startswith('svg/') and path.suffix == '.svg')
|
||||
or (path.parent in {Path(d) / 'tools', Path(d) / 'tools/author'} and path.suffix == '.py')
|
||||
@@ -79,6 +79,36 @@ if os.path.exists(references):
|
||||
if name not in names and name not in al: err(references, name + ': reference artwork is missing')
|
||||
for message in icon_palette.validation_errors(pal, names | set(al)):
|
||||
err(d + '/palette.toml', message)
|
||||
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
|
||||
def app_tokens():
|
||||
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
|
||||
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
|
||||
light_src, dark_src = src[lo:hi], src[hi:]
|
||||
def pick(block, name, dark):
|
||||
pat = (r'\bt\.%s = Color32::from_rgb\((\d+), (\d+), (\d+)\)' if dark else r'\b%s: Color32::from_rgb\((\d+), (\d+), (\d+)\)') % name
|
||||
m = re.search(pat, block)
|
||||
return None if not m else '#%02x%02x%02x' % tuple(int(x) for x in m.groups())
|
||||
out = {}
|
||||
for name in ('toolbar', 'panel', 'border', 'text', 'text_dim', 'viewport_top'):
|
||||
out[name] = (pick(light_src, name, False), pick(dark_src, name, True) or pick(light_src, name, False))
|
||||
return out
|
||||
css_path = os.path.join(d, 'tools', 'review', 'review.css')
|
||||
if os.path.exists(css_path):
|
||||
try:
|
||||
tok = app_tokens()
|
||||
css = open(css_path, encoding='utf-8').read()
|
||||
want = {
|
||||
':root{': {'--bg': tok['panel'][0], '--fg': tok['text'][0], '--card': tok['toolbar'][0], '--line': tok['border'][0], '--mut': tok['text_dim'][0]},
|
||||
'body.dark{': {'--bg': tok['viewport_top'][1], '--fg': tok['text'][1], '--card': tok['toolbar'][1], '--line': tok['border'][1], '--mut': tok['text_dim'][1]},
|
||||
}
|
||||
for block, values in want.items():
|
||||
line = next((l for l in css.split('\n') if l.startswith(block)), '')
|
||||
for var, hexv in values.items():
|
||||
m = re.search(re.escape(var) + r':(#[0-9a-fA-F]{6})', line)
|
||||
if not m or m.group(1).lower() != hexv:
|
||||
err(css_path, '%s%s is %s, the app token is %s (palettes.rs)' % (block, var, m.group(1) if m else 'missing', hexv))
|
||||
except (OSError, ValueError) as e:
|
||||
err(css_path, 'cannot compare with palettes.rs: %s' % e)
|
||||
# an icon accepted as final on the review page must not change (un-accept it there to work on it again)
|
||||
import json, review_state
|
||||
rp = d + '/review.json'
|
||||
|
||||
@@ -9,7 +9,7 @@ aliases = tomllib.load(open(d + '/aliases.toml', 'rb')).get('alias', {})
|
||||
def themed(svg, theme, icon, family, size):
|
||||
_, accent = icon_palette.resolve(palette, aliases, icon)
|
||||
return icon_palette.themed(svg, palette, theme, accent, namespace='preview-' + family + '-' + icon + '-' + theme + '-' + str(size))
|
||||
h = '<meta charset=utf-8><style>body{margin:0;font:10px sans-serif}.p{padding:8px}.light{background:#fff;color:#222}.dark{background:#1e2530;color:#ddd}.c{display:inline-block;width:92px;text-align:center;margin:3px 2px;vertical-align:top}.s{display:flex;gap:6px;justify-content:center;align-items:center;margin-top:2px}h3{margin:4px}</style>'
|
||||
h = '<meta charset=utf-8><style>body{margin:0;font:10px sans-serif}.p{padding:8px}.light{background:#f6f7f9;color:#262a32}.dark{background:#272b34;color:#dee2e8}.c{display:inline-block;width:92px;text-align:center;margin:3px 2px;vertical-align:top}.s{display:flex;gap:6px;justify-content:center;align-items:center;margin-top:2px}h3{margin:4px}</style>'
|
||||
for theme in ('light', 'dark'):
|
||||
for fam in sorted(os.listdir(d + '/svg')):
|
||||
h += '<div class="p %s"><h3>%s - %s</h3>' % (theme, fam, theme)
|
||||
|
||||