mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5965439a15 | |||
| 0b2d3ecd44 | |||
| 96e43bb4ef | |||
| 955f5aea43 | |||
| 739e442824 | |||
| 30f951ecf9 | |||
| 02458892d9 | |||
| 1d02a5f2d4 | |||
| c391ef17cb | |||
| e413327821 | |||
| a1eb63c169 | |||
| 610f2e479c | |||
| f49cd33db1 | |||
| 4c7087ec16 | |||
| f87b657c82 | |||
| 64ba396c05 | |||
| 1720c56840 | |||
| b28decfaf6 | |||
| 9e4dc48165 | |||
| 3de2f96b7f | |||
| 436ee95095 | |||
| 1ee2532678 | |||
| 3152e02fe5 | |||
| fbcd4852c1 | |||
| 97cce94a1c | |||
| 5b41d61f1e | |||
| 2863b80574 | |||
| 03a701d678 | |||
| a2ccbd0684 | |||
| 2e566e2c58 | |||
| 47285ccb88 | |||
| da16362626 | |||
| 13cd45d1f1 | |||
| c5e63b7871 | |||
| 2755c9ccfb | |||
| 4ab6d92952 | |||
| e063a7778e | |||
| 8630a3f0e5 | |||
| 0e0c23b080 | |||
| e29075376c | |||
| 44b0056d48 | |||
| 3bcf77ee32 | |||
| 91638cb1f3 | |||
| 66b872a749 | |||
| 2016ba71f4 | |||
| 7556e23fe7 | |||
| 4d06daf87b | |||
| c7e53899af | |||
| 18ca30a311 | |||
| b55f30959d | |||
| 8b98f6c7e3 | |||
| 3a504f5347 | |||
| 7f2acbc004 | |||
| d3720041b6 | |||
| a3c7a6ff93 | |||
| e6fb3793dc | |||
| c6d361347f | |||
| a078d4e7e9 | |||
| c71234edfb | |||
| 00ff63f75e | |||
| 82cd5cc52e | |||
| 7d7016b356 | |||
| cd66495e14 | |||
| 484f0cf8ae | |||
| daecea6edf | |||
| bd38bc40be | |||
| 19c8eb846e | |||
| 6f5f64f2d9 | |||
| b2525ed34e | |||
| 72a76edcf7 | |||
| 710b7a664e | |||
| 0d32821e9e | |||
| d9c5e4c325 | |||
| 12fe17d1b7 | |||
| cf7fbeaba1 | |||
| e2c9f908f8 | |||
| fa961eaa31 | |||
| aa88360357 | |||
| e1ff3fa9c0 | |||
| 3afcd69a5c | |||
| a3483ed451 | |||
| d7252f2240 | |||
| 30e9b87b48 | |||
| b20f522f5d | |||
| d895fef354 | |||
| 070b1414ee | |||
| 111c662fe4 | |||
| ab709e6c46 | |||
| 03bf992bc1 | |||
| 586a358ed4 | |||
| 4d61100534 | |||
| 25ad6c7266 | |||
| cfb3f52aec | |||
| 7c31631ea6 | |||
| c13e516cd5 | |||
| c264c7a42a | |||
| 56f09f3993 | |||
| 6e63f8bd97 | |||
| b01f361edf | |||
| c482dab4a1 | |||
| 189b47c750 | |||
| 1530a8e247 | |||
| 1d2674ebc4 | |||
| 10b5cc5914 | |||
| 41a938e731 | |||
| 9d0b509961 | |||
| 7ae17e6dd5 | |||
| 1cd14e2412 | |||
| 7014b62388 | |||
| adc568a715 | |||
| 12b0d9dbff | |||
| 613903229e | |||
| 0c4eb458df | |||
| b00c432ac9 | |||
| 8a28d13161 | |||
| 74321f8bbd | |||
| a67b3bfdf3 | |||
| abdf9d79cf | |||
| b1bbca97ef | |||
| 2ec11cfc3a | |||
| 2205955329 | |||
| bd9d0778f6 | |||
| a0f7529fd6 | |||
| 4a0d519727 | |||
| ffbcdc614c | |||
| 06af6e1107 | |||
| 84b1aeecae | |||
| a6354dfec5 | |||
| 8108646e2c | |||
| b3cbbe7bd5 | |||
| ae2d2145b2 | |||
| a800a40099 | |||
| dc3061a039 | |||
| 102314dbb2 | |||
| 4ced1d7bcf | |||
| a82f839255 | |||
| 08484af293 | |||
| d29e70bad9 | |||
| 1d482aff74 | |||
| 2445c0f13d | |||
| 3d0e2a8b3c | |||
| 2a01cca00e | |||
| d7cc5621fa | |||
| be6c7fc17c | |||
| f006eefd6e | |||
| 15269f85a1 | |||
| e53390ea1b | |||
| c8314687fc | |||
| 878339cac8 | |||
| 348bbc1dfa | |||
| 262f5c61c0 | |||
| ece2a8693a | |||
| 0f77199449 | |||
| e02ef3246f | |||
| ffbeb42b5c | |||
| 816bcf10de | |||
| 28689e784e | |||
| d396be78a2 | |||
| cbac207852 | |||
| ff2ca2d462 | |||
| ab80a410a1 | |||
| b94692497e | |||
| 7bcc1ee0eb | |||
| 36a60a70d9 | |||
| 5d17fa970b | |||
| b5bab3f663 | |||
| ccea34e73c | |||
| c7f86c7309 | |||
| 4355d117ef | |||
| 39951a22ff | |||
| a3890b3bd5 | |||
| 59cad5f0bb | |||
| 5fbe058051 | |||
| 0752d8d79c | |||
| 0344a41dc4 | |||
| dba8108369 | |||
| 57d787f5ba |
@@ -1,5 +1,6 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
|
||||
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml),
|
||||
# except the GPU path tracer's tests (the gpu-path group below), which run in four shards of their own.
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
@@ -72,6 +73,7 @@ priority = 100
|
||||
# Each nextest test has its own process/device. Concurrent cold lavapipe path
|
||||
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
|
||||
# Serialize this module per CI shard; other tests retain their normal parallelism.
|
||||
# CI runs these in their own job (GPU path tests in ci.yml), split into count partitions.
|
||||
[test-groups.gpu-path]
|
||||
max-threads = 1
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
|
||||
Before reporting a boolean or modelling failure, please try to reproduce it on the latest nightly or a build from current `main`, and say which build you used.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
|
||||
+158
-22
@@ -9,12 +9,14 @@
|
||||
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
|
||||
# no native job runs for them, on pull requests or on main.
|
||||
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
|
||||
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
|
||||
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
|
||||
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
|
||||
# only main commits.
|
||||
# checks (no archive to upload and download). The GPU path tracer's tests run one at a time
|
||||
# (.config/nextest.toml), so they have a job of their own in four shards (GPU path tests). A few
|
||||
# heavy tests (see .config/nextest.toml) are skipped on pull requests and run on pushes to main and
|
||||
# nightly. The reference tests and the doc tests run in the merge queue, on main and nightly, not
|
||||
# on pull requests (the fast lane, in Select checks); a break there is fixed forward on main. The
|
||||
# Windows jobs (D3D12 shaders and app startup) run nightly, and in the merge queue only for a group
|
||||
# that changes what they exercise. Only main saves the build caches; pull requests restore them.
|
||||
# sccache is on a Blacksmith sticky disk that only main commits.
|
||||
# A push to main runs only the repository checks when a passing merge-queue run tested that very
|
||||
# commit, or when nothing a build reads changed since the last push to main whose run passed.
|
||||
# Otherwise it runs everything.
|
||||
@@ -30,7 +32,7 @@
|
||||
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
|
||||
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# per job: 32 vCPU for the tests, 16 for Clippy, the GPU path tests, 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:
|
||||
@@ -103,6 +105,9 @@ jobs:
|
||||
kernel: ${{ steps.paths.outputs.kernel }}
|
||||
goldens: ${{ steps.paths.outputs.goldens }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
# The GPU path tests run: native, and the whole workspace or solidcraft-gpu selected. The
|
||||
# job's `if` and the `ci` job's check both read this, so they cannot disagree.
|
||||
gpu: ${{ steps.paths.outputs.gpu }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
|
||||
quick: ${{ steps.paths.outputs.quick }}
|
||||
@@ -122,6 +127,8 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
||||
GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
@@ -144,6 +151,23 @@ jobs:
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
}
|
||||
# Whether the NUL-separated paths in $1 (the diff $2) reach 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, or a lockfile change that moves the
|
||||
# graphics stack (wgpu, naga, egui, winit, windows). Pull requests and merge groups share it.
|
||||
windows_paths() {
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in
|
||||
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
|
||||
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
|
||||
esac
|
||||
done < "$1"
|
||||
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$1" \
|
||||
&& git diff "$2" -- Cargo.lock \
|
||||
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
|
||||
windows=true
|
||||
fi
|
||||
}
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# The queue lands the very commit its merge_group run tested. If that run passed,
|
||||
# testing the push again proves nothing new; otherwise (a merge that skipped the
|
||||
@@ -164,13 +188,25 @@ jobs:
|
||||
# push before may never have run: a newer push cancels a waiting one).
|
||||
echo "$tested..$PUSH_SHA changes no code: repository checks only."
|
||||
else
|
||||
# Code changed (or the push can't be compared): main runs everything.
|
||||
# Code changed (or the push can't be compared): main runs everything but Windows.
|
||||
# The merge queue ran Windows for the changes that reach it, and the nightly runs it
|
||||
# for everything else (it passed 129 of 129 non-PR runs before this).
|
||||
full=true
|
||||
packages=
|
||||
native=true
|
||||
fi
|
||||
elif [ "$EVENT_NAME" = merge_group ]; then
|
||||
native=true
|
||||
# Windows (about 16 runner-minutes) only for a group that changes what it exercises;
|
||||
# the nightly runs it whatever changed. A group that can't be compared runs it.
|
||||
if git diff --no-renames --name-only -z "$GROUP_BASE_SHA...$GROUP_HEAD_SHA" > "$RUNNER_TEMP/ci-paths"; then
|
||||
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
|
||||
else
|
||||
windows=true
|
||||
fi
|
||||
echo "windows=$windows for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
|
||||
elif [ "$EVENT_NAME" != pull_request ]; then
|
||||
# The nightly run and Run workflow: everything, Windows included.
|
||||
native=true
|
||||
windows=true
|
||||
else
|
||||
@@ -185,19 +221,9 @@ jobs:
|
||||
case "$path" in
|
||||
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
|
||||
esac
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
case "$path" in
|
||||
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
|
||||
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
|
||||
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
|
||||
&& git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \
|
||||
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
|
||||
windows=true
|
||||
fi
|
||||
# Computed as for a merge group, then turned off below (the fast lane).
|
||||
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
|
||||
# Pull requests build and test the touched packages and their dependents; nothing
|
||||
# selected means no code changed.
|
||||
select_packages "$RUNNER_TEMP/ci-paths"
|
||||
@@ -242,8 +268,16 @@ jobs:
|
||||
goldens=false
|
||||
fi
|
||||
fi
|
||||
# The GPU path tests (their own job) run with the other tests, when solidcraft-gpu is
|
||||
# among the packages they test.
|
||||
gpu=false
|
||||
if [ "$native" = true ]; then
|
||||
case " $packages " in *" solidcraft-gpu "*) gpu=true ;; esac
|
||||
if [ "$full" = true ]; then gpu=true; fi
|
||||
fi
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "gpu=$gpu"
|
||||
echo "kernel=$kernel"
|
||||
echo "goldens=$goldens"
|
||||
echo "windows=$windows"
|
||||
@@ -286,6 +320,10 @@ jobs:
|
||||
cargo test -p xtask ui_containers --locked
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Agent router and skills cite real files
|
||||
run: |
|
||||
cargo xtask skills
|
||||
cargo test -p xtask skills --locked
|
||||
- name: Design format pending changes
|
||||
run: bash scripts/test-format-pending.sh
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
@@ -487,8 +525,10 @@ jobs:
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
status=0
|
||||
# The GPU path tracer's tests run in their own job (GPU path tests): one at a time, they
|
||||
# took about 36 of this job's 45 minutes.
|
||||
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
|
||||
--ignore-default-filter -E "$base" || status=1
|
||||
--ignore-default-filter -E "($base) & not (package(=solidcraft-gpu) & test(/^path::/))" || 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
|
||||
@@ -508,6 +548,96 @@ jobs:
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
gpu-path-tests:
|
||||
name: GPU path tests (${{ matrix.shard }}/4)
|
||||
# The GPU path tracer's tests (about 108) compile their pipelines on lavapipe, one test at a
|
||||
# time (the gpu-path test group in .config/nextest.toml keeps them within the 600 s pipeline
|
||||
# setup budget). Serial, they took about 36 minutes, the longest chain in Native Linux tests;
|
||||
# here four shards run beside it. Their filter is the one Native Linux tests leaves out, so
|
||||
# every test runs exactly once.
|
||||
# 16 vCPU, not 8: lavapipe compiles on every core, and on 8 vCPU one test went past the 600 s
|
||||
# budget (665 s, against 204 s for the same test in another run) and a shard took 35 minutes.
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.gpu == 'true' }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
# As in Native Linux tests: lavapipe draws, and a test without an adapter fails.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
# lavapipe's own shader cache (below).
|
||||
MESA_SHADER_CACHE_DIR: /home/runner/.cache/mesa-gpu
|
||||
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.
|
||||
# The shards build the same thing, so only the first one commits it (and saves the cache).
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-gpu
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 && 'on-change' || 'false' }}
|
||||
# The first test in a shard to build the path pipeline compiles it cold on lavapipe (335 to
|
||||
# 665 s, against the 600 s setup budget); every later one reads Mesa's disk cache (about
|
||||
# 30 s). Keeping that cache from main's last run lets the first test read it too, until the
|
||||
# shaders change. Same sticky disk rules as the compiler cache.
|
||||
- name: Mount lavapipe's shader cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-mesa-gpu
|
||||
path: /home/runner/.cache/mesa-gpu
|
||||
commit: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 && '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-gpu
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Run one shard of the GPU path tests
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
# None of these is in the default filter (the heavy tests), so pull requests, the merge
|
||||
# queue, main and nightly all run every one of them, as before. `count` deals the tests
|
||||
# out in turn (27 each); `hash` split them 41/40/27 across three shards.
|
||||
cargo nextest run -p solidcraft-gpu --cargo-profile ci --locked --profile ci --no-tests=pass \
|
||||
--ignore-default-filter -E 'test(/^path::/)' --partition "count:$SHARD/4"
|
||||
- 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-gpu-${{ matrix.shard }}
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
|
||||
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
|
||||
@@ -769,7 +899,7 @@ jobs:
|
||||
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
|
||||
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
|
||||
name: ci
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
@@ -817,6 +947,8 @@ jobs:
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
GPU_RUN: ${{ needs.changes.outputs.gpu }}
|
||||
GPU: ${{ needs.gpu-path-tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
|
||||
@@ -836,6 +968,10 @@ jobs:
|
||||
fi
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
# The same output decides whether the GPU path tests run (their job's `if`).
|
||||
if [ "$GPU_RUN" = true ]; then
|
||||
test "$GPU" = success
|
||||
fi
|
||||
if [ "$DOCTESTS" = true ]; then
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Nightly import checks on real-world STEP files (#1397): the external corpus in
|
||||
# tests/external-corpus.toml is downloaded (hash-verified), imported headless, and checked
|
||||
# against tolerance-based expectations — body counts, total volume and area, time and memory
|
||||
# budgets. It is not part of `cargo xtask ci`: it downloads from the internet.
|
||||
#
|
||||
# Import results should improve over time (healing, edge merging, meshing). When a check fails
|
||||
# because the numbers moved for the better (or the source changed upstream), re-record with
|
||||
# `cargo xtask external-corpus --bless "why"` in a pull request, never by editing numbers.
|
||||
name: External corpus
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '40 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
only:
|
||||
description: "Run only this corpus entry name (empty = every entry)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
corpus:
|
||||
name: Import the external STEP corpus
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build the CLI
|
||||
run: cargo build --profile ci -p solidcraft-cli
|
||||
- name: Import and check every entry
|
||||
env:
|
||||
ONLY: ${{ inputs.only }}
|
||||
run: |
|
||||
if [ -z "$ONLY" ]; then
|
||||
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli
|
||||
else
|
||||
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli --only "$ONLY"
|
||||
fi
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
permissions:
|
||||
contents: read # to check out scripts/kernel-paused
|
||||
issues: write
|
||||
pull-requests: read # to list a PR's changed paths
|
||||
pull-requests: write # to list a PR's changed paths and add its label
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -65,6 +65,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p site
|
||||
cp -r target/book/. site/
|
||||
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
|
||||
cp -r dist/web site/app
|
||||
- uses: actions/configure-pages@v5
|
||||
id: pages
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release.
|
||||
# The macOS app is signed and notarized when the APPLE_* secrets exist, nothing else is signed yet.
|
||||
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
|
||||
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
|
||||
# (see the windows job). Nothing else is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
@@ -18,6 +20,8 @@
|
||||
# releases/latest/download/<name> links in the README keep working across releases.
|
||||
#
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
# A dry run from any branch also test-signs the Windows files when SIGNPATH_API_TOKEN exists:
|
||||
# gh workflow run release.yml --ref <branch> -f draft=false [-f signpath_signing_policy=test-signing]
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -32,6 +36,10 @@ on:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
signpath_signing_policy:
|
||||
description: "SignPath signing policy for the Windows files (empty: the repository variable, else test-signing)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -378,7 +386,14 @@ jobs:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
|
||||
# only signs builds whose jobs all ran on GitHub-hosted runners.
|
||||
runs-on: windows-latest
|
||||
# actions: read lets the SignPath action read this job's details and fetch the uploaded
|
||||
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -389,6 +404,16 @@ jobs:
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
# Authenticode signing through SignPath Foundation (docs/releasing.md, "Windows signing").
|
||||
# Only when the API token secret exists: forks and repositories without it build unsigned,
|
||||
# exactly as before. `if:` can't read secrets, so the check goes through this variable.
|
||||
SIGN_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
|
||||
# The one switch between the self-signed test certificate (test-signing) and the real one
|
||||
# (release-signing): a dispatch input, else the SIGNPATH_SIGNING_POLICY repository variable,
|
||||
# else test-signing.
|
||||
SIGNPATH_SIGNING_POLICY: ${{ inputs.signpath_signing_policy || vars.SIGNPATH_SIGNING_POLICY || 'test-signing' }}
|
||||
SIGNPATH_ORGANIZATION_ID: fa603ff4-c702-404b-8588-a524371c7a4e
|
||||
SIGNPATH_PROJECT_SLUG: solidcraft
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -407,10 +432,109 @@ jobs:
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS != 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
|
||||
# ---- signed path ---------------------------------------------------------------------------
|
||||
# SignPath's GitHub integration (docs.signpath.io/trusted-build-systems/github): the files go
|
||||
# up as a workflow artifact, SignPath fetches that artifact itself (so it can verify it came
|
||||
# from this workflow run), signs it, and the action downloads the signed copy. GitHub stores
|
||||
# artifacts as zips, so packaging/windows/signpath-artifact-configuration.xml (pasted into the
|
||||
# SignPath project) describes a zip holding the files at its top level.
|
||||
# Two rounds: the .exe files first, so the MSI, the portable zip and the plain .exe all carry
|
||||
# signed binaries; then the MSI built from them.
|
||||
- name: Build and stage the binaries for signing
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
|
||||
- name: Upload the unsigned binaries
|
||||
id: unsigned-exes
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signpath-unsigned-exes-windows-${{ matrix.arch }}
|
||||
path: target/windows-package/${{ matrix.arch }}/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Sign the binaries (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
github-artifact-id: ${{ steps.unsigned-exes.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
# release-signing requests wait for a person to approve them in SignPath; give that an
|
||||
# hour instead of the default 10 minutes. test-signing requests finish in seconds.
|
||||
wait-for-completion-timeout-in-seconds: 3600
|
||||
output-artifact-directory: target/windows-package/signed-exes-${{ matrix.arch }}
|
||||
- name: Put the signed binaries in place
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$stage = "target/windows-package/${{ matrix.arch }}"
|
||||
foreach ($exe in 'solidcraft.exe', 'solidcraft-cli.exe', 'solvecraft-cli.exe') {
|
||||
$signed = "target/windows-package/signed-exes-${{ matrix.arch }}/$exe"
|
||||
# The artifact configuration matches each file optionally, so check that every file
|
||||
# actually came back signed rather than trusting the request's success.
|
||||
$sig = Get-AuthenticodeSignature $signed
|
||||
if (-not $sig.SignerCertificate) { throw "$exe came back from SignPath without a signature ($($sig.Status))" }
|
||||
# The test certificate is self-signed, so Status is not Valid until release-signing.
|
||||
Write-Output "$exe signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
|
||||
Copy-Item -Force $signed "$stage/$exe"
|
||||
}
|
||||
- name: Package the signed binaries (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
|
||||
- name: Upload the unsigned MSI
|
||||
id: unsigned-msi
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signpath-unsigned-msi-windows-${{ matrix.arch }}
|
||||
path: dist/release/*.msi
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: Sign the MSI (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
wait-for-completion-timeout-in-seconds: 3600
|
||||
output-artifact-directory: target/windows-package/signed-msi-${{ matrix.arch }}
|
||||
- name: Put the signed MSI in place
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = Get-Item "dist/release/*.msi"
|
||||
$signed = "target/windows-package/signed-msi-${{ matrix.arch }}/$($msi.Name)"
|
||||
$sig = Get-AuthenticodeSignature $signed
|
||||
if (-not $sig.SignerCertificate) { throw "$($msi.Name) came back from SignPath without a signature ($($sig.Status))" }
|
||||
Write-Output "$($msi.Name) signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
|
||||
Copy-Item -Force $signed $msi.FullName
|
||||
"Windows ${{ matrix.arch }}: Authenticode-signed through SignPath (policy $env:SIGNPATH_SIGNING_POLICY, $($sig.SignerCertificate.Subject))." >> $env:GITHUB_STEP_SUMMARY
|
||||
- name: Note the unsigned build
|
||||
if: env.SIGN_WINDOWS != 'true'
|
||||
shell: pwsh
|
||||
run: '"Windows ${{ matrix.arch }}: unsigned (no SIGNPATH_API_TOKEN secret)." >> $env:GITHUB_STEP_SUMMARY'
|
||||
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
|
||||
@@ -11,3 +11,7 @@ __pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
# external corpus downloads (cargo xtask external-corpus, #1397): fetched at test time,
|
||||
# hash-pinned in tests/external-corpus.toml; never commit case data
|
||||
/corpus/
|
||||
|
||||
@@ -1,222 +1,48 @@
|
||||
# SolidCraft — instructions for agents
|
||||
# SolidCraft: instructions for agents
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
|
||||
|
||||
## Product name
|
||||
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
|
||||
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
|
||||
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
|
||||
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
|
||||
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
|
||||
`/solidcraft/`; do not add permanent old-name Pages aliases.
|
||||
## Start here
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section.
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
unless it lists the decision as the owner's.
|
||||
## Hard rules (what enforces each)
|
||||
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
|
||||
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
|
||||
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
|
||||
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
|
||||
- Sorted lists (message catalogs, `mod` lines, languages) stay sorted: `cargo xtask lists`. Never edit generated files (`docs/features.md`, `THIRD-PARTY-LICENSES.txt`) in a PR.
|
||||
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
|
||||
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
|
||||
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
|
||||
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
|
||||
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
|
||||
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
|
||||
- Show observable changes with real screenshots or a recording, captured yourself.
|
||||
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers), the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations, and gives its class the icon the taskbar falls back
|
||||
on) 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
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
|
||||
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
|
||||
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
|
||||
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — ever.** Command icons are the original SVG masters in
|
||||
`assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
|
||||
**Before any icon, icon assignment or media edit, read `assets/AGENTS.md`,
|
||||
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical
|
||||
bases and named finished references there; never invent a parallel icon style. Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
|
||||
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
|
||||
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
|
||||
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
|
||||
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **One outer UI container**: all panels, floating sheets, menus, overlays and platform UI
|
||||
windows use `ui_kit::Container` (directly or through `Panel`, `Window`, `Confirm`, `Menu`).
|
||||
Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
|
||||
System/report windows use `ui_kit::Window`; its low-level host builder is kit-private.
|
||||
Do not construct raw egui hosts, outer popup/window frames or shadows in feature modules.
|
||||
`cargo xtask ui-containers` recursively checks source/examples in all current and future crates
|
||||
and apps. It runs in the fast repository CI job and `cargo xtask ci`, with no caller allowances.
|
||||
See `docs/ui-container-audit.md` for the inventory and legitimate content/native exceptions.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **Application code is Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
Local asset authoring, lint and review tools use Python 3.11+ as documented in
|
||||
`assets/AGENTS.md`; Python is not an application runtime dependency. The application exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
|
||||
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
|
||||
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
|
||||
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
|
||||
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
|
||||
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
|
||||
check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
|
||||
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
|
||||
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
|
||||
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
|
||||
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
|
||||
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
|
||||
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
|
||||
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
|
||||
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
|
||||
make its pending status clear at handoff; do not claim it merged without checking.
|
||||
|
||||
## Before you start: look at what is already open
|
||||
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
|
||||
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
|
||||
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
|
||||
work on the same issue, the same feature under another number, and PRs that change the files
|
||||
you are about to change (`gh pr list ... --json number,title,files`).
|
||||
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
|
||||
PRs for one change can't both land, and one of them is wasted work and CI.
|
||||
|
||||
- Keep the `Changes a default:` line in the PR description filled in, and don't open a second PR for
|
||||
an issue a lower-numbered open PR already closes; `pr-checks.yml` fails both (CONTRIBUTING.md, "Sending a change").
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
|
||||
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
|
||||
a claim before you have a branch.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
|
||||
request on a connection carries the launch's token as `"token"`: read it from
|
||||
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
|
||||
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
|
||||
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
|
||||
`ui.render` or `solidcraft-cli snapshot`.
|
||||
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
|
||||
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
New UI strings go in a new `assets/locales/pending/<slug>.json` and new ATTRIBUTION.md rows in a new
|
||||
`attribution/pending/<slug>.md`, never into the catalogs or the table (see the README in each folder).
|
||||
## Working on X: read the skill first
|
||||
| Working on | Read |
|
||||
|---|---|
|
||||
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
|
||||
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
|
||||
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
|
||||
| A dialog, panel, camera, icon, colour | `skills/solidcraft-ui/SKILL.md` |
|
||||
| Labels, messages, translations | `skills/solidcraft-ui-text/SKILL.md` |
|
||||
| Saving something new in a design, import, export | `skills/solidcraft-file-formats/SKILL.md` |
|
||||
| Preferences, a changed default | `skills/solidcraft-preferences/SKILL.md` |
|
||||
| Writing or running tests | `skills/solidcraft-tests/SKILL.md` |
|
||||
| Slow work, the window thread, wasm | `skills/solidcraft-performance/SKILL.md` |
|
||||
| Releases, installers, packages | `skills/solidcraft-packaging/SKILL.md` |
|
||||
| Running the app, control channel, MCP tools | `skills/solidcraft-agent-tools/SKILL.md` |
|
||||
| Building a part over MCP | `skills/solidcraft-modeling/SKILL.md` |
|
||||
| Changing an existing design | `skills/solidcraft-editing/SKILL.md` |
|
||||
| A bug report, a crash, a wrong result | `skills/solidcraft-reporting/SKILL.md` |
|
||||
| Checking, porting, designs in git | `skills/solidcraft-checking/SKILL.md` |
|
||||
| Assemblies, joints, motion | `skills/solidcraft-mechanisms/SKILL.md` |
|
||||
>>>>>>> 4a3e0772a (Agents: AGENTS.md becomes a router to focused skills, with a staleness check (Refs #6439))
|
||||
|
||||
@@ -174,6 +174,10 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/macos/dmg/background.tiff | SolidCraft contributors | original disk image window background (blueprint sheet: grid, double border, circles, centre lines, leader arrow, title block) drawn by assets/platforms/macos/dmg/make_background.py in IBM Plex Sans/Mono; 1x and 2x representations | MIT OR Apache-2.0 |
|
||||
| assets/platforms/macos/dmg/make_background.py | SolidCraft contributors | authoring script that draws the disk image background (Pillow); not part of any build | MIT OR Apache-2.0 |
|
||||
| assets/platforms/macos/dmg/layout.json | SolidCraft contributors | disk image window size, icon size and icon positions shared by the background script and settings.py | MIT OR Apache-2.0 |
|
||||
| assets/platforms/macos/dmg/settings.py | SolidCraft contributors | dmgbuild settings (MIT, https://github.com/dmgbuild/dmgbuild) for the SolidCraft disk image; original settings, no dmgbuild code copied | MIT OR Apache-2.0 |
|
||||
| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
@@ -284,6 +288,8 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -517,6 +523,10 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
|
||||
| assets/icons/ui/design/svg/model/path_flange.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/hem.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/hem.json`; gitignored), original sheet metal demonstration | MIT OR Apache-2.0 |
|
||||
| docs/book/scripts/hem.json | SolidCraft contributors | original parametric recipe for an 80 × 50 mm sheet with an 8 mm returned edge, authored for the command guide | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/clay.json | SolidCraft contributors | original SolidCraft theme file (Clay, light): warm paper palette chosen for SolidCraft | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/clay-dark.json | SolidCraft contributors | original SolidCraft theme file (Clay Dark): warm charcoal palette chosen for SolidCraft | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
+26
-7
@@ -1,8 +1,10 @@
|
||||
# Contributing to SolidCraft
|
||||
|
||||
Agents: start from the router in [AGENTS.md](AGENTS.md), which points to a short skill in [`skills/`](skills/README.md) for each kind of work. This page stays the full human guide.
|
||||
|
||||
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
|
||||
You sketch on a plane, constrain the sketch, turn profiles into solids, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, macOS, Linux), in
|
||||
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
|
||||
model with.
|
||||
|
||||
@@ -11,7 +13,7 @@ build it, how the code is laid out, the few rules that aren't negotiable, and wh
|
||||
needed most.
|
||||
|
||||
Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
|
||||
[roadmap](docs/roadmap-detail.md) for implemented capabilities and current priorities.
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
@@ -130,7 +132,9 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every crash fix comes with a regression test.
|
||||
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
|
||||
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
|
||||
`crates/engine/src/cmd/` with input and output schemas (see
|
||||
[Adding a command](docs/book/src/dev/commands.md#adding-a-command); a test warns about a
|
||||
command without them, and will fail it once required), engine tests that measure the result (volume, area,
|
||||
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
|
||||
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
|
||||
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
|
||||
@@ -190,9 +194,11 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
|
||||
shared bound on top-level test/side commands for `cargo xtask test` and `cargo xtask ci`.
|
||||
For example, `SOLIDCRAFT_TEST_JOBS=2` allows two commands at once; the sequential wasm/doc
|
||||
worker consumes one of those slots. Every binary and side check still runs after failures.
|
||||
Without the variable the existing concurrency is unchanged. `RUST_TEST_THREADS`,
|
||||
`ACTION_MATRIX_JOBS` and Cargo's build-job setting remain independent caller overrides;
|
||||
this limit does not choose their values or change test filters, timeouts or baselines.
|
||||
Without the variable the existing concurrency is unchanged. With it set, each test binary
|
||||
inherits `RUST_TEST_THREADS` and `ACTION_MATRIX_JOBS` of `max(1, cpus / jobs)` — the machine's
|
||||
parallelism shared between the binaries in flight — unless you set either one explicitly;
|
||||
explicit caller overrides always win. Cargo's build-job setting remains an independent caller
|
||||
override; this limit does not change test filters, timeouts or baselines.
|
||||
Tests run beside each other on a busy machine, so a test must not depend on wall-clock timing.
|
||||
|
||||
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
|
||||
@@ -274,6 +280,9 @@ and a fresh CI run:
|
||||
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
|
||||
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
|
||||
sorted). Add a new module into the existing group, not as a new group at the end.
|
||||
- **Commands without schemas** (`crates/engine/src/schema/unconverted.txt`): sorted, one id per
|
||||
line, and it only shrinks. Converting a command removes its line; a new command is never added
|
||||
to it.
|
||||
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
|
||||
checks it).
|
||||
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
|
||||
@@ -334,6 +343,16 @@ Changes with no useful observable behavior do not need screenshot or video evide
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
To show a picture without committing it, attach the media to the PR (it goes to the PR's own
|
||||
attachment storage and renders inline in the comment). A gist is the better home for a log or
|
||||
another text file, and you link it; a gist does not render a picture inline. The review artifacts
|
||||
stay out of the diff that way, and nothing has to be pushed to a branch of its own.
|
||||
|
||||
Attaching works in the browser for anyone who can comment: drag the file into a comment on the
|
||||
pull request. A command-line agent without a browser session usually cannot attach a file (the
|
||||
upload needs write access to this repository). Say so in the pull request, and the person running
|
||||
the agent or a maintainer can attach it.
|
||||
|
||||
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
|
||||
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
|
||||
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
|
||||
@@ -389,7 +408,7 @@ In order of how much they'd help users:
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
[ROADMAP.md](docs/roadmap-detail.md)), but anyone who wants to own one is welcome.
|
||||
|
||||
## Updating the generated feature list
|
||||
|
||||
|
||||
Generated
+21
-17
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4240,11 +4240,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4265,7 +4265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4282,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4302,7 +4302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4311,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4331,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4355,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4375,7 +4375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4384,7 +4384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4398,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4409,14 +4409,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4445,12 +4445,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
@@ -5983,12 +5984,15 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"syn 2.0.119",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## Download
|
||||
|
||||
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
|
||||
- **Windows:** the [installer][windows-msi].
|
||||
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
|
||||
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
@@ -96,8 +96,10 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
|
||||
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
|
||||
runs its own design session, or with `--connect` it drives the running app so you can watch.
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
|
||||
example session: [docs/mcp.md](docs/mcp.md).
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
|
||||
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
|
||||
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
|
||||
[docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
+80
-694
@@ -1,696 +1,82 @@
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
|
||||
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
|
||||
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
|
||||
so edge-touching assembly neighbours stay separate regardless of body order (#226).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
cycles reported with their path, rename follows references, deletes list their users):
|
||||
user parameters with units and expressions (+-*/, ^,
|
||||
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
|
||||
d1, d2… for every dimension and feature input, references between parameters with a
|
||||
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
|
||||
CSV/JSON import and export.
|
||||
3. Components and assemblies with joints (M9).
|
||||
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. macOS Developer ID signing and notarization are in the release workflow (#5749; they switch on when the Apple secrets exist). Windows installer signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
|
||||
|
||||
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
|
||||
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
|
||||
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
|
||||
applies the same gestures to model and Render views and blocks orbit on flat pages.
|
||||
|
||||
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
|
||||
action tiles, recent rows and sample cards, with recovery review and Save As/Export
|
||||
entry points. Document loading, sample caches and recovery safety retain their
|
||||
existing command paths.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- Modelled threads preview their cut before OK using the background worker on desktop.
|
||||
Browser users apply the cut with OK to avoid blocking on every edit.
|
||||
|
||||
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
|
||||
pitch selectors expose the existing metric designation parameter; custom designations stay
|
||||
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
|
||||
full/partial-length options remain available. This does not add tolerance classes or
|
||||
left-hand threads.
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
|
||||
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
thermal data, per-property sources/conditions and isotropic/orthotropic stability
|
||||
checks through read-only `material.properties.validate`. It does not assign or
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
- Directional elasticity (#994) queries the validated card in material-local axes
|
||||
through read-only `material.properties.directional`, with source/condition review.
|
||||
It does not assign body frames, predict structural behavior or enable a solver.
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
|
||||
selected appearance/material revisions into existing design definitions in one undo step.
|
||||
Appearance offers local loading/search/export; physical materials use existing commands.
|
||||
This slice is independent of the render stack and does not change the document format.
|
||||
See [the contract and limits](docs/book/src/dev/local-libraries.md).
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
appearances, editable wood mapping, and parameterized component/assembly families.
|
||||
Stable identity/revision, copied design definitions, provenance and resource limits
|
||||
apply from the first implementation. Existing material, appearance and standard-part
|
||||
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
|
||||
for source/license review and acceptance gates. This is planning only: pack loading,
|
||||
texture rendering and expanded component families are not implemented by this entry.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
|
||||
|
||||
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
gamut clipping only when explicitly requested. It is not a renderer/resource
|
||||
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
|
||||
|
||||
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
|
||||
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
|
||||
stay unchanged. HDRI, transmission and textures remain separate gates.
|
||||
See [the consumer and limitations](docs/render-opaque-pbr.md).
|
||||
|
||||
- Combined integration is tracked in #842, with independent scalar libraries in
|
||||
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
|
||||
Render/library consumers use the merged asynchronous picker (#1127). Material-card
|
||||
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
|
||||
|
||||
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
|
||||
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
|
||||
results stay outside persistence/undo; native workers and cooperative browser tasks
|
||||
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
|
||||
|
||||
- R3 adds an overlay-free product scene and the integrated Render workspace, with
|
||||
runtime GPU host capabilities, per-document drafts and named camera/background
|
||||
controls. Design keeps its camera and drawing policy. Raster transparency remains
|
||||
approximate; PBR and environments are later slices. See
|
||||
[workspace and scene contracts](docs/render-workspace.md).
|
||||
|
||||
- R2 adds bounded named render setups in document format 21 and the read/save
|
||||
`render.settings.get/set` commands. Proven render-only changes preserve geometry
|
||||
and prepared world data through undo/redo and checkpoint restore. This slice does
|
||||
not add a Render workspace, PBR, environments or image jobs; see
|
||||
[settings and refresh policy](docs/render-settings.md).
|
||||
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
|
||||
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
|
||||
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
|
||||
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
|
||||
|
||||
- The initial opaque surface numerical contract for #1004 provides bounded linear
|
||||
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
||||
The numerical module alone advertises no capabilities; the installed consumer above
|
||||
provides rendering. See
|
||||
[the model, evidence and remaining consumer gates](docs/render-surface.md).
|
||||
|
||||
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
The legacy Design snapshot route remains unchanged. Later slices above provide the
|
||||
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Operation length fields share design-unit defaults and input evaluation, including secondary
|
||||
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
|
||||
handles and sketch previews use the same lengths as committed geometry. Existing stored
|
||||
millimetre values retain their size when edited in a design using another unit (#3271).
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
|
||||
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
|
||||
associative axis expressions and construction-axis references remain unsupported.
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
|
||||
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
|
||||
it), drives it like any other dimension and keeps the circle on its side of the line.
|
||||
- Sketch Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
|
||||
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
|
||||
they touch, how far they overlap, or the narrowest gap between nested circles).
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
|
||||
and pocket depths, interference volumes and parts-library lengths also use design units.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
|
||||
reporting for unexpected results that did not produce an error.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
|
||||
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
|
||||
journal) leaves a local report with the backtrace, version and commit, the command and the
|
||||
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
|
||||
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
|
||||
Grok login. See [agent chat](docs/agent-chat.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
|
||||
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
|
||||
script after a step gives the design at that step. Appearances, folders, views and
|
||||
configurations are listed as left out; components and joints are not supported yet.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
|
||||
durable supported profile/face references, endpoint dimensioning and contextual reporting.
|
||||
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
|
||||
authored splits and actual creases remain protected. Wireframe retains physical edges.
|
||||
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
|
||||
prisms with separated circular holes; unsupported shapes retain the existing strict check.
|
||||
- Common-bore Union retries check additional boundary curves before recutting the bore,
|
||||
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
|
||||
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
|
||||
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## Workspace layouts (#2029)
|
||||
|
||||
- General preferences save Floating or Compact using the universal UI containers.
|
||||
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
|
||||
flush beneath them with a square join, and opens tool sheets below the compass.
|
||||
Sheets remain draggable, rounding all corners away from their right dock; footer
|
||||
backgrounds follow the same corners. A separate preference hides ribbon icon labels
|
||||
for a shorter ribbon in either layout.
|
||||
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
|
||||
retains the active tool, camera, selection and design; system windows and menus
|
||||
keep their existing behavior.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
|
||||
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
|
||||
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
|
||||
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
multi-face ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
curved chamfer chains with straight planar ends can terminate across two existing planar
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
|
||||
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
|
||||
checked within 0.1% of analytic offset slices), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
with the edge between them too, the corner becomes a sphere octant),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs); knife edges, where the two faces have opposite
|
||||
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
|
||||
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
|
||||
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
|
||||
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
|
||||
other, except a round after a round of the edge between the wall and a flat face that it
|
||||
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
|
||||
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
|
||||
curves is refused as too large rather than returning an unmeshable face (#1113).
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
curved wall qualification rejects false transverse axes instead of filling holes.
|
||||
Bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
their neighbours are planes or cylinders. Other coincident curved configurations can still
|
||||
fail. Bounded containment classification preserves fully internal cavities and nested
|
||||
material islands independently of shell order, with STEP round-trip and material-membership
|
||||
regressions. Near-contact curved shells use the general boolean path rather than a
|
||||
containment shortcut. Spheres are built from six pole-free patches.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
|
||||
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
|
||||
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
|
||||
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
|
||||
offset surfaces and pcurve-only edges are not read yet,
|
||||
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
|
||||
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
|
||||
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
|
||||
references resolve by name first, follow splits and merges, and warn when the entity split,
|
||||
merged or vanished, or was found by geometry only (operations that report no history yet).
|
||||
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
|
||||
source certificates with checked target and author occurrence paths. A missing or changed
|
||||
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
|
||||
retaining current geometry and dimension identities. Unsupported modified faces need an
|
||||
explicit fixed plane. Legacy attachments retain their original resolver behavior.
|
||||
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
|
||||
and Revolve To Object on a curved face cut the tool back to the target's surface.
|
||||
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
|
||||
turns the profile and scales it (or stretches it across to the rail) against the start; two
|
||||
rails carry it between them. Rails along a path with sharp corners are refused for now.
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
|
||||
speeds without changing stored nodes or relaxing the distance tolerance (#971).
|
||||
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
|
||||
editable source updates the derived boundary live. Break Association restores point editing.
|
||||
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
Newly captured adaptive profile references with complete source coverage retain their outer
|
||||
and hole identities through span-count changes, including parameter edits after extrusion.
|
||||
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
|
||||
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
|
||||
reselection. Format 12 stores the new bounded whole-boundary references.
|
||||
Projected analytic boundaries follow nested loop identities through resizing, including
|
||||
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
|
||||
can lose or regain corner arcs during an inset while preserving reserved curve identities
|
||||
and linked profile selections. Format 12 saves those associations; compatible older closed
|
||||
offsets upgrade structurally without changing their curve IDs. Breaking an association
|
||||
freezes its current boundary and materializes downstream profile references. Child offsets
|
||||
of a boundary with retired arcs still require breaking its association first; mixed or
|
||||
nonconvex junctions and retained references to disappearing individual arcs remain limited.
|
||||
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
|
||||
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
|
||||
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
|
||||
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
|
||||
use the sketch's actual target, author or uniquely placed third-component frame.
|
||||
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
|
||||
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
|
||||
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
|
||||
use the sketch-normal direction for each selected profile, including To Object and curved
|
||||
target trimming, independently of the selected axis sign.
|
||||
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
|
||||
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
|
||||
radial walls. New features infer a unique eligible cylinder when the face field is empty;
|
||||
ambiguous targets require an explicit face. Saved target
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
|
||||
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
|
||||
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
|
||||
between sections, and sections with holes cannot collapse to a point.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
the origin; clicked UI regressions verify geometry, closed profiles and undo.
|
||||
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
|
||||
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
|
||||
invalidate the display cache without changing CAD geometry or timeline history. Work
|
||||
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
|
||||
The Sketch Palette scrolls its options on short windows and enlarged displays while
|
||||
keeping constraint status and Finish Sketch visible.
|
||||
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
|
||||
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
|
||||
rejected values keep the editable field focused.
|
||||
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
|
||||
checked occurrence paths. Active nested occurrences author planar sketches and solids in
|
||||
their selected frame. Pending moves compose through parents; dialog cancellation restores
|
||||
its selected move. Source materials and body/face appearances follow transformed instances.
|
||||
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
|
||||
independent feature/input histories, styles and internal motion records, with bounded geometry
|
||||
checks and explicit external-link notices. Paste New selects the copy while preserving the active
|
||||
authoring context. Imported periodic STEP seams survive placement and reexport with shared
|
||||
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
|
||||
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
|
||||
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
|
||||
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
|
||||
- Movable window title bars follow the app's dark/light header palette. Radial right-click
|
||||
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
|
||||
- Desktop distribution: running Windows windows and executable resources use the app icon;
|
||||
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
|
||||
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
|
||||
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
|
||||
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
|
||||
callbacks to prevent black frames. Other backends retain the requested sample count, and
|
||||
stored preferences remain unchanged.
|
||||
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
|
||||
restrict values. Preview explains effective values without modifying the document; applying
|
||||
rules validates rows atomically. Length expressions retain their authoring units across
|
||||
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
|
||||
Component styles and certified semantic face styles are available; unsupported lineage refuses
|
||||
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
|
||||
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
|
||||
sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
check. Under-resolved fits return an error.
|
||||
- Contact driving checks the trajectory between clear endpoints, including linked joints,
|
||||
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Assembly dragging
|
||||
|
||||
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
|
||||
construction-plane selection of 3D wires and loft guide rails remain separate work.
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
## Construction on 3D paths
|
||||
|
||||
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
|
||||
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Design properties (#1015)
|
||||
|
||||
- Designs carry project, part number, revision, author, organization, description and licence
|
||||
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
|
||||
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
|
||||
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
|
||||
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
|
||||
Per-component properties (a part number per component) can follow with the same type.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
to eight workers, retaining serial traversal, result order and persistent source identities.
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||
feature warnings and structured inspection data. Diagnostics survive cached
|
||||
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
|
||||
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
|
||||
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
|
||||
on such a pair, both faces are split along the line of their cylinder through the touching
|
||||
points (the solids are unchanged), the boolean is taken of those, and its history is composed
|
||||
back over the operands as given (#788). The result keeps the split lines as edges of the bore
|
||||
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
|
||||
in a 45 mm block with a tool of 80 mm) is still refused.
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
The short version, for people who use SolidCraft. The detailed developer status (milestone
|
||||
tables, limitations, measurements) is in [docs/roadmap-detail.md](docs/roadmap-detail.md).
|
||||
|
||||
## What SolidCraft is
|
||||
|
||||
SolidCraft is open-source parametric 3D CAD written in Rust. You sketch, constrain, extrude, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app and in the browser
|
||||
([try it](https://solidcraft.io/app)), and it has a command-line tool and an MCP server so that
|
||||
scripts and AI agents can drive the same commands you use.
|
||||
|
||||
## Works today
|
||||
|
||||
- Sketching with a constraint solver, and the usual create and modify tools.
|
||||
- Extrude, revolve, loft, sweep, coil, fillet, chamfer, shell, draft, holes, threads,
|
||||
patterns, mirror and split.
|
||||
- Parameters with units and expressions, and configurations.
|
||||
- Faces and edges keep their names through edits, so references survive changes.
|
||||
- Components, assemblies, joints, motion studies and exploded views.
|
||||
- Sheet metal with flat patterns and DXF export, and a plastic-enclosure subset.
|
||||
- A Render workspace with GPU rendering, studio lighting, materials and image export
|
||||
(first release in 0.22.0).
|
||||
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, and FreeCAD and Rhino files.
|
||||
- Autosave and crash recovery, and the browser build of the same app.
|
||||
|
||||
The full list of commands is in [docs/features.md](docs/features.md). Release notes for each
|
||||
version are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
|
||||
## In progress now
|
||||
|
||||
Each feature below is a [milestone](https://github.com/tangency-labs/solidcraft/milestones) holding
|
||||
the issues for it, so the count shows how far along it is. Counts are as of 2026-10-25, one per
|
||||
feature or request. Work merged on a feature branch (`drawings`, `render`) counts as built but
|
||||
reaches the app only when that branch merges. They are rough, not a promise.
|
||||
|
||||
- **[Rendering & materials](https://github.com/tangency-labs/solidcraft/milestone/17)**: in progress, 9 of 34 pieces done, 3 of them built only on the `render` branch and in the app when it merges. The basic Render workspace is already in releases (0.22.0); the interface rework (#6281) and the material and light libraries are what is left.
|
||||
- **[Drawings](https://github.com/tangency-labs/solidcraft/milestone/8)**: in progress, 3 of 21 pieces built (2 in releases, 1 on the `drawings` branch), and 3 more partly built there (sheets and borders, bill-of-materials types, the paper scene). Not in the app yet: it lands when the `drawings` branch merges. Today you can export the current view as a DXF.
|
||||
- **[Sheet metal](https://github.com/tangency-labs/solidcraft/milestone/15)**: in progress, 1 of 30 requests done. Flanges, hems, unfold and flat patterns already work; the 30 are extensions, mostly the per-edge flange options (segments, miters, partial flanges, reliefs) and bend allowance.
|
||||
- **[Sketching](https://github.com/tangency-labs/solidcraft/milestone/14)**: in progress, 12 of 50 pieces done. Dimension types, selection and snapping options.
|
||||
- **[Solid modeling](https://github.com/tangency-labs/solidcraft/milestone/2)**: in progress, 9 of 50 pieces done. Blends, holes, threads and patterns.
|
||||
- **[Assemblies & joints](https://github.com/tangency-labs/solidcraft/milestone/3)**: in progress, 4 of 19 pieces done. Joints, motion studies and exploded views already work; placement rules, linked sub-assemblies, fastener stacks and weldment corners are the pieces left.
|
||||
- **[Import & export](https://github.com/tangency-labs/solidcraft/milestone/5)**: in progress, 7 of 39 pieces done. FreeCAD, SolidWorks and point-cloud import, and mesh export options.
|
||||
- **[AI agents & MCP](https://github.com/tangency-labs/solidcraft/milestone/18)**: in progress, 2 of 8 pieces done. Agent chat and the MCP server.
|
||||
- **[Interface, web app & plugins](https://github.com/tangency-labs/solidcraft/milestone/4)**: in progress, 31 of 96 pieces done. Ribbon redesign, navigation, themes, the web app and plugins.
|
||||
- **[SMLib kernel migration](https://github.com/tangency-labs/solidcraft/milestone/1)**: in progress, 1 tracking issue open. The kernel is moving from truck to NVIDIA's SMLib (usd-brep, Apache-2.0) on the `kernel-smlib` branch; it is not in releases yet. Tracked in [#4012](https://github.com/tangency-labs/solidcraft/issues/4012).
|
||||
|
||||
## Next
|
||||
|
||||
- **[Surfacing](https://github.com/tangency-labs/solidcraft/milestone/16)**: next, 6 of 14 pieces done. Patch, trim, blend and match exist; the rest of the surface toolset is planned.
|
||||
- **[Manufacturing / CAM](https://github.com/tangency-labs/solidcraft/milestone/9)**: next, 1 pieces tracked, none done. There is no CAM in SolidCraft yet. Send to Slicer and a first toolpath slice are in review; proposals are [#1153](https://github.com/tangency-labs/solidcraft/issues/1153) and [#1621](https://github.com/tangency-labs/solidcraft/issues/1621).
|
||||
|
||||
Features are not tied to a release or a date. Work lands in the daily releases as it is finished.
|
||||
Anything without a milestone, such as bug fixes, is not a feature and is not listed here.
|
||||
|
||||
## How releases work
|
||||
|
||||
- Releases ship daily, as numbered versions (v0.22.0, v0.22.1) with nightly pre-releases in
|
||||
between. They are delivered through the in-app updater.
|
||||
- SolidCraft tells you when a newer stable release exists and never updates without asking.
|
||||
Where it can replace itself safely (AppImage, Windows installer and portable builds) there is
|
||||
an **Update now** button; downloads are checked against the release's SHA-256 list. See
|
||||
[updates and privacy](docs/book/src/guide/updates.md).
|
||||
- Design files carry a format number. A new version opens designs from older versions, but an
|
||||
older version refuses a design saved by a newer one (0.22.0 writes format 35, and 0.21.0 and
|
||||
earlier will not open it). Each release's notes say when the format changed. Keep a copy of
|
||||
an important design before you move to a new version.
|
||||
|
||||
## Licence
|
||||
|
||||
SolidCraft is licensed MIT OR Apache-2.0, at your option; see [LICENSE-MIT](LICENSE-MIT) and
|
||||
[LICENSE-APACHE](LICENSE-APACHE). Versions already released stay available under those licences
|
||||
permanently.
|
||||
|
||||
## How to follow along
|
||||
|
||||
- Feature milestones: <https://github.com/tangency-labs/solidcraft/milestones>
|
||||
- Issues: <https://github.com/tangency-labs/solidcraft/issues> (area labels such as
|
||||
`area:kernel`, `area:sketch` and `area:ui` group them)
|
||||
- Discord: <https://discord.gg/FtU4NQNVDH>
|
||||
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
|
||||
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
@@ -262,8 +262,7 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
|
||||
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
if solidcraft_engine::io::is_design_container(&bytes) {
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::new(doc);
|
||||
session.path = Some(path.to_owned());
|
||||
let mut session = Session::opened_at(doc, None, Some(path));
|
||||
session.param_check = check;
|
||||
return Ok(session);
|
||||
}
|
||||
@@ -340,6 +339,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let json_out = json_out(args)?;
|
||||
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
@@ -444,6 +444,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
|
||||
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(usage)?;
|
||||
let mut s = load(path)?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
@@ -843,7 +844,7 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::opened(doc, saved.as_ref());
|
||||
let s = Session::opened_at(doc, saved.as_ref(), (!step).then_some(path.as_str()));
|
||||
drop(read);
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
|
||||
// only the features whose state a thinned file left out.
|
||||
|
||||
@@ -395,8 +395,10 @@ fn actions(s: &Session) -> Vec<Action> {
|
||||
let r = round3((shortest * 0.1).max(0.01));
|
||||
let mut add = |action, act| out.push(Action { entity: entity.clone(), action, act });
|
||||
if !smooth(e, mid) {
|
||||
add("edge.fillet", Act::Run("solid.fillet", json!({"edges": [pt(mid)], "radius": r})));
|
||||
add("edge.chamfer", Act::Run("solid.chamfer", json!({"edges": [pt(mid)], "distance": r})));
|
||||
// The midpoint is interior to this edge, but other bodies (including component
|
||||
// copies in their local frames) can have coincident edges. Retain the picked body.
|
||||
add("edge.fillet", Act::Run("solid.fillet", json!({"body": world, "edges": [pt(mid)], "radius": r})));
|
||||
add("edge.chamfer", Act::Run("solid.chamfer", json!({"body": world, "edges": [pt(mid)], "distance": r})));
|
||||
}
|
||||
let dir = straight(pts);
|
||||
if dir.is_some() {
|
||||
@@ -1278,6 +1280,41 @@ fn rectangle(plane: Value, p0: [f64; 2], p1: [f64; 2]) -> [Value; 3] {
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_round_actions_keep_the_picked_body() {
|
||||
for (corner, length) in [([10, 0, 20], 4), ([0, 0, 0], 20)] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Plate"})).unwrap();
|
||||
s.execute(
|
||||
"solid.box",
|
||||
&json!({"corner": corner, "length": length, "width": length, "height": length, "body_name": "Block", "operation": "new"}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut tried = 0;
|
||||
for a in actions(&s) {
|
||||
if !a.entity.starts_with("Plate/") || !matches!(a.action, "edge.chamfer" | "edge.fillet") {
|
||||
continue;
|
||||
}
|
||||
let Act::Run(id, ref p) = a.act else { continue };
|
||||
let q = &p["edges"][0];
|
||||
let x = q[0].as_f64().unwrap();
|
||||
if q[1] != 0.0 || q[2] != 20.0 || !(0.0..20.0).contains(&x) {
|
||||
continue;
|
||||
}
|
||||
// Block either meets this midpoint or coincides with the whole edge.
|
||||
assert_eq!(p["body"], "Plate", "{}: {p}", a.action);
|
||||
assert_eq!(q, &json!([10.0, 0.0, 20.0]));
|
||||
let mut unscoped = p.clone();
|
||||
unscoped.as_object_mut().unwrap().remove("body");
|
||||
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
|
||||
tried += 1;
|
||||
}
|
||||
assert_eq!(tried, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
|
||||
for (cmd, size) in [("solid.fillet", "radius"), ("solid.chamfer", "distance")] {
|
||||
|
||||
@@ -22,6 +22,19 @@ impl Scratch {
|
||||
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn saving_script(&self) -> (PathBuf, PathBuf) {
|
||||
let path = self.0.join("saving.json");
|
||||
let saved = self.0.join("script-ran.solidcraft");
|
||||
std::fs::write(
|
||||
&path,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
|
||||
{"command": "file.save_as", "params": {"path": saved}}]})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
(path, saved)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
@@ -43,6 +56,92 @@ fn body_count(path: &Path) -> Value {
|
||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_invalid_options_before_script_execution_or_output_writes() {
|
||||
let dir = Scratch::new();
|
||||
let (script, script_saved) = dir.saving_script();
|
||||
let cases = [
|
||||
(&["--out"][..], "--out needs a value"),
|
||||
(&["--save"][..], "--save needs a value"),
|
||||
(&["--in"][..], "--in needs a value"),
|
||||
(&["--out", "--quiet"][..], "--out needs a value"),
|
||||
(&["--ouut", "part.step"][..], "doesn't take --ouut"),
|
||||
(&["--unknown"][..], "doesn't take --unknown"),
|
||||
];
|
||||
for existing in [false, true] {
|
||||
let saved = dir.0.join(format!("{existing}.solidcraft"));
|
||||
let exported = dir.0.join(format!("{existing}.step"));
|
||||
let report = dir.0.join(format!("{existing}.json"));
|
||||
let outputs = [&saved, &exported, &report];
|
||||
if existing {
|
||||
for path in outputs {
|
||||
std::fs::write(path, b"existing output").unwrap();
|
||||
}
|
||||
}
|
||||
for (extra, message) in cases {
|
||||
let output = cli()
|
||||
.arg("run")
|
||||
.arg(&script)
|
||||
.arg("--save")
|
||||
.arg(&saved)
|
||||
.arg("--out")
|
||||
.arg(&exported)
|
||||
.arg("--json-out")
|
||||
.arg(&report)
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains(message), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
|
||||
for path in outputs {
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"existing output");
|
||||
} else {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_and_inspect_reject_invalid_options_before_script_execution_or_reporting() {
|
||||
let dir = Scratch::new();
|
||||
let (script, script_saved) = dir.saving_script();
|
||||
let cases = [
|
||||
(&["--in"][..], "--in needs a value"),
|
||||
(&["--in", "--quiet"][..], "--in needs a value"),
|
||||
(&["--out", "part.step"][..], "doesn't take --out"),
|
||||
(&["--unknown"][..], "doesn't take --unknown"),
|
||||
];
|
||||
for command in ["eval", "inspect"] {
|
||||
for existing in [false, true] {
|
||||
let report = dir.0.join(format!("{command}-{existing}.json"));
|
||||
if existing {
|
||||
std::fs::write(&report, b"existing report").unwrap();
|
||||
}
|
||||
for (extra, message) in cases {
|
||||
let output = cli().arg(command).arg(&script).arg("--json-out").arg(&report).args(extra).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{command} {extra:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains(message),
|
||||
"{command} {extra:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&report).unwrap(), b"existing report");
|
||||
} else {
|
||||
assert!(!report.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_loads_both_design_encodings_by_contents() {
|
||||
let dir = Scratch::new();
|
||||
|
||||
@@ -23,6 +23,7 @@ solidcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
|
||||
@@ -114,6 +114,46 @@ pub fn opens_design(extensions: &[&str]) -> bool {
|
||||
extensions.is_empty() || extensions.iter().any(|e| solidcraft_engine::io::DESIGN_EXTS.contains(e))
|
||||
}
|
||||
|
||||
/// Where an Open for `extensions` (what the command reads; `&[]`: anything) is answered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum OpenRoute {
|
||||
/// The browser's open dialog for a design: the file keeps its handle, so Save writes back.
|
||||
Disk,
|
||||
/// The "Files in this browser" window, where the browser can't open a design from the disk.
|
||||
Panel,
|
||||
/// A plain file input that offers these types: the file is read into the engine's files and
|
||||
/// the command runs on it (Insert DXF or SVG, images, meshes, imported settings).
|
||||
Upload,
|
||||
}
|
||||
|
||||
/// Choose how an Open for `extensions` asks for its file; `api`: the browser can open a file
|
||||
/// from the disk and keep its handle.
|
||||
pub fn open_route(api: bool, extensions: &[&str]) -> OpenRoute {
|
||||
match (opens_design(extensions), api) {
|
||||
(true, true) => OpenRoute::Disk,
|
||||
(true, false) => OpenRoute::Panel,
|
||||
(false, _) => OpenRoute::Upload,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `accept` list of a file input for `extensions` (with or without their dot): ".dxf,.svg".
|
||||
pub fn accept_list(extensions: &[&str]) -> String {
|
||||
let list: Vec<String> = extensions
|
||||
.iter()
|
||||
.map(|e| e.trim().trim_start_matches('.'))
|
||||
.filter(|e| !e.is_empty())
|
||||
.map(|e| format!(".{}", e.to_ascii_lowercase()))
|
||||
.collect();
|
||||
list.join(",")
|
||||
}
|
||||
|
||||
/// The largest file an upload for `extensions` reads: images go into a canvas, which has a size
|
||||
/// limit, so a bigger one is refused before it is read. `None`: the importer checks the file.
|
||||
pub fn upload_limit(extensions: &[&str]) -> Option<usize> {
|
||||
let image = |e: &&str| ["png", "jpg", "jpeg"].contains(&e.trim_start_matches('.').to_ascii_lowercase().as_str());
|
||||
(!extensions.is_empty() && extensions.iter().all(image)).then_some(solidcraft_engine::doc::canvas::MAX_CANVAS_BYTES)
|
||||
}
|
||||
|
||||
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
|
||||
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"));
|
||||
@@ -140,7 +180,7 @@ mod web {
|
||||
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 super::{HANDLE_MAX_AGE_MS, is_abort, make_id, parse, path, plan_prune, save_type};
|
||||
use crate::files::{Files, err, read_handle};
|
||||
|
||||
const DB: &str = "solidcraft-disk-handles";
|
||||
@@ -294,7 +334,7 @@ mod web {
|
||||
let options = Object::new();
|
||||
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
|
||||
let extensions = Array::new();
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm"] {
|
||||
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"] {
|
||||
extensions.push(&e.into());
|
||||
}
|
||||
let accept = Object::new();
|
||||
@@ -315,11 +355,6 @@ mod web {
|
||||
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
|
||||
@@ -621,6 +656,21 @@ mod tests {
|
||||
assert!(!opens_design(&["json"]));
|
||||
}
|
||||
|
||||
/// Insert DXF or SVG, images and other non-design files get a file input that offers their
|
||||
/// types in every browser (#2052); designs keep the disk dialog or the browser's files.
|
||||
#[test]
|
||||
fn opens_of_other_files_use_a_file_input_for_their_types() {
|
||||
assert_eq!(open_route(true, &["dxf"]), OpenRoute::Upload);
|
||||
assert_eq!(open_route(false, &["png", "jpg", "jpeg"]), OpenRoute::Upload);
|
||||
assert_eq!(open_route(true, &[]), OpenRoute::Disk);
|
||||
assert_eq!(open_route(false, &["solidcraft"]), OpenRoute::Panel);
|
||||
assert_eq!(accept_list(&["dxf"]), ".dxf");
|
||||
assert_eq!(accept_list(&["png", ".JPG", "", "jpeg"]), ".png,.jpg,.jpeg");
|
||||
assert_eq!(accept_list(&[]), "");
|
||||
assert!(upload_limit(&["png", "jpg", "jpeg"]).is_some());
|
||||
assert_eq!(upload_limit(&["dxf"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_dialog_offers_the_type_of_the_name() {
|
||||
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
|
||||
|
||||
@@ -448,6 +448,18 @@ pub fn recovery_path() -> String {
|
||||
format!("{OPFS}{RECOVERY}")
|
||||
}
|
||||
|
||||
/// A recovery file lives in OPFS, but its relative links belong to the original design's
|
||||
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
|
||||
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
|
||||
let path = recovery_path();
|
||||
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
|
||||
solidcraft_engine::resolve_design_links(&mut doc, original_path);
|
||||
let bytes = solidcraft_engine::io::write_design_container(&doc).map_err(|e| e.to_string())?;
|
||||
vfs::insert(&path, bytes);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Pick files on the computer: the File System Access picker where there is one, else an
|
||||
/// `<input type=file>`. Read files arrive in `files.incoming`.
|
||||
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
|
||||
@@ -500,8 +512,9 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
// Everything the browser build imports, from the format registry: cadmpeg is pure Rust, so
|
||||
// FreeCAD and Rhino files are part of it.
|
||||
let accept = solidcraft_engine::io::formats::open_extensions(true).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
// FreeCAD and Rhino files are part of it; plus the DXF and SVG drawings Open and Insert take.
|
||||
let accept =
|
||||
solidcraft_engine::io::formats::open_extensions(true).iter().chain(&["dxf", "svg"]).map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
input.set_accept(&accept);
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
@@ -527,6 +540,67 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
input.click();
|
||||
}
|
||||
|
||||
/// Ask for one file of these types (`extensions`, e.g. `["dxf"]`) with a plain file input, the
|
||||
/// dialog every browser has. The answer is the file's path in the engine's files (under
|
||||
/// [`UPLOAD`], as for a file picked in the disk dialog), so the command that asked runs on it;
|
||||
/// `None` when the dialog was closed or the file could not be read.
|
||||
pub fn pick_upload(files: &Rc<Files>, extensions: &[&str]) -> impl std::future::Future<Output = Option<String>> + use<> {
|
||||
// Shown now, in the click: a file dialog needs the user's gesture.
|
||||
let chosen = choose_file(&disk::accept_list(extensions));
|
||||
let limit = disk::upload_limit(extensions);
|
||||
let files = files.clone();
|
||||
async move {
|
||||
let read = async {
|
||||
let value = JsFuture::from(chosen?).await.map_err(err)?;
|
||||
// Closing the dialog answers no file: nothing to say.
|
||||
let Ok(file) = value.dyn_into::<web_sys::File>() else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if limit.is_some_and(|max| !file.size().is_finite() || file.size() > max as f64) {
|
||||
return Err(format!("{name}: the file is too large"));
|
||||
}
|
||||
let bytes = blob_bytes(&file).await.map_err(|e| format!("{name}: {e}"))?;
|
||||
let path = format!("{UPLOAD}{name}");
|
||||
vfs::insert(&path, bytes);
|
||||
Ok(Some(path))
|
||||
};
|
||||
match read.await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
files.say(format!("open: {e}"), true);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a hidden `<input type=file>` offering `accept` (call in the click). The promise answers
|
||||
/// the picked `File`, or `null` when the dialog is closed (the input's `cancel` event).
|
||||
fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("this page can't show a file dialog")?;
|
||||
let input = doc.create_element("input").map_err(err)?.dyn_into::<web_sys::HtmlInputElement>().map_err(|_| "no file input".to_string())?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
input.set_attribute("style", "display:none").map_err(err)?;
|
||||
doc.body().ok_or("this page has no body")?.append_child(&input).map_err(err)?;
|
||||
let promise = js_sys::Promise::new(&mut |resolve: Function, _reject: Function| {
|
||||
// One answer for a pick or a cancel; read from the event, so it does not keep the input.
|
||||
let done = Closure::once_into_js(move |event: web_sys::Event| {
|
||||
let file = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()).and_then(|input| {
|
||||
input.set_onchange(None);
|
||||
input.set_oncancel(None);
|
||||
input.remove();
|
||||
input.files().and_then(|list| list.get(0))
|
||||
});
|
||||
let _ = resolve.call1(&JsValue::UNDEFINED, &file.map_or(JsValue::NULL, JsValue::from));
|
||||
});
|
||||
input.set_onchange(Some(done.unchecked_ref()));
|
||||
input.set_oncancel(Some(done.unchecked_ref()));
|
||||
});
|
||||
input.click();
|
||||
Ok(promise)
|
||||
}
|
||||
|
||||
/// Read files dropped on the page.
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Bounded HTTPS downloads, handed to the normal design-open command as unsaved copies.
|
||||
//! Bounded HTTPS or same-origin downloads, handed to the normal design-open command as unsaved copies.
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use js_sys::{Reflect, Uint8Array};
|
||||
@@ -87,14 +87,15 @@ impl Download {
|
||||
}
|
||||
|
||||
async fn fetch(value: &str) -> Reply {
|
||||
let url = design_url(value)?;
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let page_origin = window.location().origin().map_err(|_| "Cannot read page origin")?;
|
||||
let url = design_url(value, &page_origin)?;
|
||||
let name = url.path_segments().and_then(|mut segments| segments.next_back()).filter(|name| !name.is_empty()).unwrap_or("Design.solidcraft");
|
||||
let decoded = js_sys::decode_uri_component(name).ok().and_then(|name| name.as_string()).unwrap_or_else(|| name.to_owned());
|
||||
let name: String = decoded.strip_suffix(".solidcraft").unwrap_or(&decoded).chars().take(255).collect();
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let response = JsFuture::from(window.fetch_with_str(url.as_str())).await.map_err(|_| "Network request failed or the host refused CORS access")?;
|
||||
let response: web_sys::Response = response.dyn_into().map_err(|_| "Invalid download response")?;
|
||||
design_url(&response.url()).map_err(|_| "Design redirected to a URL that does not use HTTPS")?;
|
||||
design_url(&response.url(), &page_origin).map_err(|_| "Design redirected to a URL that does not use HTTPS or the page's own origin")?;
|
||||
if !response.ok() {
|
||||
return Err(format!("HTTP {} {}", response.status(), response.status_text()));
|
||||
}
|
||||
|
||||
+104
-28
@@ -59,16 +59,44 @@ impl Launch {
|
||||
fn persists(&self) -> bool {
|
||||
!self.embed
|
||||
}
|
||||
|
||||
fn full_app_url(&self, mut url: url::Url) -> String {
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
if let Some(open) = &self.open {
|
||||
url.query_pairs_mut().append_pair("open", open);
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn design_url(value: &str) -> Result<url::Url, String> {
|
||||
fn design_url(value: &str, page_origin: &str) -> Result<url::Url, String> {
|
||||
if value.len() > 16_384 {
|
||||
return Err("Design URL is too long".into());
|
||||
}
|
||||
let url = url::Url::parse(value).map_err(|_| "Invalid design URL")?;
|
||||
if url.scheme() != "https" || url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("Design links must use HTTPS without credentials".into());
|
||||
let decoded = percent_encoding::percent_decode_str(value).decode_utf8().map_err(|_| "Invalid design URL encoding")?;
|
||||
if decoded.chars().any(|ch| ch.is_control() || ch == '\\') {
|
||||
return Err("Design links must not contain backslashes or control characters".into());
|
||||
}
|
||||
// Check before URL parsing normalizes away dot segments. Decode escaped slashes too.
|
||||
let path = value.split(['?', '#']).next().unwrap_or_default();
|
||||
let path = percent_encoding::percent_decode_str(path).collect::<Vec<_>>();
|
||||
if path.split(|byte| *byte == b'/').any(|segment| segment == b"..") {
|
||||
return Err("Design links must not contain parent path segments".into());
|
||||
}
|
||||
let page = url::Url::parse(page_origin).map_err(|_| "Invalid page origin")?;
|
||||
let url = if value.starts_with('/') && !value.starts_with("//") {
|
||||
page.join(value).map_err(|_| "Invalid design path")?
|
||||
} else {
|
||||
url::Url::parse(value).map_err(|_| "Invalid design URL")?
|
||||
};
|
||||
if (url.scheme() != "https" && url.origin() != page.origin())
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err("Design links must use HTTPS or the page's own origin without credentials".into());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
@@ -330,7 +358,7 @@ mod web {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.on_hover_text("STEP, 3MF or STL into the open design")
|
||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
@@ -383,7 +411,7 @@ mod web {
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.on_hover_text("Into the open design (STEP, 3MF, STL)")
|
||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
@@ -498,15 +526,25 @@ mod web {
|
||||
});
|
||||
});
|
||||
let Some(recover) = answer else { return };
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
if recover {
|
||||
self.app.open_path(&files::recovery_path());
|
||||
let recovery = match files::prepare_recovery(path.as_deref()) {
|
||||
Ok(recovery) => recovery,
|
||||
Err(error) => {
|
||||
self.app.set_status(error, true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.app.open_path(&recovery);
|
||||
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
|
||||
return;
|
||||
}
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
}
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
@@ -707,14 +745,17 @@ mod web {
|
||||
let (restorable, restorer) = (files.clone(), files.clone());
|
||||
let services = if launch.embed { Services::default() } else { Services {
|
||||
// Open shows the browser's open dialog for a design where it has one
|
||||
// (the file keeps its handle, so Save writes back to it); otherwise,
|
||||
// and for inserting STEP or meshes, the browser's files window.
|
||||
pick_open: Some(Box::new(move |exts: &[&str]| {
|
||||
if disk::handles_open(exts) {
|
||||
return Box::pin(disk::pick_open(&opener));
|
||||
// (the file keeps its handle, so Save writes back to it), else the
|
||||
// browser's files window. A command that reads another kind of file
|
||||
// (Insert DXF or SVG, an image, a mesh, settings) gets a file dialog
|
||||
// offering that kind, and runs on the picked file.
|
||||
pick_open: Some(Box::new(move |exts: &[&str]| match disk::open_route(disk::supported(), exts) {
|
||||
disk::OpenRoute::Disk => Box::pin(disk::pick_open(&opener)),
|
||||
disk::OpenRoute::Upload => Box::pin(files::pick_upload(&opener, exts)),
|
||||
disk::OpenRoute::Panel => {
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
panel.panel.set(true);
|
||||
Box::pin(std::future::ready(None))
|
||||
})),
|
||||
// A design is saved to a file the user picks, or kept in the browser
|
||||
// where the browser can't pick one; everything else is downloaded.
|
||||
@@ -769,14 +810,7 @@ mod web {
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
|
||||
let font_language = app.preferences.language.clone();
|
||||
let linked = crate::linked::Download::start(launch.open.as_deref(), &cc.egui_ctx);
|
||||
let full_url = window.location().href().ok().and_then(|value| url::Url::parse(&value).ok()).map(|mut url| {
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
if let Some(open) = &launch.open {
|
||||
url.query_pairs_mut().append_pair("open", open);
|
||||
}
|
||||
url.to_string()
|
||||
});
|
||||
let full_url = window.location().href().ok().and_then(|value| url::Url::parse(&value).ok()).map(|url| launch.full_app_url(url));
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
launch,
|
||||
@@ -838,24 +872,53 @@ mod tests {
|
||||
assert!(!Launch::parse("?other=embed&reopen=https://example.org").embed);
|
||||
assert!(Launch::parse("?open=https://example.org/a").persists());
|
||||
assert_eq!(Launch::parse("?open=first&open=second").open.as_deref(), Some("first"));
|
||||
let launch = Launch::parse("?embed&open=/dutchman/jobs/abc/part.solidcraft%3Fx%3Da%2520b");
|
||||
assert!(launch.embed);
|
||||
assert!(!launch.persists());
|
||||
let page = url::Url::parse("http://solidcraft.io/?embed#view").expect("synthetic page");
|
||||
let full = url::Url::parse(&launch.full_app_url(page)).expect("full app URL");
|
||||
let reopened = Launch::parse(full.query().unwrap_or_default());
|
||||
assert_eq!(reopened.open, launch.open);
|
||||
assert_eq!(reopened.open.as_deref(), Some("/dutchman/jobs/abc/part.solidcraft?x=a%20b"));
|
||||
assert!(!reopened.embed);
|
||||
assert!(reopened.persists());
|
||||
assert!(full.fragment().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
|
||||
assert!(design_url("https://example.org/arm.solidcraft?download=1").is_ok());
|
||||
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
|
||||
for value in [
|
||||
"http://example.org/a",
|
||||
"data:application/json,{}",
|
||||
"blob:https://example.org/a",
|
||||
"javascript:alert(1)",
|
||||
"/arm.solidcraft",
|
||||
"https://user:secret@example.org/a",
|
||||
"//evil.example/x",
|
||||
"/../x",
|
||||
"/%2e%2e/x",
|
||||
"/a/%2E./x",
|
||||
"/a%2f..%2fx",
|
||||
"/a\\x",
|
||||
"/a%5cx",
|
||||
"/a\nx",
|
||||
"/a%00x",
|
||||
"/a%7fx",
|
||||
"/a%C2%85x",
|
||||
"/a%3f/../x",
|
||||
"/a/..#view",
|
||||
"/a/..?download=1",
|
||||
"https://solidcraft.io/a/../x",
|
||||
"http://user:secret@solidcraft.io/x",
|
||||
"http://solidcraft.io.evil.example/x",
|
||||
"http://solidcraft.io:8080/x",
|
||||
"relative.solidcraft",
|
||||
"",
|
||||
] {
|
||||
assert!(design_url(value).is_err(), "{value}");
|
||||
assert!(design_url(value, "http://solidcraft.io").is_err(), "{value}");
|
||||
}
|
||||
assert!(design_url(&format!("https://example.org/{}", "x".repeat(16_384))).is_err());
|
||||
assert!(design_url("http://redirect.example.org/a").is_err());
|
||||
assert!(design_url(&format!("https://example.org/{}", "x".repeat(16_384)), "http://solidcraft.io").is_err());
|
||||
assert!(design_url("http://redirect.example.org/a", "http://solidcraft.io").is_err());
|
||||
|
||||
let cap = solidcraft_engine::io::MAX_DESIGN_BYTES;
|
||||
assert!(check_download_size(0, cap as u64).is_ok());
|
||||
@@ -867,6 +930,19 @@ mod tests {
|
||||
assert!(check_download_size(usize::MAX, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_links_allow_same_origin_paths_and_redirects() {
|
||||
for origin in ["http://solidcraft.io", "https://solidcraft.io", "http://solidcraft.io:8080"] {
|
||||
assert_eq!(design_url("/a/b.solidcraft", origin).map(|url| url.to_string()), Ok(format!("{origin}/a/b.solidcraft")));
|
||||
assert!(design_url(&format!("{origin}/x"), origin).is_ok());
|
||||
// Final response URLs follow the same origin and scheme rules as the request.
|
||||
assert!(design_url("https://docs.solidcraft.io/x", origin).is_ok());
|
||||
assert!(design_url("http://other/x", origin).is_err());
|
||||
}
|
||||
assert!(design_url("http://solidcraft.io/x", "https://solidcraft.io").is_err());
|
||||
assert!(design_url("http://SOLIDCRAFT.io:80/x", "http://solidcraft.io").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ctrl_tab_is_left_to_the_browser() {
|
||||
// Ctrl+Tab and Ctrl+Shift+Tab (the event's key is "Tab" either way) switch browser tabs.
|
||||
|
||||
@@ -13,11 +13,15 @@ use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
use solidcraft_ui_egui::control::{DESIGN_WATCH, DesignWatch, Subscriber};
|
||||
|
||||
/// Connections served at once, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
/// How long a subscribed connection waits after a `design.changed` line before the next: what
|
||||
/// changes meanwhile (a parameter dragged) goes out as one line, the latest (#6985).
|
||||
const EVENT_GAP: Duration = Duration::from_millis(100);
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The token files this launch wrote (its control port, the agent chat's endpoint), removed
|
||||
@@ -365,10 +369,91 @@ fn busy_message(p: &Pending, method: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
|
||||
/// A subscribed connection's socket, for its replies and its events: each line is written whole
|
||||
/// and alone, so an event never lands inside a reply (#6985).
|
||||
struct Lines {
|
||||
socket: TcpStream,
|
||||
turn: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
fn send(&self, line: &Value) -> std::io::Result<()> {
|
||||
let _turn = self.turn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
send(&mut &self.socket, line)
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of
|
||||
/// their own until the connection ends.
|
||||
struct Subscription {
|
||||
lines: Arc<Lines>,
|
||||
stop: Arc<AtomicBool>,
|
||||
_counted: Subscriber,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Write `reply`, the window's answer to `control.subscribe` naming the design `seen`, on
|
||||
/// `out`, then send `design`'s changes after it: the reply goes out before any event. `None`,
|
||||
/// with nothing written, when no thread or socket was left for the events.
|
||||
fn start(
|
||||
design: &'static DesignWatch,
|
||||
counted: Subscriber,
|
||||
out: &TcpStream,
|
||||
seen: (String, u64),
|
||||
reply: &Value,
|
||||
) -> Option<(Self, std::io::Result<()>)> {
|
||||
let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) });
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let (l, s) = (lines.clone(), stop.clone());
|
||||
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen)).ok()?;
|
||||
let sent = send(&mut &lines.socket, reply);
|
||||
drop(first);
|
||||
Some((Subscription { lines, stop, _counted: counted }, sent))
|
||||
}
|
||||
}
|
||||
|
||||
/// The design a `control.subscribe` reply names.
|
||||
fn design_of(reply: &Value) -> Option<(String, u64)> {
|
||||
let result = reply.get("result")?;
|
||||
Some((result.get("document_id")?.as_str()?.to_string(), result.get("revision")?.as_u64()?))
|
||||
}
|
||||
|
||||
impl Drop for Subscription {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// The socket closes now, not when the thread next looks; a line blocked on a client that
|
||||
// stopped reading ends too.
|
||||
let _ = self.lines.socket.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `design.changed` lines of a subscribed connection, each time the design differs from the
|
||||
/// last one it was told of (`seen`), until `stop`.
|
||||
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64)) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue };
|
||||
if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() {
|
||||
break;
|
||||
}
|
||||
seen = now;
|
||||
std::thread::sleep(EVENT_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `reply` on the connection: through its subscription once it has one.
|
||||
fn answer(out: &mut TcpStream, events: Option<&Subscription>, reply: &Value) -> std::io::Result<()> {
|
||||
match events {
|
||||
Some(e) => e.lines.send(reply),
|
||||
None => send(out, reply),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING, &DESIGN_WATCH);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn serve_with(
|
||||
stream: TcpStream,
|
||||
token: &str,
|
||||
@@ -377,6 +462,7 @@ fn serve_with(
|
||||
patient: bool,
|
||||
waits: Waits,
|
||||
running: &'static Running,
|
||||
design: &'static DesignWatch,
|
||||
) {
|
||||
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
@@ -385,12 +471,15 @@ fn serve_with(
|
||||
let mut bad = 0usize;
|
||||
let mut authenticated = false;
|
||||
let mut pending: Option<Pending> = None;
|
||||
let mut events: Option<Subscription> = None;
|
||||
loop {
|
||||
// A first `control.subscribe` on its way to the window (#6985).
|
||||
let mut joining = None;
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = send(&mut out, &json!({"ok": false, "error": e.to_string()}));
|
||||
let _ = answer(&mut out, events.as_ref(), &json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -434,6 +523,14 @@ fn serve_with(
|
||||
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
|
||||
// Nothing more is queued behind it, to run unseen when it ends.
|
||||
json!({"ok": false, "busy": true, "error": busy_message(p, &method)})
|
||||
} else if method == "control.subscribe" && events.is_some() {
|
||||
if late.is_some() {
|
||||
// As below: finished, and the client moved on without asking.
|
||||
pending = None;
|
||||
}
|
||||
// Its events already say how the design moves; a second answer could only
|
||||
// arrive behind a newer one.
|
||||
json!({"ok": false, "error": "already subscribed: this connection is sent design.changed lines; ask document.revision for the design now"})
|
||||
} else {
|
||||
if late.is_some() {
|
||||
// Finished, and the client moved on without asking: it is dropped.
|
||||
@@ -443,6 +540,9 @@ fn serve_with(
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let what = control_pending::describe(&method, ¶ms);
|
||||
let effect = control_pending::effect(&method, ¶ms);
|
||||
// Counted before the window answers, so it publishes every change from its
|
||||
// answer on.
|
||||
joining = (method == "control.subscribe").then(|| design.join());
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
let ticket = running.add(connection, &id, &req);
|
||||
if tx.send(req).is_err() {
|
||||
@@ -472,7 +572,20 @@ fn serve_with(
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if send(&mut out, &reply).is_err() {
|
||||
// Subscribed once the window named the design (not when it refused or timed out).
|
||||
let sent = match joining.and_then(|counted| design_of(&reply).map(|seen| (counted, seen))) {
|
||||
Some((counted, seen)) => match Subscription::start(design, counted, &out, seen, &reply) {
|
||||
Some((subscription, sent)) => {
|
||||
events = Some(subscription);
|
||||
sent
|
||||
}
|
||||
None => {
|
||||
send(&mut out, &json!({"id": reply.get("id"), "ok": false, "error": "not subscribed: no thread or socket left for the events"}))
|
||||
}
|
||||
},
|
||||
None => answer(&mut out, events.as_ref(), &reply),
|
||||
};
|
||||
if sent.is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
@@ -512,7 +625,7 @@ mod tests {
|
||||
static RUNNING: Running = Running::new();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().flatten().next() {
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
@@ -578,7 +691,7 @@ mod tests {
|
||||
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));
|
||||
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
@@ -648,6 +761,95 @@ mod tests {
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
|
||||
}
|
||||
|
||||
/// #6985: a connection that sends `control.subscribe` gets the design in the reply, then a
|
||||
/// `design.changed` line each time the window publishes another: never before the reply,
|
||||
/// none for the same design, the latest after a burst, each line whole between its replies.
|
||||
/// A second subscribe is refused without reaching the window. A connection that did not
|
||||
/// subscribe gets no events, and one that closed is no longer counted.
|
||||
#[test]
|
||||
fn subscribed_connections_are_told_of_design_changes() {
|
||||
static RUNNING: Running = Running::new();
|
||||
static WATCH: DesignWatch = DesignWatch::new();
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The window: it answers `control.subscribe` as the app does, publishing the design first;
|
||||
// then the design changes at once, before the reply may have been written.
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
if req.method == "control.subscribe" {
|
||||
WATCH.publish("d-1", 3);
|
||||
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
|
||||
WATCH.publish("d-1", 4);
|
||||
} else {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, WAITS, &RUNNING, &WATCH));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
(s.try_clone().unwrap(), BufReader::new(s))
|
||||
};
|
||||
let next = |lines: &mut BufReader<TcpStream>| -> Value {
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let changed = |id: &str, revision: u64| json!({"event": "design.changed", "params": {"document_id": id, "revision": revision}});
|
||||
|
||||
let (mut quiet_out, mut quiet) = open();
|
||||
writeln!(quiet_out, "{}", json!({"id": 1, "method": "ui.view", "token": token})).unwrap();
|
||||
assert_eq!(next(&mut quiet)["id"], 1);
|
||||
|
||||
let (mut out, mut lines) = open();
|
||||
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
|
||||
assert_eq!(next(&mut lines), json!({"id": 1, "ok": true, "result": {"document_id": "d-1", "revision": 3}}));
|
||||
assert_eq!(next(&mut lines), changed("d-1", 4), "the change after the window's answer comes after the reply");
|
||||
assert!(WATCH.subscribed());
|
||||
// The design it was told of is no news; another revision is.
|
||||
WATCH.publish("d-1", 4);
|
||||
WATCH.publish("d-1", 5);
|
||||
assert_eq!(next(&mut lines), changed("d-1", 5));
|
||||
// A burst ends with its latest.
|
||||
for revision in 6..=10 {
|
||||
WATCH.publish("d-1", revision);
|
||||
}
|
||||
let mut last = 5;
|
||||
while last < 10 {
|
||||
let e = next(&mut lines);
|
||||
let revision = e["params"]["revision"].as_u64().unwrap();
|
||||
assert!(e["event"] == "design.changed" && revision > last, "{e} after {last}");
|
||||
last = revision;
|
||||
}
|
||||
// Another tab: another id, whatever its revision.
|
||||
WATCH.publish("d-2", 2);
|
||||
assert_eq!(next(&mut lines), changed("d-2", 2));
|
||||
// Subscribed already: refused here (the window would have published d-1 again).
|
||||
writeln!(out, "{}", json!({"id": 2, "method": "control.subscribe"})).unwrap();
|
||||
let r = next(&mut lines);
|
||||
assert!(r["id"] == 2 && r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("already subscribed")), "{r}");
|
||||
// Replies still come, whole, and nothing else was sent meanwhile.
|
||||
writeln!(out, "{}", json!({"id": 3, "method": "ui.view"})).unwrap();
|
||||
assert_eq!(next(&mut lines), json!({"id": 3, "ok": true, "result": "ui.view"}));
|
||||
writeln!(quiet_out, "{}", json!({"id": 2, "method": "ui.view"})).unwrap();
|
||||
assert_eq!(next(&mut quiet), json!({"id": 2, "ok": true, "result": "ui.view"}), "no events without subscribing");
|
||||
// Closed, it is no longer counted.
|
||||
drop((out, lines));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while WATCH.subscribed() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(!WATCH.subscribed());
|
||||
}
|
||||
|
||||
/// A timed-out request that only reads is not said to "still apply", nor is one that only
|
||||
/// writes a file (an export, #1989); a change is, by name.
|
||||
#[test]
|
||||
|
||||
+125
-20
@@ -28,6 +28,7 @@ mod native_crash;
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod nav_bridge;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
@@ -87,6 +88,8 @@ struct App {
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
/// A device's own app moving the view (Preferences › Navigation, off by default).
|
||||
nav_bridge: nav_bridge::NavBridge,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -145,6 +148,8 @@ impl eframe::App for App {
|
||||
if !self.solve.file_picker_busy() {
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
}
|
||||
// Every frame, so switching the bridge off reaches the reader while a picker is open too.
|
||||
self.nav_bridge.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
@@ -182,6 +187,10 @@ impl eframe::App for App {
|
||||
}
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
|
||||
// Before a restart below starts the next instance, which would otherwise get nothing from
|
||||
// the 3D mouse driver (`solidcraft_3dx_client::stop`).
|
||||
#[cfg(target_os = "macos")]
|
||||
solidcraft_3dx_client::stop();
|
||||
solidcraft_engine::crash::end_session();
|
||||
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
|
||||
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
|
||||
@@ -292,7 +301,9 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
dialog = dialog.add_filter(label, &case_variants(first));
|
||||
}
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
|
||||
let mut everything = io::formats::open_extensions(true);
|
||||
everything.extend(["dxf", "svg"]);
|
||||
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
@@ -576,13 +587,22 @@ fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]
|
||||
}
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
/// Keep the default until the installed renderer's startup check proves a fallback is needed.
|
||||
fn scene_msaa_request(saved: Option<u16>) -> u16 {
|
||||
saved.unwrap_or(MSAA)
|
||||
}
|
||||
|
||||
/// Query the scene's colour/depth intersection, rather than the depthless window's counts.
|
||||
fn scene_sample_choices(depth_bits: u8, stencil_bits: u8, query: impl FnOnce(Option<eframe::wgpu::TextureFormat>) -> Vec<u32>) -> Vec<u32> {
|
||||
query(eframe::egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits))
|
||||
}
|
||||
|
||||
/// An explicitly saved anti-aliasing choice, absent until Graphics changes it.
|
||||
fn msaa() -> Option<u16> {
|
||||
graphics()
|
||||
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
||||
.and_then(|m| u16::try_from(m).ok())
|
||||
.filter(|m| [1, 2, 4, 8].contains(m))
|
||||
.unwrap_or(MSAA)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
||||
@@ -622,6 +642,18 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
/// Disconnects from the 3D mouse driver when dropped, as `main` returns or a panic unwinds out
|
||||
/// of it (`solidcraft_3dx_client::stop`).
|
||||
#[cfg(target_os = "macos")]
|
||||
struct DriverClient;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Drop for DriverClient {
|
||||
fn drop(&mut self) {
|
||||
solidcraft_3dx_client::stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
|
||||
@@ -686,7 +718,13 @@ fn main() -> eframe::Result {
|
||||
if safe_mode {
|
||||
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
||||
}
|
||||
let samples = if safe_mode { MSAA } else { msaa() };
|
||||
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
|
||||
if let Some(note) = &fallback_note {
|
||||
log::warn!("{note}");
|
||||
eprintln!("SolidCraft: {note}");
|
||||
notes.push(note.clone());
|
||||
}
|
||||
let saved_samples = if safe_mode { None } else { msaa() };
|
||||
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
|
||||
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
|
||||
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
||||
@@ -764,6 +802,10 @@ 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();
|
||||
// A panic on the window's thread ends the app without `on_exit`; this disconnects from the
|
||||
// 3D mouse driver then too.
|
||||
#[cfg(target_os = "macos")]
|
||||
let _driver_client = DriverClient;
|
||||
mark("run_native");
|
||||
let result = eframe::run_native(
|
||||
"SolidCraft",
|
||||
@@ -794,6 +836,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.cpu_meshing = safe_mode;
|
||||
app.graphics_fallback = fallback_note.clone();
|
||||
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);
|
||||
}
|
||||
@@ -816,18 +859,24 @@ fn main() -> eframe::Result {
|
||||
(info.name.clone(), format!("{} ({:?}, {:?})", info.name, info.device_type, info.backend))
|
||||
})
|
||||
.collect();
|
||||
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
|
||||
let info = rs.adapter.get_info();
|
||||
let samples = scene_msaa_request(saved_samples);
|
||||
let supported = scene_sample_choices(depth_bits, STENCIL_BITS, |depth| {
|
||||
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
|
||||
});
|
||||
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
|
||||
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
|
||||
app.msaa_supported = supported;
|
||||
app.preferences.msaa = u32::from(samples);
|
||||
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::reinstall();
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
// Reflect a confirmed probe fallback or an unsupported saved count in Graphics.
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
solidcraft_engine::crash::set_environment(&format!(
|
||||
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
|
||||
info.backend, info.name, info.driver
|
||||
@@ -889,8 +938,9 @@ fn main() -> eframe::Result {
|
||||
if !notes.is_empty() {
|
||||
app.set_status(notes.join(" "), true);
|
||||
}
|
||||
let nav_bridge = nav_bridge::NavBridge::start(&cc.egui_ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx, nav_bridge.claim());
|
||||
#[cfg(target_os = "macos")]
|
||||
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
|
||||
Ok(menu) => Some(menu),
|
||||
@@ -915,12 +965,13 @@ fn main() -> eframe::Result {
|
||||
space_mouse,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu,
|
||||
nav_bridge,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
control_server::remove_token_file();
|
||||
match result {
|
||||
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
|
||||
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, settings.as_deref()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
@@ -941,6 +992,9 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
|
||||
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
||||
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
|
||||
// A retry applies only to this launch. WGPU_BACKEND always has priority.
|
||||
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
|
||||
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
|
||||
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
|
||||
eprintln!("SolidCraft graphics: starting {backends:?}");
|
||||
setup.instance_descriptor.backends = backends;
|
||||
@@ -962,24 +1016,47 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
|
||||
options
|
||||
}
|
||||
|
||||
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
|
||||
/// the software renderer, else a message box saying what failed instead of a silent exit.
|
||||
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
|
||||
/// A failed renderer returns here before app creation. A fresh process avoids reusing winit's
|
||||
/// event loop or a failed device; each retry advances through the APIs, then software once.
|
||||
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
|
||||
log::error!("the window could not open: {error}");
|
||||
eprintln!("SolidCraft: the window could not open: {error}");
|
||||
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
|
||||
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
|
||||
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
|
||||
// This failure was handled, so neither the marker nor the journal should call it a crash.
|
||||
if let Some(dir) = settings {
|
||||
startup::started(dir);
|
||||
}
|
||||
solidcraft_engine::crash::end_session();
|
||||
let explicit = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
|
||||
if let Some(next) = startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed)
|
||||
&& let Ok(exe) = std::env::current_exe()
|
||||
{
|
||||
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
|
||||
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
|
||||
log::warn!("{note}");
|
||||
eprintln!("SolidCraft: {note}");
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
|
||||
if next == "cpu" {
|
||||
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
|
||||
} else {
|
||||
command.env(startup::RETRY_BACKEND_ENV, next);
|
||||
}
|
||||
match command.status() {
|
||||
Ok(status) if status.success() => return Ok(()),
|
||||
// The second try showed its own message.
|
||||
// The final try showed its own message.
|
||||
Ok(_) => return Err(error),
|
||||
Err(e) => log::error!("could not start again with the software renderer: {e}"),
|
||||
Err(e) => log::error!("could not start again on {next}: {e}"),
|
||||
}
|
||||
}
|
||||
let message = if explicit {
|
||||
format!("SolidCraft could not open its window with WGPU_BACKEND.\n\n{error}\n\nChange or remove WGPU_BACKEND to try another graphics API.")
|
||||
} else {
|
||||
startup::no_window_message(&error.to_string(), settings)
|
||||
};
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("SolidCraft could not start")
|
||||
.set_description(startup::no_window_message(&error.to_string(), settings))
|
||||
.set_description(message)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
Err(error)
|
||||
@@ -1200,6 +1277,34 @@ mod msaa_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_sample_choices_include_the_scene_depth_format() {
|
||||
use eframe::wgpu::TextureFormat;
|
||||
for (bits, expected) in [(24, TextureFormat::Depth24PlusStencil8), (32, TextureFormat::Depth32FloatStencil8)] {
|
||||
// A synthetic colour target supports 8x, but the scene depth supports only 1x/4x.
|
||||
let supported = super::scene_sample_choices(bits, 8, |depth| match depth {
|
||||
Some(format) if format == expected => vec![1, 4],
|
||||
None => vec![1, 4, 8],
|
||||
other => panic!("unexpected scene depth: {other:?}"),
|
||||
});
|
||||
assert_eq!(supported, [1, 4]);
|
||||
assert_eq!(super::scene_samples(eframe::wgpu::Backend::Dx12, 8, &supported), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_msaa_keeps_four_samples_by_default_and_explicit_choices() {
|
||||
use eframe::wgpu::Backend;
|
||||
let default = super::scene_msaa_request(None);
|
||||
assert_eq!(default, 4);
|
||||
for backend in [Backend::Dx12, Backend::Vulkan, Backend::Metal] {
|
||||
assert_eq!(super::scene_samples(backend, u32::from(default), &[1, 4]), 4);
|
||||
}
|
||||
for saved in [1, 2, 4, 8] {
|
||||
assert_eq!(super::scene_msaa_request(Some(saved)), saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scene_follows_the_backend_policy_too() {
|
||||
use eframe::wgpu::Backend;
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
//! The navigation bridge: a 3D input device's own program (a homemade device's driver app) moves
|
||||
//! the view over a loopback connection, the way a 3D mouse driver hands its axes to the programs
|
||||
//! it supports, so the device emulates no mouse and the pointer stays the user's.
|
||||
//!
|
||||
//! The device app listens on `127.0.0.1:<port>`; with Preferences › Navigation › "Navigate with a
|
||||
//! device app" on (off by default), SolidCraft connects to it, says hello and reads one JSON
|
||||
//! object per line (docs/3d-mouse.md). The reader thread only sums the motion; the egui thread
|
||||
//! hands it to the view once per frame (`space_mouse::bridge_frame`). A device the app says it
|
||||
//! reads is left to it by the 3D mouse reader ([`Claim`]), so the view doesn't move twice.
|
||||
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::Context;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{BRIDGE_VIEWS, BridgeMotion, bridge_frame, bridge_step};
|
||||
|
||||
/// The longest line read; a longer one is skipped to its end.
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// Standard views waiting for the next frame; past this many, more are a flood and dropped.
|
||||
const MAX_VIEWS: usize = 8;
|
||||
/// The longest name a device app may give for itself or its device, in characters.
|
||||
const MAX_NAME: usize = 64;
|
||||
/// How long a connection attempt waits.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// How long a read waits for data before the reader looks at the preferences again.
|
||||
const READ_WAKE: Duration = Duration::from_millis(250);
|
||||
/// The wait before connecting again when no device app answered or one went away.
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(1);
|
||||
/// In zoom mode, a motion of this many points along Y is one zoom step: with 8 % a step, the 1 %
|
||||
/// a point that a zoom drag moves.
|
||||
const POINTS_PER_ZOOM_STEP: f64 = 8.0;
|
||||
|
||||
/// What the device's motion drives, as the device app last said (`mode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
#[default]
|
||||
Orbit,
|
||||
Pan,
|
||||
Zoom,
|
||||
}
|
||||
|
||||
/// What the reader thread and the egui thread share.
|
||||
#[derive(Debug, Default)]
|
||||
struct Shared {
|
||||
/// The port the preferences ask to connect to; none while the bridge is off.
|
||||
wanted: Option<u16>,
|
||||
/// The device app connected, by the name it gave ("?" until it gives one).
|
||||
connected: Option<String>,
|
||||
/// The device the connected app reads, as it named it: the 3D mouse reader leaves that one alone.
|
||||
device: Option<String>,
|
||||
/// Motion since the egui thread last took it.
|
||||
motion: BridgeMotion,
|
||||
/// Standard views asked for since then.
|
||||
views: Vec<String>,
|
||||
}
|
||||
|
||||
fn lock(shared: &Mutex<Shared>) -> Option<MutexGuard<'_, Shared>> {
|
||||
shared.lock().ok()
|
||||
}
|
||||
|
||||
/// A name a device app gave, without control characters and cut to [`MAX_NAME`]; none when empty.
|
||||
fn name(value: Option<&Value>) -> Option<String> {
|
||||
let name: String = value?.as_str()?.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
let name = name.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// A number of a message, finite and in range; none when it isn't one.
|
||||
fn number(msg: &Value, key: &str) -> f64 {
|
||||
bridge_step(msg.get(key).and_then(Value::as_f64).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Takes one line from the device app into `shared`. Lines that aren't a message this version
|
||||
/// knows are ignored. Returns whether the view has something new to show.
|
||||
fn consume(line: &[u8], mode: &mut Mode, shared: &mut Shared) -> bool {
|
||||
let Ok(msg) = serde_json::from_slice::<Value>(line) else { return false };
|
||||
let add = |shared: &mut Shared, motion: BridgeMotion| {
|
||||
shared.motion = shared.motion.plus(motion);
|
||||
!motion.is_still()
|
||||
};
|
||||
match msg.get("t").and_then(Value::as_str) {
|
||||
Some("axis") => {
|
||||
let (x, y) = (number(&msg, "x"), number(&msg, "y"));
|
||||
let motion = match mode {
|
||||
Mode::Orbit => BridgeMotion { orbit: [x, y], ..Default::default() },
|
||||
Mode::Pan => BridgeMotion { pan: [x, y], ..Default::default() },
|
||||
// Up zooms in, as a zoom drag does.
|
||||
Mode::Zoom => BridgeMotion { zoom: -y / POINTS_PER_ZOOM_STEP, ..Default::default() },
|
||||
};
|
||||
add(shared, motion)
|
||||
}
|
||||
Some("zoom") => add(shared, BridgeMotion { zoom: number(&msg, "d"), ..Default::default() }),
|
||||
Some("mode") => {
|
||||
let on = msg.get("on").and_then(Value::as_bool).unwrap_or(false);
|
||||
*mode = match msg.get("mode").and_then(Value::as_str) {
|
||||
Some("pan") if on => Mode::Pan,
|
||||
Some("zoom") if on => Mode::Zoom,
|
||||
_ => Mode::Orbit,
|
||||
};
|
||||
false
|
||||
}
|
||||
Some("view") => {
|
||||
let view = msg.get("name").and_then(Value::as_str).and_then(|n| BRIDGE_VIEWS.iter().find(|v| **v == n));
|
||||
match view {
|
||||
Some(view) if shared.views.len() < MAX_VIEWS => {
|
||||
shared.views.push((*view).to_string());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
Some("hello") => {
|
||||
shared.connected = name(msg.get("server")).or_else(|| shared.connected.clone());
|
||||
shared.device = name(msg.get("device"));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits what arrives into lines, across reads. A line longer than [`MAX_LINE`] is dropped up to
|
||||
/// its newline, so a peer that never sends one can't make the reader hold more than that.
|
||||
#[derive(Default)]
|
||||
struct Lines {
|
||||
line: Vec<u8>,
|
||||
skipping: bool,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
fn feed(&mut self, mut data: &[u8], mut each: impl FnMut(&[u8])) {
|
||||
while !data.is_empty() {
|
||||
let end = data.iter().position(|b| *b == b'\n');
|
||||
let (part, rest) = match end {
|
||||
Some(i) => (data.get(..i).unwrap_or_default(), data.get(i + 1..).unwrap_or_default()),
|
||||
None => (data, &[][..]),
|
||||
};
|
||||
if !self.skipping {
|
||||
let room = (MAX_LINE + 1).saturating_sub(self.line.len());
|
||||
self.line.extend_from_slice(part.get(..room.min(part.len())).unwrap_or_default());
|
||||
if self.line.len() > MAX_LINE {
|
||||
self.line.clear();
|
||||
self.skipping = true;
|
||||
}
|
||||
}
|
||||
if end.is_some() {
|
||||
if !self.skipping {
|
||||
each(self.line.strip_suffix(b"\r").unwrap_or(&self.line));
|
||||
}
|
||||
self.line.clear();
|
||||
self.skipping = false;
|
||||
}
|
||||
data = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What SolidCraft says first, so the device app knows which program it is driving.
|
||||
fn hello() -> Value {
|
||||
json!({"t": "hello", "app": "solidcraft", "version": env!("CARGO_PKG_VERSION")})
|
||||
}
|
||||
|
||||
/// Reads one connection until the device app closes it, it fails, or the preferences no longer
|
||||
/// ask for this port; then forgets what it had.
|
||||
fn serve(mut stream: TcpStream, port: u16, ctx: &Context, shared: &Mutex<Shared>) {
|
||||
// Without a read timeout the reader couldn't notice the bridge being switched off.
|
||||
if stream.set_read_timeout(Some(READ_WAKE)).is_err() || writeln!(stream, "{}", hello()).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = stream.set_nodelay(true);
|
||||
log::info!("navigation bridge: connected to 127.0.0.1:{port}");
|
||||
if let Some(mut shared) = lock(shared) {
|
||||
shared.connected = Some("?".into());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let (mut lines, mut mode) = (Lines::default(), Mode::default());
|
||||
let mut chunk = [0_u8; 4096];
|
||||
while lock(shared).is_some_and(|shared| shared.wanted == Some(port)) {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => {
|
||||
let mut news = false;
|
||||
if let Some(mut shared) = lock(shared) {
|
||||
lines.feed(chunk.get(..size).unwrap_or_default(), |line| news |= consume(line, &mut mode, &mut shared));
|
||||
}
|
||||
if news {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => {}
|
||||
Err(error) => {
|
||||
log::info!("navigation bridge: connection lost: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut shared) = lock(shared) {
|
||||
*shared = Shared { wanted: shared.wanted, ..Shared::default() };
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Connects to the device app while the preferences ask for it, again whenever it goes away.
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Shared>>) {
|
||||
loop {
|
||||
let Some(port) = lock(&shared).and_then(|shared| shared.wanted) else {
|
||||
std::thread::sleep(READ_WAKE);
|
||||
continue;
|
||||
};
|
||||
if let Ok(stream) = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), CONNECT_TIMEOUT) {
|
||||
serve(stream, port, &ctx, &shared);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which 3D mouse the connected device app reads, for the 3D mouse reader to leave alone. Only
|
||||
/// the Windows and macOS readers know a device's strings to compare.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
pub struct Claim(Arc<Mutex<Shared>>);
|
||||
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
impl Claim {
|
||||
/// Whether a device with these manufacturer and product strings is the one the connected
|
||||
/// device app reads (its name appears in one, ignoring case).
|
||||
pub fn claims(&self, strings: &[String]) -> bool {
|
||||
let Some(device) = lock(&self.0).and_then(|shared| shared.device.clone()) else { return false };
|
||||
let device = device.to_lowercase();
|
||||
strings.iter().any(|s| s.to_lowercase().contains(&device))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NavBridge {
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
}
|
||||
|
||||
impl NavBridge {
|
||||
pub fn start(ctx: &Context) -> Self {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let (worker_ctx, worker_state) = (ctx.clone(), Arc::clone(&shared));
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-nav-bridge".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("navigation bridge: reader could not start: {error}");
|
||||
}
|
||||
Self { shared }
|
||||
}
|
||||
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
pub fn claim(&self) -> Claim {
|
||||
Claim(Arc::clone(&self.shared))
|
||||
}
|
||||
|
||||
/// Each frame: passes the preferences to the reader, and the motion and views since the last
|
||||
/// frame to the view. Motion that arrived while the window had no focus is dropped with it.
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let wanted = Some(app.preferences.nav_bridge_port).filter(|port| app.preferences.nav_bridge_enabled && *port != 0);
|
||||
let Some((motion, views, connected)) = lock(&self.shared).map(|mut shared| {
|
||||
shared.wanted = wanted;
|
||||
(std::mem::take(&mut shared.motion), std::mem::take(&mut shared.views), shared.connected.clone())
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
app.space_mouse.bridge = connected.filter(|_| wanted.is_some());
|
||||
if !motion.is_still() || !views.is_empty() {
|
||||
bridge_frame(app, ctx, motion, &views);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::time::Instant;
|
||||
|
||||
fn feed(shared: &mut Shared, mode: &mut Mode, text: &str) -> bool {
|
||||
let mut news = false;
|
||||
Lines::default().feed(text.as_bytes(), |line| news |= consume(line, mode, shared));
|
||||
news
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_motion_orbits_pans_or_zooms_as_the_mode_says() {
|
||||
let (mut shared, mut mode) = (Shared::default(), Mode::default());
|
||||
assert!(feed(&mut shared, &mut mode, "{\"t\":\"axis\",\"x\":3,\"y\":-2}\n{\"t\":\"axis\",\"x\":1.5,\"y\":0}\n"));
|
||||
assert_eq!(shared.motion, BridgeMotion { orbit: [4.5, -2.0], ..Default::default() });
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":true}\n{\"t\":\"axis\",\"x\":5,\"y\":6}\n");
|
||||
assert_eq!(mode, Mode::Pan);
|
||||
assert_eq!(shared.motion.pan, [5.0, 6.0]);
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"zoom\",\"on\":true}\n{\"t\":\"axis\",\"x\":0,\"y\":-16}\n");
|
||||
assert_eq!(shared.motion.zoom, 2.0, "up zooms in, 8 points a step");
|
||||
// Off, whichever mode it names, is back to orbit.
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":false}\n");
|
||||
assert_eq!(mode, Mode::Orbit);
|
||||
// The ring's steps add up with the zoom mode's.
|
||||
feed(&mut shared, &mut mode, "{\"t\":\"zoom\",\"d\":-0.5}\n");
|
||||
assert_eq!(shared.motion.zoom, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_are_queued_by_their_known_names_only() {
|
||||
let (mut shared, mut mode) = (Shared::default(), Mode::default());
|
||||
assert!(feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"iso\"}\n{\"t\":\"view\",\"name\":\"fit\"}\n"));
|
||||
assert!(!feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"ISO\"}\n{\"t\":\"view\",\"name\":\"../etc\"}\n{\"t\":\"view\"}\n"));
|
||||
assert_eq!(shared.views, ["iso", "fit"]);
|
||||
let flood = "{\"t\":\"view\",\"name\":\"top\"}\n".repeat(100);
|
||||
feed(&mut shared, &mut mode, &flood);
|
||||
assert_eq!(shared.views.len(), MAX_VIEWS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_lines_never_move_the_view_out_of_range() {
|
||||
let (mut shared, mut mode) = (Shared::default(), Mode::default());
|
||||
let lines = [
|
||||
"not json",
|
||||
"{\"t\":\"axis\",\"x\":\"5\",\"y\":null}",
|
||||
"{\"t\":\"axis\",\"x\":1e308,\"y\":-1e308}",
|
||||
"{\"t\":\"axis\",\"x\":1e999}",
|
||||
"{\"t\":\"zoom\",\"d\":[1]}",
|
||||
"{\"t\":42}",
|
||||
"[1,2,3]",
|
||||
"{\"t\":\"mode\",\"mode\":{\"x\":1},\"on\":\"yes\"}",
|
||||
"\u{0}\u{1}\u{2}",
|
||||
];
|
||||
for line in lines {
|
||||
Lines::default().feed(format!("{line}\n").as_bytes(), |l| {
|
||||
consume(l, &mut mode, &mut shared);
|
||||
});
|
||||
}
|
||||
let limit = solidcraft_ui_egui::space_mouse::BRIDGE_MAX_STEP;
|
||||
let values = shared.motion.orbit.into_iter().chain(shared.motion.pan).chain([shared.motion.zoom]);
|
||||
for value in values {
|
||||
assert!(value.is_finite() && value.abs() <= limit, "{value}");
|
||||
}
|
||||
assert_eq!(mode, Mode::Orbit);
|
||||
// Invalid UTF-8 is no message either.
|
||||
assert!(!consume(&[0xff, 0xfe, b'{', b'}'], &mut mode, &mut shared));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_split_across_reads_and_long_ones_are_skipped_to_their_end() {
|
||||
let mut got = Vec::new();
|
||||
let mut lines = Lines::default();
|
||||
for part in ["{\"t\":", "\"zoom\"}\r\n{\"a\"", ":1}\n"] {
|
||||
lines.feed(part.as_bytes(), |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
}
|
||||
assert_eq!(got, ["{\"t\":\"zoom\"}", "{\"a\":1}"]);
|
||||
got.clear();
|
||||
let long = vec![b'x'; MAX_LINE * 3];
|
||||
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
assert!(lines.line.len() <= MAX_LINE + 1, "never holds more than one line");
|
||||
lines.feed(b"tail\nnext\n", |line| got.push(String::from_utf8_lossy(line).to_string()));
|
||||
assert_eq!(got, ["next"], "the long line is dropped to its newline, the next one read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_device_app_names_itself_and_the_device_it_leaves_alone() {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let claim = Claim(Arc::clone(&shared));
|
||||
let strings = ["STMicroelectronics".to_string(), "MAKMOUSE 3D".to_string()];
|
||||
assert!(!claim.claims(&strings), "nothing is claimed before an app says so");
|
||||
let mut mode = Mode::default();
|
||||
if let Ok(mut shared) = shared.lock() {
|
||||
let hello = format!("{{\"t\":\"hello\",\"server\":\"Dev\\u0007App {}\",\"device\":\"makmouse\"}}\n", "x".repeat(200));
|
||||
feed(&mut shared, &mut mode, &hello);
|
||||
assert_eq!(shared.connected.as_deref().map(|n| n.chars().count()), Some(MAX_NAME));
|
||||
assert!(shared.connected.as_deref().is_some_and(|n| n.starts_with("DevApp")));
|
||||
}
|
||||
assert!(claim.claims(&strings), "the name matches ignoring case");
|
||||
assert!(!claim.claims(&["3Dconnexion".to_string(), "SpaceMouse Compact".to_string()]), "another 3D mouse stays the reader's");
|
||||
}
|
||||
|
||||
/// Waits until `check` holds, or fails after a few seconds.
|
||||
fn wait_for(what: &str, mut check: impl FnMut() -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !check() {
|
||||
assert!(Instant::now() < deadline, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_says_hello_reads_the_stream_and_ends_when_the_bridge_is_switched_off() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
|
||||
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let (mut server, _) = listener.accept().unwrap();
|
||||
let worker = {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
|
||||
};
|
||||
let mut hello_line = String::new();
|
||||
BufReader::new(server.try_clone().unwrap()).read_line(&mut hello_line).unwrap();
|
||||
let hello: Value = serde_json::from_str(&hello_line).unwrap();
|
||||
assert_eq!((hello["t"].as_str(), hello["app"].as_str()), (Some("hello"), Some("solidcraft")));
|
||||
server
|
||||
.write_all(b"{\"t\":\"hello\",\"server\":\"MAKMOUSE\",\"device\":\"MAKMOUSE\"}\n{\"t\":\"axis\",\"x\":2,\"y\":1}\n{\"t\":\"view\",\"name\":\"top\"}\n")
|
||||
.unwrap();
|
||||
wait_for("the stream", || shared.lock().is_ok_and(|s| s.motion.orbit == [2.0, 1.0] && s.views == ["top"]));
|
||||
assert_eq!(shared.lock().unwrap().connected.as_deref(), Some("MAKMOUSE"));
|
||||
// Switched off: the connection ends and what it had is forgotten.
|
||||
shared.lock().unwrap().wanted = None;
|
||||
worker.join().unwrap();
|
||||
let after = shared.lock().unwrap();
|
||||
assert!(after.connected.is_none() && after.device.is_none() && after.motion.is_still() && after.views.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_connection_forgets_the_app() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
|
||||
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let (server, _) = listener.accept().unwrap();
|
||||
let worker = {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
|
||||
};
|
||||
wait_for("the connection", || shared.lock().is_ok_and(|s| s.connected.is_some()));
|
||||
drop(server);
|
||||
worker.join().unwrap();
|
||||
let after = shared.lock().unwrap();
|
||||
assert_eq!((after.connected.as_deref(), after.wanted), (None, Some(port)), "still wanted, so the reader connects again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_frame_hands_the_view_the_motion_and_passes_the_preferences_on() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
let ctx = Context::default();
|
||||
let _ = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {});
|
||||
let mut bridge = NavBridge { shared: Arc::new(Mutex::new(Shared::default())) };
|
||||
let queue = |bridge: &NavBridge| {
|
||||
let mut shared = bridge.shared.lock().unwrap();
|
||||
shared.connected = Some("MAKMOUSE".into());
|
||||
shared.motion = BridgeMotion { orbit: [10.0, 0.0], ..Default::default() };
|
||||
};
|
||||
// Off (the default): nothing is wanted, nothing reaches the view.
|
||||
queue(&bridge);
|
||||
bridge.tick(&mut app, &ctx);
|
||||
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
|
||||
assert!(app.viewport.bridge_motion.is_none() && app.space_mouse.bridge.is_none());
|
||||
// On: the port is passed to the reader and the motion to the view, once.
|
||||
app.preferences.nav_bridge_enabled = true;
|
||||
app.preferences.nav_bridge_port = 9123;
|
||||
queue(&bridge);
|
||||
bridge.tick(&mut app, &ctx);
|
||||
assert_eq!(bridge.shared.lock().unwrap().wanted, Some(9123));
|
||||
assert_eq!(app.space_mouse.bridge.as_deref(), Some("MAKMOUSE"));
|
||||
assert_eq!(app.viewport.bridge_motion.map(|m| m.orbit), Some([10.0, 0.0]));
|
||||
assert!(bridge.shared.lock().unwrap().motion.is_still(), "taken, not handed over twice");
|
||||
// Port 0 is no port.
|
||||
app.preferences.nav_bridge_port = 0;
|
||||
bridge.tick(&mut app, &ctx);
|
||||
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
|
||||
}
|
||||
}
|
||||
@@ -528,6 +528,9 @@ struct Candidate<D> {
|
||||
device: D,
|
||||
layout: Layout,
|
||||
ids: (u16, u16),
|
||||
/// The device's manufacturer and product strings ([`device_strings`]).
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
|
||||
strings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Input reports read with a timeout in milliseconds: hidapi's devices, and the tests' simulated
|
||||
@@ -634,8 +637,15 @@ fn follow<D: ReportSource>(mut candidates: Vec<Candidate<D>>, now: impl Fn() ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's manufacturer and product strings, which say whether a device app has it
|
||||
/// ([`crate::nav_bridge::Claim`]). Asked of the opened 3D mouse only.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
fn device_strings(device: &hidapi::HidDevice) -> Vec<String> {
|
||||
[device.get_manufacturer_string(), device.get_product_string()].into_iter().filter_map(|s| s.ok().flatten()).collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>, bridge: crate::nav_bridge::Claim) {
|
||||
let Ok(mut api) = hid_api() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
return;
|
||||
@@ -657,11 +667,17 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
// 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(Candidate { device, layout, ids: (info.vendor_id(), info.product_id()) })
|
||||
let strings = device_strings(&device);
|
||||
Some(Candidate { device, layout, ids: (info.vendor_id(), info.product_id()), strings })
|
||||
})
|
||||
.collect();
|
||||
let names: Vec<_> = open.iter().map(|candidate| (candidate.ids, candidate.strings.clone())).collect();
|
||||
// Whether the device read now is one a device app has: it moves the view over the navigation
|
||||
// bridge, so read here as well the device would move it twice. What was read before ages out.
|
||||
let mut strings: &[String] = &[];
|
||||
follow(open, Instant::now, |event| match event {
|
||||
Followed::Device(ids, buttons) => {
|
||||
strings = names.iter().find(|(known, _)| *known == ids).map_or(&[][..], |(_, strings)| strings.as_slice());
|
||||
connected(&ctx, &shared, Source::Hid(Some(ids)));
|
||||
// Taken over from another device: what it reported meanwhile is its baseline.
|
||||
if let Some(buttons) = buttons
|
||||
@@ -670,6 +686,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
reading.set_buttons(buttons);
|
||||
}
|
||||
}
|
||||
Followed::Report(..) if bridge.claims(strings) => {}
|
||||
Followed::Report(layout, report) => publish(&ctx, &shared, *layout, report),
|
||||
Followed::Gone => reset(&ctx, &shared),
|
||||
});
|
||||
@@ -1054,7 +1071,8 @@ pub struct SpaceMouse {
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
pub fn start(ctx: &Context) -> Self {
|
||||
/// Starts the reader. A device that `bridge` says its own app reads is left to that app.
|
||||
pub fn start(ctx: &Context, bridge: crate::nav_bridge::Claim) -> Self {
|
||||
let now = Instant::now();
|
||||
let shared = Arc::new(Mutex::new(Reading::new(now)));
|
||||
let worker_state = Arc::clone(&shared);
|
||||
@@ -1063,11 +1081,14 @@ impl SpaceMouse {
|
||||
let drivers = Arc::new(Drivers::default());
|
||||
#[cfg(target_os = "linux")]
|
||||
let worker_drivers = Arc::clone(&drivers);
|
||||
// hidraw names no device's strings to compare (`Claim`).
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = bridge;
|
||||
let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || {
|
||||
#[cfg(target_os = "linux")]
|
||||
reader(worker_ctx, worker_state, worker_drivers, Machine);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
reader(worker_ctx, worker_state);
|
||||
reader(worker_ctx, worker_state, bridge);
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
@@ -1458,6 +1479,7 @@ mod tests {
|
||||
},
|
||||
layout,
|
||||
ids: (0x256f, *product),
|
||||
strings: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let (mut seen, mut read) = (Vec::new(), 0);
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//! row the next launch starts in safe mode by itself.
|
||||
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
|
||||
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
|
||||
//! - **No usable graphics device**: one retry on the software renderer
|
||||
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
|
||||
//! - **A renderer that cannot start**: on Windows, try the remaining graphics APIs in order,
|
||||
//! then the software renderer (`SOLIDCRAFT_RENDERER=cpu`). An explicit `WGPU_BACKEND` fails
|
||||
//! cleanly without switching. Exhausted retries show a message box with the error.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -218,6 +219,34 @@ pub fn choose_backends(
|
||||
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
|
||||
}
|
||||
|
||||
/// Internal, process-local retry choice; never written to the graphics preference.
|
||||
pub const RETRY_BACKEND_ENV: &str = "SOLIDCRAFT_STARTUP_BACKEND";
|
||||
/// Why this launch is using a fallback, carried to the new process for status and Preferences.
|
||||
pub const FALLBACK_NOTE_ENV: &str = "SOLIDCRAFT_GRAPHICS_FALLBACK";
|
||||
|
||||
/// A handled startup failure tries each remaining Windows API once, then software rendering.
|
||||
/// An explicit environment choice is final, including when software rendering was requested.
|
||||
pub fn fallback_backend(windows: bool, explicit: bool, cpu: bool, failed: eframe::wgpu::Backends) -> Option<&'static str> {
|
||||
if explicit || cpu {
|
||||
return None;
|
||||
}
|
||||
if windows {
|
||||
let last = WINDOWS_ORDER.iter().rposition(|backend| failed.intersects(*backend));
|
||||
if let Some(next) = last.and_then(|i| WINDOWS_ORDER.get(i + 1)) {
|
||||
return Some(if *next == eframe::wgpu::Backends::VULKAN { "vulkan" } else { "gl" });
|
||||
}
|
||||
}
|
||||
Some("cpu")
|
||||
}
|
||||
|
||||
/// Keep driver diagnostics on one bounded line, including across the finite retry chain.
|
||||
pub fn fallback_note(previous: &str, failed: eframe::wgpu::Backends, next: &str, error: &str) -> String {
|
||||
let previous: String = previous.chars().take(2048).collect();
|
||||
let error: String = error.chars().take(1024).collect();
|
||||
let line = format!("{previous} Graphics fallback: {failed:?} failed ({error}); trying {next}.");
|
||||
line.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(4096).collect()
|
||||
}
|
||||
|
||||
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
|
||||
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
|
||||
|
||||
@@ -442,6 +471,42 @@ mod tests {
|
||||
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_windows_backends_fall_back_in_order_and_stop_after_software() {
|
||||
use eframe::wgpu::Backends as B;
|
||||
assert_eq!(fallback_backend(true, false, false, B::DX12), Some("vulkan"));
|
||||
assert_eq!(fallback_backend(true, false, false, B::VULKAN), Some("gl"));
|
||||
assert_eq!(fallback_backend(true, false, false, B::GL), Some("cpu"));
|
||||
assert_eq!(fallback_backend(true, false, true, B::DX12), None);
|
||||
assert_eq!(fallback_backend(true, false, false, B::DX12 | B::VULKAN), Some("gl"));
|
||||
assert_eq!(fallback_backend(false, false, false, B::VULKAN), Some("cpu"));
|
||||
let note = fallback_note("", B::DX12, "vulkan", "egui_pipeline\n0x80070057");
|
||||
assert!(note.contains("egui_pipeline 0x80070057") && note.contains("vulkan") && !note.contains('\n'));
|
||||
let long = fallback_note(&"x".repeat(5000), B::VULKAN, "gl", &"y".repeat(5000));
|
||||
assert!(long.chars().count() <= 4096 && long.ends_with("trying gl."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_failure_never_switches_api_or_retries_software() {
|
||||
use eframe::wgpu::Backends as B;
|
||||
for windows in [false, true] {
|
||||
for cpu in [false, true] {
|
||||
for backend in [B::DX12, B::VULKAN, B::GL, B::all()] {
|
||||
assert_eq!(fallback_backend(windows, true, cpu, backend), None);
|
||||
let mut probed = false;
|
||||
assert_eq!(
|
||||
choose_backends(windows, true, cpu, Some("vulkan"), |_| {
|
||||
probed = true;
|
||||
false
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert!(!probed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_opengl_on_windows_gets_the_system_title_bar() {
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
@@ -25,6 +25,7 @@ assets/
|
||||
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
|
||||
locales/{en,de,ja,ko}.json # shared offline catalogs
|
||||
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
|
||||
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
|
||||
archive/icons/macos-v1/
|
||||
source/icon.vectorcraft # earlier editable work, retained
|
||||
# redundant historical exports are retired
|
||||
|
||||
@@ -48,6 +48,38 @@ source = "#236ead"
|
||||
light = "#236ead"
|
||||
dark = "#2976b8"
|
||||
|
||||
# Simultaneous component colours for inspection icons; existing workspace ramps stay unchanged.
|
||||
|
||||
[roles.component-orange-1]
|
||||
source = "#f1d2af"
|
||||
light = "#f1d2af"
|
||||
dark = "#ba8f6a"
|
||||
|
||||
[roles.component-orange-2]
|
||||
source = "#c06f00"
|
||||
light = "#c06f00"
|
||||
dark = "#b86f04"
|
||||
|
||||
[roles.component-orange-3]
|
||||
source = "#a45000"
|
||||
light = "#a45000"
|
||||
dark = "#95682d"
|
||||
|
||||
[roles.component-yellow-1]
|
||||
source = "#e5d99b"
|
||||
light = "#e5d99b"
|
||||
dark = "#a9985f"
|
||||
|
||||
[roles.component-yellow-2]
|
||||
source = "#a28200"
|
||||
light = "#a28200"
|
||||
dark = "#9a8100"
|
||||
|
||||
[roles.component-yellow-3]
|
||||
source = "#826700"
|
||||
light = "#826700"
|
||||
dark = "#857100"
|
||||
|
||||
[strokes] # on the 64 grid
|
||||
geometry = 2.55
|
||||
action = 4.25
|
||||
|
||||
@@ -57,6 +57,8 @@ circle_tt false
|
||||
circle_ttt false
|
||||
close false
|
||||
coil false
|
||||
colored_components false
|
||||
colored_components_off false
|
||||
combine false
|
||||
compare false
|
||||
component false
|
||||
@@ -245,6 +247,7 @@ status_error false
|
||||
status_recovery false
|
||||
style false
|
||||
suppress false
|
||||
surface false
|
||||
surface_blend false
|
||||
surface_boundary false
|
||||
surface_extend false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Colored components</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#c06f00"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#a45000"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#f1d2af"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#a28200"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#826700"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#e5d99b"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disable component colors</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -270,7 +270,7 @@
|
||||
"Preview Row": "Náhled řádku",
|
||||
"Preview leaves the current design unchanged.": "Náhled aktuální návrh nemění.",
|
||||
"Profiles/Curves": "Profily/křivky",
|
||||
"Quantity": "Množství",
|
||||
"Quantity": "Počet",
|
||||
"RECENT": "NEDÁVNÉ",
|
||||
"REFERENCE": "REFERENCE",
|
||||
"RULED SURFACE": "PŘÍMKOVÁ PLOCHA",
|
||||
@@ -974,6 +974,7 @@
|
||||
"motion.key_here": "Vložit klíčový snímek sem",
|
||||
"motion.key_here_hint": "Přidat v tomto kroku klíčový snímek s aktuální hodnotou kloubu",
|
||||
"motion.no_collisions": "Během studie nedochází ke kolizím",
|
||||
"motion.plot_quantity": "Množství",
|
||||
"motion.step_back": "Krok zpět",
|
||||
"motion.step_forward": "Krok vpřed",
|
||||
"motion.to_end": "Na konec",
|
||||
@@ -1556,6 +1557,7 @@
|
||||
"ui.ai_http_busy": "endpoint je zaneprázdněný nebo překročil limit; zkuste to později",
|
||||
"ui.ai_http_key": "endpoint odmítl klíč: zkontrolujte jej v Předvolby > AI",
|
||||
"ui.ai_http_not_found": "takový endpoint nebo model neexistuje: zkontrolujte základní URL a název modelu",
|
||||
"ui.ai_http_too_large": "konverzace je pro tento endpoint příliš velká; začněte nový chat",
|
||||
"ui.ai_key": "API klíč",
|
||||
"ui.ai_key_hint": "volitelné; uloženo v systémové klíčence",
|
||||
"ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.",
|
||||
@@ -1850,6 +1852,7 @@
|
||||
"ui.heading_torus": "ANULOID",
|
||||
"ui.heading_utilities": "NÁSTROJE",
|
||||
"ui.heading_utility": "NÁSTROJE",
|
||||
"ui.heading_weldments": "SVAŘENCE",
|
||||
"ui.help": "Nápověda",
|
||||
"ui.hide": "Skrýt",
|
||||
"ui.hideAxes": "Skrýt osy",
|
||||
|
||||
@@ -1177,6 +1177,7 @@
|
||||
"motion.key_here": "Sæt punkt her",
|
||||
"motion.key_here_hint": "Tilføj et animationspunkt i dette trin med ledets aktuelle værdi",
|
||||
"motion.no_collisions": "Ingen kollisioner under analysen",
|
||||
"motion.plot_quantity": "Antal",
|
||||
"motion.step_back": "Trin tilbage",
|
||||
"motion.step_forward": "Trin frem",
|
||||
"motion.to_end": "Til slutningen",
|
||||
@@ -1737,6 +1738,7 @@
|
||||
"ui.ai_http_busy": "endepunktet er optaget eller har nået sin grænse; prøv igen senere",
|
||||
"ui.ai_http_key": "endepunktet afviste nøglen: Kontrollér den i Indstillinger > AI",
|
||||
"ui.ai_http_not_found": "endepunktet eller modellen findes ikke: Kontrollér basis-URL og modelnavn",
|
||||
"ui.ai_http_too_large": "samtalen er for stor til dette endpoint; start en ny chat",
|
||||
"ui.ai_key": "API-nøgle",
|
||||
"ui.ai_key_hint": "valgfri; gemmes i systemets nøglering",
|
||||
"ui.ai_key_needs_https": "En nøgle sendes kun via HTTPS eller til denne computer.",
|
||||
@@ -2039,6 +2041,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "VÆRKTØJER",
|
||||
"ui.heading_utility": "VÆRKTØJ",
|
||||
"ui.heading_weldments": "SVEJSNING",
|
||||
"ui.help": "Hjælp",
|
||||
"ui.hide": "Skjul",
|
||||
"ui.hideAxes": "Skjul akser",
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
"Profile / Edges": "Profil / Kanten",
|
||||
"Profiles or curves": "Profile oder Kurven",
|
||||
"Profiles/Curves": "Profile/Kurven",
|
||||
"Quantity": "Größe",
|
||||
"Quantity": "Anzahl",
|
||||
"RECENT": "ZULETZT",
|
||||
"REFERENCE": "REFERENZ",
|
||||
"RULED SURFACE": "REGELFLÄCHE",
|
||||
@@ -631,6 +631,7 @@
|
||||
"cmd.document_metadata": "Konstruktionseigenschaften",
|
||||
"cmd.document_metadata_remove": "Konstruktionseigenschaft entfernen",
|
||||
"cmd.document_metadata_set": "Konstruktionseigenschaften ändern",
|
||||
"cmd.document_revision": "Konstruktionsrevision",
|
||||
"cmd.document_units": "Einheiten der Konstruktion ändern",
|
||||
"cmd.edit_checkpoint": "Prüfpunkt",
|
||||
"cmd.edit_restore_checkpoint": "Prüfpunkt wiederherstellen",
|
||||
@@ -1104,6 +1105,7 @@
|
||||
"motion.key_here": "Schlüsselbild hier setzen",
|
||||
"motion.key_here_hint": "Ein Schlüsselbild bei diesem Schritt mit dem aktuellen Gelenkwert hinzufügen",
|
||||
"motion.no_collisions": "Keine Kollisionen in der Untersuchung",
|
||||
"motion.plot_quantity": "Größe",
|
||||
"motion.step_back": "Schritt zurück",
|
||||
"motion.step_forward": "Schritt vor",
|
||||
"motion.to_end": "Zum Ende",
|
||||
@@ -1116,6 +1118,7 @@
|
||||
"nav.fusion.help": "Mittlere Maustaste verschiebt, Umschalt+Mitte dreht, rechte Maustaste dreht",
|
||||
"nav.inventor.help": "Mittlere Maustaste verschiebt, Umschalt+Mitte dreht, Strg+Mitte zoomt, rechte Maustaste dreht",
|
||||
"nav.openscad.help": "Linksziehen auf leerem Hintergrund dreht, Umschalt+Linksziehen zieht ein Auswahlrechteck auf, Skizzengeometrie wird durch Ziehen bearbeitet; rechtes oder mittleres Ziehen verschiebt, Umschalt+rechtes oder Umschalt+mittleres Ziehen zoomt",
|
||||
"nav.perspective.sketch": "Beim Bearbeiten einer Skizze wird die orthografische Ansicht verwendet. Beenden Sie die Skizze, um die Perspektive umzuschalten.",
|
||||
"nav.solidworks.help": "Mittlere Maustaste dreht, Strg+Mitte verschiebt, Umschalt+Mitte zoomt, rechte Maustaste dreht",
|
||||
"nav.tinkercad.help": "Rechte Maustaste dreht, mittlere oder Umschalt+rechte Maustaste verschiebt",
|
||||
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
|
||||
@@ -1641,6 +1644,7 @@
|
||||
"ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später",
|
||||
"ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI",
|
||||
"ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen",
|
||||
"ui.ai_http_too_large": "die Unterhaltung ist für diesen Endpunkt zu groß; beginnen Sie einen neuen Chat",
|
||||
"ui.ai_key": "API-Schlüssel",
|
||||
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
|
||||
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
|
||||
@@ -1937,6 +1941,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "WERKZEUGE",
|
||||
"ui.heading_utility": "HILFSMITTEL",
|
||||
"ui.heading_weldments": "SCHWEISSTEILE",
|
||||
"ui.help": "Hilfe",
|
||||
"ui.hide": "Ausblenden",
|
||||
"ui.hideAxes": "Achsen ausblenden",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"ui.one_side": "Einseitig",
|
||||
"ui.open": "Öffnen",
|
||||
"ui.open_ends_tip": "Berührt nichts",
|
||||
"ui.open_linked_design": "Verknüpftes Design öffnen",
|
||||
"ui.open_preferences": "Einstellungen öffnen",
|
||||
"ui.open_recent": "Zuletzt verwendet",
|
||||
"ui.operation": "Operation",
|
||||
@@ -2233,6 +2239,7 @@
|
||||
"ui.up_axis_note": "Welche Achse in den Ansichten nach oben zeigt (Achsenansicht, Home, Ansehen). Designs bleiben unverändert.",
|
||||
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
|
||||
"ui.view_centre": "Ansichtszentrum",
|
||||
"ui.viewport_theme_follow": "Dem Design folgen",
|
||||
"ui.visual_shaded_hidden_edges": "Schattiert mit verdeckten Kanten",
|
||||
"ui.visual_shaded_with_edges": "Schattiert mit Kanten",
|
||||
"ui.visual_style": "Ansichtsstil",
|
||||
|
||||
@@ -173,7 +173,12 @@
|
||||
"Don't ask again": "Don't ask again",
|
||||
"Double-click to rename": "Double-click to rename",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing",
|
||||
"Drag to move the boss across the model. It snaps to sketch points on the face.": "Drag to move the boss across the model. It snaps to sketch points on the face.",
|
||||
"Drag to move the hole; click to remove it": "Drag to move the hole; click to remove it",
|
||||
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Drag to move the rest across the model. It snaps to sketch points on the face.",
|
||||
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Drag to move the snap fit across the model. It snaps to sketch points on the face.",
|
||||
"Drag to point the catch in another direction.": "Drag to point the catch in another direction.",
|
||||
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.",
|
||||
"Drive Joint": "Drive Joint",
|
||||
"Duration": "Duration",
|
||||
"Edges": "Edges",
|
||||
@@ -692,6 +697,7 @@
|
||||
"cmd.component_capture_position": "Capture Position",
|
||||
"cmd.component_copies": "Find Identical Copies",
|
||||
"cmd.component_create": "New Component",
|
||||
"cmd.component_cycle_color": "Cycle Component Color",
|
||||
"cmd.component_delete": "Delete Component",
|
||||
"cmd.component_extract": "Extract to File",
|
||||
"cmd.component_from_bodies": "Create Components from Bodies",
|
||||
@@ -753,6 +759,7 @@
|
||||
"cmd.document_metadata": "Design Properties",
|
||||
"cmd.document_metadata_remove": "Remove Design Property",
|
||||
"cmd.document_metadata_set": "Change Design Properties",
|
||||
"cmd.document_revision": "Design Revision",
|
||||
"cmd.document_units": "Change Design Units",
|
||||
"cmd.edit_checkpoint": "Checkpoint",
|
||||
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
|
||||
@@ -776,6 +783,8 @@
|
||||
"cmd.inspect_accessibility": "Accessibility Analysis",
|
||||
"cmd.inspect_center_of_mass": "Center of Mass",
|
||||
"cmd.inspect_clearance": "Clearance",
|
||||
"cmd.inspect_colored_components": "Enable Component Colors",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colors",
|
||||
"cmd.inspect_compare": "Compare Bodies",
|
||||
"cmd.inspect_continuity": "Continuity Analysis",
|
||||
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
|
||||
@@ -916,6 +925,7 @@
|
||||
"cmd.sheet_refold": "Refold",
|
||||
"cmd.sheet_rules": "List Sheet Metal Rules",
|
||||
"cmd.sheet_unfold": "Unfold",
|
||||
"cmd.sketch_arc3d": "3D Arc",
|
||||
"cmd.sketch_arc_center_point": "Center Point Arc",
|
||||
"cmd.sketch_arc_tangent": "Tangent Arc",
|
||||
"cmd.sketch_arc_three_point": "3-Point Arc",
|
||||
@@ -1038,17 +1048,25 @@
|
||||
"cmd.view_back": "Back View",
|
||||
"cmd.view_bottom": "Bottom View",
|
||||
"cmd.view_delete": "Delete Named View",
|
||||
"cmd.view_fit": "Fit",
|
||||
"cmd.view_front": "Front View",
|
||||
"cmd.view_graphics_preset": "Graphics Preset",
|
||||
"cmd.view_grid": "Grid",
|
||||
"cmd.view_ground_shadow": "Ground shadow",
|
||||
"cmd.view_home": "Home view",
|
||||
"cmd.view_isometric": "Isometric View",
|
||||
"cmd.view_left": "Left View",
|
||||
"cmd.view_list": "List Named Views",
|
||||
"cmd.view_navigate": "Navigate View",
|
||||
"cmd.view_navigation_bar": "Navigation bar",
|
||||
"cmd.view_normal_to": "Normal to Selection",
|
||||
"cmd.view_rename": "Rename Named View",
|
||||
"cmd.view_right": "Right View",
|
||||
"cmd.view_save": "New Named View",
|
||||
"cmd.view_spin": "Continuous orbit",
|
||||
"cmd.view_tool": "Navigation tool",
|
||||
"cmd.view_top": "Top View",
|
||||
"cmd.view_visual_style": "Visual style",
|
||||
"color.copy": "Click to copy color values",
|
||||
"color.edit": "Click to edit color",
|
||||
"color.gamma": "Showing color values in 0-255 gamma space",
|
||||
@@ -1133,6 +1151,7 @@
|
||||
"dxf.progress.write": "Writing DXF (5/5)",
|
||||
"dxf.tolerance": "Curve tolerance",
|
||||
"dxf.tolerance_help": "Visible bodies and shown sketches only. Curves use straight segments. Smaller tolerance gives finer curves and uses more time and memory.",
|
||||
"file.open.drawing_filter": "Designs, CAD files, meshes and drawings",
|
||||
"file_load.built": "Built so far: {built} of {total} features.",
|
||||
"file_load.converting": "Converting solid {done} of {total}",
|
||||
"file_load.inserting": "Inserting geometry",
|
||||
@@ -1174,6 +1193,26 @@
|
||||
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
|
||||
"help.solid.sweep": "Drags a sketch profile along a path to make a solid. Use it for pipes, rails, handles and any part of constant section that follows a curve.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"hole_presets.confirm_delete": "Confirm delete",
|
||||
"hole_presets.delete": "Delete",
|
||||
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
|
||||
"hole_presets.full": "The personal library already contains 64 presets.",
|
||||
"hole_presets.invalid_name": "Enter a short name without line breaks or control characters.",
|
||||
"hole_presets.invalid_parameters": "Complete the hole parameters using valid expressions of reasonable length.",
|
||||
"hole_presets.missing": "The selected preset is no longer available.",
|
||||
"hole_presets.name": "Preset name",
|
||||
"hole_presets.name_exists": "A preset with this name already exists. Use a different name.",
|
||||
"hole_presets.name_hint": "A unique name",
|
||||
"hole_presets.overwrite": "Save changes to selected preset",
|
||||
"hole_presets.preset": "Preset",
|
||||
"hole_presets.recall": "Recall",
|
||||
"hole_presets.rename": "Rename",
|
||||
"hole_presets.save": "Save new",
|
||||
"hole_presets.scope": "Saved in your profile for all projects.",
|
||||
"hole_presets.select": "Select a preset",
|
||||
"hole_presets.title": "Personal presets",
|
||||
"hole_presets.unsupported_options": "This preset uses thread options that this version cannot recall.",
|
||||
"hole_presets.unsupported_overwrite": "This preset contains options that cannot be overwritten safely.",
|
||||
"home.back": "Back to {name}",
|
||||
"home.crashes": "Crash reports",
|
||||
"home.design_workspace": "Design",
|
||||
@@ -1322,6 +1361,7 @@
|
||||
"motion.key_here": "Key here",
|
||||
"motion.key_here_hint": "Add a key at this step with the joint's value now",
|
||||
"motion.no_collisions": "No collisions over the study",
|
||||
"motion.plot_quantity": "Quantity",
|
||||
"motion.step_back": "Step back",
|
||||
"motion.step_forward": "Step on",
|
||||
"motion.to_end": "To the end",
|
||||
@@ -1334,6 +1374,7 @@
|
||||
"nav.fusion.help": "Middle drag pans, Shift+middle orbits, right drag orbits",
|
||||
"nav.inventor.help": "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
|
||||
"nav.openscad.help": "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms",
|
||||
"nav.perspective.sketch": "Sketch editing uses orthographic view. Finish the sketch to switch perspective.",
|
||||
"nav.solidworks.help": "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
|
||||
"nav.tinkercad.help": "Right drag orbits, middle or Shift+right drag pans",
|
||||
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
|
||||
@@ -1343,6 +1384,10 @@
|
||||
"nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.",
|
||||
"nav.touchpad.zoom": "Zoom (vertical scroll or pinch)",
|
||||
"needs two joints": "needs two joints",
|
||||
"network.allow_once": "Allow once",
|
||||
"network.always_allow": "Always allow this server",
|
||||
"network.prompt_detail": "This design links to a file on network location {host}. Opening it connects to that computer and may send your Windows sign-in to it. Only allow servers you trust.",
|
||||
"network.prompt_title": "Network location",
|
||||
"new configuration": "new configuration",
|
||||
"new parameter": "new parameter",
|
||||
"no hole": "no hole",
|
||||
@@ -1371,6 +1416,7 @@
|
||||
"pick the path": "pick the path",
|
||||
"prefs.add_item": "Add the item",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option also frees manipulator arrows",
|
||||
"prefs.anti_aliasing_amd_dx12": "Anti-aliasing uses 1× because the startup check drew nothing with multisampling and passed at 1×. You can choose another setting above; restart to apply it if the check passes.",
|
||||
"prefs.anti_aliasing_note": "Smooths jagged edges.",
|
||||
"prefs.applies_on_restart": "Applies on restart",
|
||||
"prefs.assistant": "Assistant",
|
||||
@@ -1443,12 +1489,17 @@
|
||||
"prefs.mouse_mapping": "Mouse mapping",
|
||||
"prefs.navigation": "Navigation",
|
||||
"prefs.navigation_note": "How the mouse, trackpad and 3D mouse move the view.",
|
||||
"prefs.network_host_remove": "Remove",
|
||||
"prefs.network_locations": "Allow network locations in designs",
|
||||
"prefs.network_locations_note": "Off: a design's linked files on another computer (\\\\server\\share) are opened only from the servers listed here, or after you allow one when asked.",
|
||||
"prefs.network_locations_section": "Network locations",
|
||||
"prefs.not_on_gpu": "not on this GPU",
|
||||
"prefs.on": "On",
|
||||
"prefs.opaque": "Opaque",
|
||||
"prefs.orbit_snap": "Hold Shift during orbit to snap to an axis.",
|
||||
"prefs.orientation": "Orientation",
|
||||
"prefs.overall_speed": "Overall speed",
|
||||
"prefs.placement": "Placement",
|
||||
"prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap",
|
||||
"prefs.preview_choices": "Preview of your choices",
|
||||
"prefs.quality_default": "default",
|
||||
@@ -1467,6 +1518,11 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
|
||||
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
|
||||
"prefs.shortcuts": "Shortcuts",
|
||||
"prefs.sketch_exit_view": "When a sketch ends",
|
||||
"prefs.sketch_exit_view.keep": "Keep the current view",
|
||||
"prefs.sketch_exit_view.previous": "Return to the previous view",
|
||||
"prefs.sketch_exit_view.unless_rotated": "Return unless rotated",
|
||||
"prefs.sketch_exit_view_note": "Where the view goes after Finish Sketch, Cancel or a double Esc.",
|
||||
"prefs.sketch_selection": "Sketch & selection",
|
||||
"prefs.sketch_selection_heading": "Sketch and selection",
|
||||
"prefs.sketch_selection_note": "How sketches snap and how clicks select.",
|
||||
@@ -1493,6 +1549,8 @@
|
||||
"prefs.units_precision": "Units and precision",
|
||||
"prefs.updates": "Updates",
|
||||
"prefs.view": "View behavior",
|
||||
"prefs.view_confirm_corner": "OK and Cancel in the view's corner",
|
||||
"prefs.view_confirm_corner_note": "While a dialog or a sketch is open, a check and a cross at the top right of the view do what the dialog's OK and Cancel do, or finish and cancel the sketch. The view navigator moves down below them.",
|
||||
"prefs.viewport": "Viewport",
|
||||
"prefs.wheel": "Wheel",
|
||||
"prefs.wheel_trackpad": "Wheel and trackpad",
|
||||
@@ -2180,12 +2238,18 @@
|
||||
"the parts library is not available": "the parts library is not available",
|
||||
"thread.automatic_size": "Automatic size",
|
||||
"thread.designation": "Designation",
|
||||
"thread.direction": "Thread direction",
|
||||
"thread.iso_metric": "ISO metric",
|
||||
"thread.left_hand": "Left-hand",
|
||||
"thread.right_hand": "Right-hand",
|
||||
"thread.select_size": "Select size",
|
||||
"timeline.delete_after_marker_one": "Delete 1 Feature After History Marker",
|
||||
"timeline.delete_after_marker_other": "Delete {count} Features After History Marker",
|
||||
"timeline.delete_folders_tip": "Keep their features and nested folders in the history",
|
||||
"timeline.delete_many": "Delete {count} Selected Features",
|
||||
"timeline.feature_error": "Error: {message}",
|
||||
"timeline.feature_hint": "Double-click to edit · drag to reorder · right-click for more",
|
||||
"timeline.feature_warning": "Warning: {message}",
|
||||
"timeline.group": "New Feature Folder",
|
||||
"timeline.move_many": "Move Timeline Items",
|
||||
"timeline.move_to_group": "Move Features to Folder",
|
||||
@@ -2382,10 +2446,12 @@
|
||||
"ui.agent_chat_key_in_env": "key set",
|
||||
"ui.agent_chat_left": "Left",
|
||||
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
|
||||
"ui.agent_chat_login_found": "login found",
|
||||
"ui.agent_chat_minimize": "Minimise to a strip",
|
||||
"ui.agent_chat_mode_all": "All",
|
||||
"ui.agent_chat_mode_ask": "Ask",
|
||||
"ui.agent_chat_mode_edits": "Edits",
|
||||
"ui.agent_chat_model_no_tools": "{model} can't use SolidCraft's tools, so it can only chat. Pick a model with tool support.",
|
||||
"ui.agent_chat_model_tip": "The model the agent uses. Your choice is remembered for this agent.",
|
||||
"ui.agent_chat_new": "New chat",
|
||||
"ui.agent_chat_new_confirm": "Start a new chat? The current one is cleared.",
|
||||
@@ -2394,6 +2460,7 @@
|
||||
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_images": "This agent doesn't take pictures",
|
||||
"ui.agent_chat_no_picture": "There is no picture on the clipboard",
|
||||
"ui.agent_chat_no_tool_call": "The model didn't use SolidCraft's tools, so the design wasn't changed. Choose a model that supports tool calling; see Local models in the agent chat guide.",
|
||||
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
|
||||
"ui.agent_chat_none_found_short": "No agent found",
|
||||
"ui.agent_chat_not_installed": "Not installed",
|
||||
@@ -2482,6 +2549,7 @@
|
||||
"ui.ai_http_busy": "the endpoint is busy or over its limit; try again later",
|
||||
"ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI",
|
||||
"ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name",
|
||||
"ui.ai_http_too_large": "the conversation is too large for this endpoint; start a new chat",
|
||||
"ui.ai_key": "API key",
|
||||
"ui.ai_key_hint": "optional; kept in the system keychain",
|
||||
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
|
||||
@@ -2528,9 +2596,27 @@
|
||||
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
|
||||
"ui.ai_test_ok": "Reached: {count} models",
|
||||
"ui.ai_testing": "Testing…",
|
||||
"ui.ai_tool_arguments": "Arguments",
|
||||
"ui.ai_tool_details": "Details",
|
||||
"ui.ai_tool_done": "done",
|
||||
"ui.ai_tool_failed": "failed",
|
||||
"ui.ai_tool_result": "Result",
|
||||
"ui.ai_tool_running": "running",
|
||||
"ui.ai_tool_sum_bodies_one": "{count} body",
|
||||
"ui.ai_tool_sum_bodies_other": "{count} bodies",
|
||||
"ui.ai_tool_sum_by": "by {value}",
|
||||
"ui.ai_tool_sum_edges_one": "{count} edge",
|
||||
"ui.ai_tool_sum_edges_other": "{count} edges",
|
||||
"ui.ai_tool_sum_faces_one": "{count} face",
|
||||
"ui.ai_tool_sum_faces_other": "{count} faces",
|
||||
"ui.ai_tool_sum_features_one": "{count} feature",
|
||||
"ui.ai_tool_sum_features_other": "{count} features",
|
||||
"ui.ai_tool_sum_measured": "Measured {what}",
|
||||
"ui.ai_tool_sum_more": "{steps}, and {count} more",
|
||||
"ui.ai_tool_sum_radius": "r {value}",
|
||||
"ui.ai_tool_sum_saved": "Saved {file}",
|
||||
"ui.ai_tool_sum_screenshot": "Screenshot",
|
||||
"ui.ai_tool_sum_then": "{first}, then {next}",
|
||||
"ui.ai_tool_unfold": "Click to show what it returned",
|
||||
"ui.ai_tool_waiting": "waiting",
|
||||
"ui.ai_toolbar_button": "Show Assistant button in application bar",
|
||||
@@ -2790,6 +2876,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "UTILITIES",
|
||||
"ui.heading_utility": "UTILITY",
|
||||
"ui.heading_weldments": "WELDMENTS",
|
||||
"ui.help": "Help",
|
||||
"ui.hide": "Hide",
|
||||
"ui.hideAxes": "Hide Axes",
|
||||
@@ -2876,6 +2963,11 @@
|
||||
"ui.move": "Move",
|
||||
"ui.multiple": "Multiple",
|
||||
"ui.named_views": "Named Views",
|
||||
"ui.nav_bridge": "Navigate with a device app on this computer",
|
||||
"ui.nav_bridge_connected": "Connected",
|
||||
"ui.nav_bridge_port": "Port",
|
||||
"ui.nav_bridge_tip": "A 3D input device's own program (for example a homemade 3D mouse's driver) moves the view over a local connection, so the mouse pointer stays yours. SolidCraft connects to it on this port on this computer only.",
|
||||
"ui.nav_bridge_waiting": "Waiting for the device app",
|
||||
"ui.navigation": "Navigation",
|
||||
"ui.needs_design": "{command}: open a design first",
|
||||
"ui.newSketchGroup": "New Sketch Group",
|
||||
@@ -2894,6 +2986,7 @@
|
||||
"ui.one_side": "One Side",
|
||||
"ui.open": "Open",
|
||||
"ui.open_ends_tip": "Touches nothing",
|
||||
"ui.open_linked_design": "Open Linked Design",
|
||||
"ui.open_preferences": "Open Preferences",
|
||||
"ui.open_recent": "Open Recent",
|
||||
"ui.operation": "Operation",
|
||||
@@ -2924,6 +3017,8 @@
|
||||
"ui.pinToToolbar": "Pin to Toolbar",
|
||||
"ui.pipe": "Pipe",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Placement handles",
|
||||
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
@@ -3094,6 +3189,7 @@
|
||||
"ui.total_angle": "Total angle",
|
||||
"ui.trackpad_pan_tip": "Two-finger scroll pans; in Fusion, Shift + two fingers orbits. Off: scroll zooms. Mouse wheel and pinch zoom; thumb wheel pans sideways.",
|
||||
"ui.trim": "Trim",
|
||||
"ui.turn_up_axis": "Turn Up Axis",
|
||||
"ui.twist": "Twist",
|
||||
"ui.two_finger_scroll_pans": "Two-finger scroll pans",
|
||||
"ui.two_sides": "Two Sides",
|
||||
@@ -3111,6 +3207,7 @@
|
||||
"ui.updateToCurrentView": "Update to Current View",
|
||||
"ui.vertical_constraint": "Vertical Constraint",
|
||||
"ui.view_centre": "View centre",
|
||||
"ui.viewport_theme_follow": "Follow theme",
|
||||
"ui.visual_shaded_hidden_edges": "Shaded with hidden edges",
|
||||
"ui.visual_shaded_with_edges": "Shaded with edges",
|
||||
"ui.visual_style": "Visual style",
|
||||
@@ -3175,6 +3272,9 @@
|
||||
"update.up_to_date": "SolidCraft is up to date.",
|
||||
"update.update_now": "Update now",
|
||||
"update.you_have": "You have {version}.",
|
||||
"view.auto_ortho_faces": "Perspective while orbiting, ortho on faces",
|
||||
"web.files.insert_drawings_hint": "CAD files, meshes, DXF or SVG into the open design",
|
||||
"web.files.insert_stored_drawings_hint": "Into the open design (CAD files, meshes, DXF, SVG)",
|
||||
"web.open_full": "Open in SolidCraft",
|
||||
"web.open_link": "Open linked design?",
|
||||
"web.open_link_confirm": "There are unsaved changes. Open the linked design in a new tab?",
|
||||
|
||||
@@ -1057,6 +1057,7 @@
|
||||
"motion.key_here": "Insertar fotograma clave aquí",
|
||||
"motion.key_here_hint": "Añadir un fotograma clave en este paso con el valor actual de la unión",
|
||||
"motion.no_collisions": "Sin colisiones durante el estudio",
|
||||
"motion.plot_quantity": "Cantidad",
|
||||
"motion.step_back": "Paso atrás",
|
||||
"motion.step_forward": "Paso adelante",
|
||||
"motion.to_end": "Al final",
|
||||
@@ -1758,6 +1759,9 @@
|
||||
"thread.designation": "Designación",
|
||||
"thread.iso_metric": "Métrica ISO",
|
||||
"thread.select_size": "Seleccionar tamaño",
|
||||
"timeline.delete_folders_tip": "Conservar sus operaciones y carpetas anidadas en el historial",
|
||||
"timeline.feature_error": "Error: {message}",
|
||||
"timeline.feature_warning": "Advertencia: {message}",
|
||||
"timeline.group": "Nueva carpeta de operaciones",
|
||||
"timeline.move_many": "Mover elementos de la línea de tiempo",
|
||||
"timeline.move_to_group": "Mover operaciones a la carpeta",
|
||||
@@ -2037,6 +2041,7 @@
|
||||
"ui.ai_http_busy": "el servicio está ocupado o ha superado su límite; inténtalo más tarde",
|
||||
"ui.ai_http_key": "el servicio ha rechazado la clave: compruébala en Preferencias > IA",
|
||||
"ui.ai_http_not_found": "no existe ese servicio o modelo: comprueba la URL base y el nombre del modelo",
|
||||
"ui.ai_http_too_large": "la conversación es demasiado grande para este endpoint; empieza un chat nuevo",
|
||||
"ui.ai_key": "Clave de API",
|
||||
"ui.ai_key_hint": "opcional; se guarda en el llavero del sistema",
|
||||
"ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.",
|
||||
@@ -2330,6 +2335,7 @@
|
||||
"ui.heading_torus": "TORO",
|
||||
"ui.heading_utilities": "UTILIDADES",
|
||||
"ui.heading_utility": "UTILIDAD",
|
||||
"ui.heading_weldments": "SOLDADURA",
|
||||
"ui.help": "Ayuda",
|
||||
"ui.hide": "Ocultar",
|
||||
"ui.hideAxes": "Ocultar ejes",
|
||||
@@ -2342,6 +2348,7 @@
|
||||
"ui.history_first": "Ir al principio",
|
||||
"ui.history_forward": "Avanzar un paso",
|
||||
"ui.history_last": "Ir al final",
|
||||
"ui.history_marker_tip": "Marcador del historial: arrastra para retroceder el modelo; las nuevas operaciones se insertan aquí",
|
||||
"ui.history_title": "Historial",
|
||||
"ui.hole": "Agujero",
|
||||
"ui.hole_tap_type": "Tipo de roscado del agujero",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"Preview Row": "Aperçu de la ligne",
|
||||
"Preview leaves the current design unchanged.": "L'aperçu ne modifie pas la conception actuelle.",
|
||||
"Profiles/Curves": "Profils/Courbes",
|
||||
"Quantity": "Grandeur",
|
||||
"Quantity": "Quantité",
|
||||
"RECENT": "RÉCENTS",
|
||||
"REFERENCE": "RÉFÉRENCE",
|
||||
"RULED SURFACE": "SURFACE RÉGLÉE",
|
||||
@@ -983,6 +983,7 @@
|
||||
"motion.key_here": "Placer une image clé ici",
|
||||
"motion.key_here_hint": "Ajouter une image clé à cette étape avec la valeur actuelle de la liaison",
|
||||
"motion.no_collisions": "Aucune collision au cours de l'étude",
|
||||
"motion.plot_quantity": "Grandeur",
|
||||
"motion.step_back": "Étape précédente",
|
||||
"motion.step_forward": "Étape suivante",
|
||||
"motion.to_end": "À la fin",
|
||||
@@ -1403,6 +1404,7 @@
|
||||
"ui.ai_http_busy": "le point d'accès est occupé ou a dépassé sa limite ; réessayez plus tard",
|
||||
"ui.ai_http_key": "le point d'accès a refusé la clé : vérifiez-la dans Préférences › IA",
|
||||
"ui.ai_http_not_found": "point d'accès ou modèle introuvable : vérifiez l'URL de base et le nom du modèle",
|
||||
"ui.ai_http_too_large": "la conversation est trop volumineuse pour ce point d'accès ; commencez une nouvelle discussion",
|
||||
"ui.ai_key": "Clé d'API",
|
||||
"ui.ai_key_hint": "facultative ; conservée dans le trousseau de clés du système",
|
||||
"ui.ai_key_needs_https": "Une clé n'est envoyée qu'en HTTPS, ou vers cet ordinateur.",
|
||||
@@ -1696,6 +1698,7 @@
|
||||
"ui.heading_torus": "TORE",
|
||||
"ui.heading_utilities": "UTILITAIRES",
|
||||
"ui.heading_utility": "UTILITAIRE",
|
||||
"ui.heading_weldments": "MÉCANO-SOUDURE",
|
||||
"ui.help": "Aide",
|
||||
"ui.hide": "Masquer",
|
||||
"ui.hideAxes": "Masquer les axes",
|
||||
|
||||
@@ -971,6 +971,7 @@
|
||||
"motion.key_here": "Inserir fotograma clave aquí",
|
||||
"motion.key_here_hint": "Engadir un fotograma clave neste paso co valor actual da unión",
|
||||
"motion.no_collisions": "Sen colisións durante o estudo",
|
||||
"motion.plot_quantity": "Cantidade",
|
||||
"motion.step_back": "Paso atrás",
|
||||
"motion.step_forward": "Paso adiante",
|
||||
"motion.to_end": "Ao final",
|
||||
@@ -1389,6 +1390,7 @@
|
||||
"ui.ai_http_busy": "o punto de acceso está ocupado ou superou o seu límite; téntao de novo máis tarde",
|
||||
"ui.ai_http_key": "o punto de acceso rexeitou a clave: compróbaa en Preferencias > IA",
|
||||
"ui.ai_http_not_found": "non existe ese punto de acceso ou modelo: comproba o URL base e o nome do modelo",
|
||||
"ui.ai_http_too_large": "a conversa é demasiado grande para este punto de acceso; comeza un chat novo",
|
||||
"ui.ai_key": "Clave da API",
|
||||
"ui.ai_key_hint": "opcional; gárdase no chaveiro do sistema",
|
||||
"ui.ai_key_needs_https": "A clave só se envía por HTTPS ou a este ordenador.",
|
||||
@@ -1682,6 +1684,7 @@
|
||||
"ui.heading_torus": "TORO",
|
||||
"ui.heading_utilities": "UTILIDADES",
|
||||
"ui.heading_utility": "UTILIDADE",
|
||||
"ui.heading_weldments": "SOLDADURA",
|
||||
"ui.help": "Axuda",
|
||||
"ui.hide": "Ocultar",
|
||||
"ui.hideAxes": "Ocultar os eixes",
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Kulcs ide",
|
||||
"motion.key_here_hint": "Kulcs hozzáadása ennél a lépésnél a csukló jelenlegi értékével",
|
||||
"motion.no_collisions": "Nincs ütközés a vizsgálat során",
|
||||
"motion.plot_quantity": "Mennyiség",
|
||||
"motion.step_back": "Lépés vissza",
|
||||
"motion.step_forward": "Lépés tovább",
|
||||
"motion.to_end": "A végére",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"ui.ai_http_busy": "a végpont foglalt vagy túllépte a korlátját; próbálja később",
|
||||
"ui.ai_http_key": "a végpont elutasította a kulcsot: ellenőrizze a Beállítások > AI menüben",
|
||||
"ui.ai_http_not_found": "nincs ilyen végpont vagy modell: ellenőrizze az alap-URL-t és a modell nevét",
|
||||
"ui.ai_http_too_large": "a beszélgetés túl nagy ehhez a végponthoz; kezdjen új csevegést",
|
||||
"ui.ai_key": "API-kulcs",
|
||||
"ui.ai_key_hint": "opcionális; a rendszer kulcstartójában tárolódik",
|
||||
"ui.ai_key_needs_https": "A kulcs csak HTTPS-en vagy erre a számítógépre kerül elküldésre.",
|
||||
@@ -1624,6 +1626,7 @@
|
||||
"ui.heading_torus": "TÓRUSZ",
|
||||
"ui.heading_utilities": "SEGÉDESZKÖZÖK",
|
||||
"ui.heading_utility": "SEGÉDESZKÖZ",
|
||||
"ui.heading_weldments": "HEGESZTÉS",
|
||||
"ui.help": "Súgó",
|
||||
"ui.hide": "Elrejtés",
|
||||
"ui.hideAxes": "Tengelyek elrejtése",
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Key di sini",
|
||||
"motion.key_here_hint": "Tambah key pada langkah ini dengan nilai sambungan saat ini",
|
||||
"motion.no_collisions": "Tidak ada tabrakan sepanjang studi",
|
||||
"motion.plot_quantity": "Jumlah",
|
||||
"motion.step_back": "Mundur satu langkah",
|
||||
"motion.step_forward": "Maju satu langkah",
|
||||
"motion.to_end": "Ke akhir",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"ui.ai_http_busy": "endpoint sibuk atau melebihi batas; coba lagi nanti",
|
||||
"ui.ai_http_key": "endpoint menolak key: periksa di Preferensi > AI",
|
||||
"ui.ai_http_not_found": "endpoint atau model tidak ada: periksa URL dasar dan nama model",
|
||||
"ui.ai_http_too_large": "percakapan terlalu besar untuk endpoint ini; mulai obrolan baru",
|
||||
"ui.ai_key": "Key API",
|
||||
"ui.ai_key_hint": "opsional; disimpan di keychain sistem",
|
||||
"ui.ai_key_needs_https": "Key hanya dikirim lewat HTTPS, atau ke komputer ini.",
|
||||
@@ -1624,6 +1626,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "UTILITAS",
|
||||
"ui.heading_utility": "UTILITAS",
|
||||
"ui.heading_weldments": "KONSTRUKSI LAS",
|
||||
"ui.help": "Bantuan",
|
||||
"ui.hide": "Sembunyikan",
|
||||
"ui.hideAxes": "Sembunyikan Sumbu",
|
||||
|
||||
@@ -873,6 +873,26 @@
|
||||
"help.mit": "Licenza MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.third_party": "Licenze di terze parti",
|
||||
"hole_presets.confirm_delete": "Conferma eliminazione",
|
||||
"hole_presets.delete": "Elimina",
|
||||
"hole_presets.delete_prompt": "Eliminare questo preset personale? I fori esistenti restano invariati.",
|
||||
"hole_presets.full": "La raccolta personale contiene già 64 preset.",
|
||||
"hole_presets.invalid_name": "Inserisci un nome breve, senza a capo o caratteri di controllo.",
|
||||
"hole_presets.invalid_parameters": "Completa i parametri del foro con espressioni valide e di lunghezza ragionevole.",
|
||||
"hole_presets.missing": "Il preset selezionato non è più disponibile.",
|
||||
"hole_presets.name": "Nome preset",
|
||||
"hole_presets.name_exists": "Esiste già un preset con questo nome. Usa un nome diverso.",
|
||||
"hole_presets.name_hint": "Un nome univoco",
|
||||
"hole_presets.overwrite": "Salva le modifiche nel preset selezionato",
|
||||
"hole_presets.preset": "Preset",
|
||||
"hole_presets.recall": "Richiama",
|
||||
"hole_presets.rename": "Rinomina",
|
||||
"hole_presets.save": "Salva nuovo",
|
||||
"hole_presets.scope": "Salvati nel tuo profilo per tutti i progetti.",
|
||||
"hole_presets.select": "Seleziona un preset",
|
||||
"hole_presets.title": "Preset personali",
|
||||
"hole_presets.unsupported_options": "Questo preset usa opzioni del filetto che questa versione non può richiamare.",
|
||||
"hole_presets.unsupported_overwrite": "Questo preset contiene opzioni che non possono essere sovrascritte in sicurezza.",
|
||||
"home.back": "Torna a {name}",
|
||||
"home.crashes": "Rapporti di arresto anomalo",
|
||||
"home.design_workspace": "Progettazione",
|
||||
@@ -976,6 +996,7 @@
|
||||
"motion.key_here": "Inserisci fotogramma chiave qui",
|
||||
"motion.key_here_hint": "Aggiungi un fotogramma chiave in questo passo con il valore attuale del giunto",
|
||||
"motion.no_collisions": "Nessuna collisione durante lo studio",
|
||||
"motion.plot_quantity": "Quantità",
|
||||
"motion.step_back": "Passo indietro",
|
||||
"motion.step_forward": "Passo avanti",
|
||||
"motion.to_end": "Alla fine",
|
||||
@@ -1130,7 +1151,10 @@
|
||||
"the parts library is not available": "la libreria delle parti non è disponibile",
|
||||
"thread.automatic_size": "Dimensione automatica",
|
||||
"thread.designation": "Designazione",
|
||||
"thread.direction": "Verso del filetto",
|
||||
"thread.iso_metric": "Metrica ISO",
|
||||
"thread.left_hand": "Sinistro",
|
||||
"thread.right_hand": "Destro",
|
||||
"thread.select_size": "Seleziona dimensione",
|
||||
"timeline.group": "Nuova cartella di lavorazioni",
|
||||
"timeline.move_many": "Sposta elementi della sequenza temporale",
|
||||
@@ -1405,6 +1429,7 @@
|
||||
"ui.ai_http_busy": "l'endpoint è occupato o ha superato il limite; riprova più tardi",
|
||||
"ui.ai_http_key": "l'endpoint ha rifiutato la chiave: controllala in Preferenze > IA",
|
||||
"ui.ai_http_not_found": "endpoint o modello inesistente: controlla l'URL di base e il nome del modello",
|
||||
"ui.ai_http_too_large": "la conversazione è troppo grande per questo endpoint; inizia una nuova chat",
|
||||
"ui.ai_key": "Chiave API",
|
||||
"ui.ai_key_hint": "facoltativa; conservata nel portachiavi di sistema",
|
||||
"ui.ai_key_needs_https": "La chiave viene inviata solo tramite HTTPS o a questo computer.",
|
||||
@@ -1699,6 +1724,7 @@
|
||||
"ui.heading_torus": "TORO",
|
||||
"ui.heading_utilities": "UTILITÀ",
|
||||
"ui.heading_utility": "UTILITÀ",
|
||||
"ui.heading_weldments": "SALDATURE",
|
||||
"ui.help": "Aiuto",
|
||||
"ui.hide": "Nascondi",
|
||||
"ui.hideAxes": "Nascondi assi",
|
||||
|
||||
+101
-1
@@ -173,7 +173,12 @@
|
||||
"Don't ask again": "今後確認しない",
|
||||
"Double-click to rename": "ダブルクリックで名前変更",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効",
|
||||
"Drag to move the boss across the model. It snaps to sketch points on the face.": "ドラッグしてボスをモデル上で移動します。面上のスケッチ点にスナップします。",
|
||||
"Drag to move the hole; click to remove it": "ドラッグで穴を移動、クリックで削除",
|
||||
"Drag to move the rest across the model. It snaps to sketch points on the face.": "ドラッグしてレストをモデル上で移動します。面上のスケッチ点にスナップします。",
|
||||
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "ドラッグしてスナップ フィットをモデル上で移動します。面上のスケッチ点にスナップします。",
|
||||
"Drag to point the catch in another direction.": "ドラッグしてキャッチの向きを変えます。",
|
||||
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "ドラッグしてレストを 5° 刻みで回転します。Alt または Ctrl を押したままにすると自由に回転します。",
|
||||
"Drive Joint": "ジョイントを駆動",
|
||||
"Duration": "時間",
|
||||
"Edges": "エッジ",
|
||||
@@ -398,7 +403,7 @@
|
||||
"Profile / Edges": "プロファイル / エッジ",
|
||||
"Profiles or curves": "プロファイルまたは曲線",
|
||||
"Profiles/Curves": "プロファイル/曲線",
|
||||
"Quantity": "量",
|
||||
"Quantity": "数量",
|
||||
"RECENT": "最近使用した項目",
|
||||
"REFERENCE": "参照",
|
||||
"RULED SURFACE": "ルールドサーフェス",
|
||||
@@ -692,6 +697,7 @@
|
||||
"cmd.component_capture_position": "位置をキャプチャ",
|
||||
"cmd.component_copies": "同一のコピーを検索",
|
||||
"cmd.component_create": "新規コンポーネント",
|
||||
"cmd.component_cycle_color": "コンポーネントの色を順に変更",
|
||||
"cmd.component_delete": "コンポーネントを削除",
|
||||
"cmd.component_extract": "ファイルに抽出",
|
||||
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
|
||||
@@ -753,6 +759,7 @@
|
||||
"cmd.document_metadata": "デザインのプロパティ",
|
||||
"cmd.document_metadata_remove": "デザインのプロパティを削除",
|
||||
"cmd.document_metadata_set": "デザインのプロパティを変更",
|
||||
"cmd.document_revision": "デザイン リビジョン",
|
||||
"cmd.document_units": "デザインの単位を変更",
|
||||
"cmd.edit_checkpoint": "チェックポイント",
|
||||
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
|
||||
@@ -776,6 +783,8 @@
|
||||
"cmd.inspect_accessibility": "アクセシビリティ解析",
|
||||
"cmd.inspect_center_of_mass": "重心",
|
||||
"cmd.inspect_clearance": "クリアランス",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
|
||||
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
|
||||
"cmd.inspect_compare": "ボディを比較",
|
||||
"cmd.inspect_continuity": "連続性解析",
|
||||
"cmd.inspect_curvature_comb": "曲率コーム解析",
|
||||
@@ -916,6 +925,7 @@
|
||||
"cmd.sheet_refold": "折り戻し",
|
||||
"cmd.sheet_rules": "板金ルールを一覧表示",
|
||||
"cmd.sheet_unfold": "展開",
|
||||
"cmd.sketch_arc3d": "3D 円弧",
|
||||
"cmd.sketch_arc_center_point": "中心点円弧",
|
||||
"cmd.sketch_arc_tangent": "正接円弧",
|
||||
"cmd.sketch_arc_three_point": "3点指定円弧",
|
||||
@@ -1038,17 +1048,25 @@
|
||||
"cmd.view_back": "背面ビュー",
|
||||
"cmd.view_bottom": "下面ビュー",
|
||||
"cmd.view_delete": "名前付きビューを削除",
|
||||
"cmd.view_fit": "フィット",
|
||||
"cmd.view_front": "正面ビュー",
|
||||
"cmd.view_graphics_preset": "グラフィックスプリセット",
|
||||
"cmd.view_grid": "グリッド",
|
||||
"cmd.view_ground_shadow": "地面の影",
|
||||
"cmd.view_home": "ホームビュー",
|
||||
"cmd.view_isometric": "等角ビュー",
|
||||
"cmd.view_left": "左側面ビュー",
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_navigate": "ビューナビゲーション",
|
||||
"cmd.view_navigation_bar": "ナビゲーションバー",
|
||||
"cmd.view_normal_to": "選択アイテムに垂直",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
"cmd.view_right": "右側面ビュー",
|
||||
"cmd.view_save": "新規名前付きビュー",
|
||||
"cmd.view_spin": "連続オービット",
|
||||
"cmd.view_tool": "ナビゲーションツール",
|
||||
"cmd.view_top": "上面ビュー",
|
||||
"cmd.view_visual_style": "表示スタイル",
|
||||
"color.copy": "クリックして色の値をコピー",
|
||||
"color.edit": "クリックして色を編集",
|
||||
"color.gamma": "0〜255のガンマ空間で色の値を表示",
|
||||
@@ -1133,6 +1151,7 @@
|
||||
"dxf.progress.write": "DXFを書き込み中 (5/5)",
|
||||
"dxf.tolerance": "曲線の許容誤差",
|
||||
"dxf.tolerance_help": "表示中のボディとスケッチを出力し、曲線は線分で近似します。許容誤差を小さくすると曲線が滑らかになりますが、処理時間とメモリ使用量が増えます。",
|
||||
"file.open.drawing_filter": "デザイン、CADファイル、メッシュ、図面",
|
||||
"file_load.built": "構築済み: 全{total}フィーチャー中{built}件。",
|
||||
"file_load.converting": "ソリッドを変換中: {done}/{total}",
|
||||
"file_load.inserting": "ジオメトリを挿入中",
|
||||
@@ -1174,6 +1193,26 @@
|
||||
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
|
||||
"help.solid.sweep": "スケッチの輪郭をパスに沿って動かし、立体を作ります。パイプ、レール、ハンドルなど、一定の断面で曲線をたどる部品に使います。",
|
||||
"help.third_party": "サードパーティライセンス",
|
||||
"hole_presets.confirm_delete": "削除を確認",
|
||||
"hole_presets.delete": "削除",
|
||||
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
|
||||
"hole_presets.full": "個人用ライブラリには既に64個のプリセットがあります。",
|
||||
"hole_presets.invalid_name": "改行や制御文字を含まない短い名前を入力してください。",
|
||||
"hole_presets.invalid_parameters": "適切な長さの有効な式で穴のパラメーターを入力してください。",
|
||||
"hole_presets.missing": "選択したプリセットは利用できなくなりました。",
|
||||
"hole_presets.name": "プリセット名",
|
||||
"hole_presets.name_exists": "同じ名前のプリセットが既にあります。別の名前を指定してください。",
|
||||
"hole_presets.name_hint": "一意の名前",
|
||||
"hole_presets.overwrite": "選択したプリセットの変更を保存",
|
||||
"hole_presets.preset": "プリセット",
|
||||
"hole_presets.recall": "呼び出し",
|
||||
"hole_presets.rename": "名前を変更",
|
||||
"hole_presets.save": "新規保存",
|
||||
"hole_presets.scope": "すべてのプロジェクトで使えるようにプロファイルに保存します。",
|
||||
"hole_presets.select": "プリセットを選択",
|
||||
"hole_presets.title": "個人用プリセット",
|
||||
"hole_presets.unsupported_options": "このプリセットには、このバージョンで呼び出せないねじオプションが含まれています。",
|
||||
"hole_presets.unsupported_overwrite": "このプリセットには安全に上書きできないオプションが含まれています。",
|
||||
"home.back": "{name} に戻る",
|
||||
"home.crashes": "クラッシュレポート",
|
||||
"home.design_workspace": "デザイン",
|
||||
@@ -1322,6 +1361,7 @@
|
||||
"motion.key_here": "ここにキーフレームを追加",
|
||||
"motion.key_here_hint": "このステップにジョイントの現在値でキーフレームを追加",
|
||||
"motion.no_collisions": "スタディ全体で衝突はありません",
|
||||
"motion.plot_quantity": "量",
|
||||
"motion.step_back": "前のステップ",
|
||||
"motion.step_forward": "次のステップ",
|
||||
"motion.to_end": "最後へ",
|
||||
@@ -1334,6 +1374,7 @@
|
||||
"nav.fusion.help": "中ドラッグでパン、Shift+中ドラッグで回転、右ドラッグで回転。",
|
||||
"nav.inventor.help": "中ドラッグでパン、Shift+中ドラッグで回転、Ctrl+中ドラッグでズーム、右ドラッグで回転。",
|
||||
"nav.openscad.help": "空きスペースで左ドラッグすると回転、Shift+左ドラッグで矩形選択、スケッチ ジオメトリはドラッグで編集。右または中ドラッグでパン、Shift+右または Shift+中ドラッグでズーム。",
|
||||
"nav.perspective.sketch": "スケッチ編集中は平行投影で表示されます。透視投影に切り替えるにはスケッチを終了してください。",
|
||||
"nav.solidworks.help": "中ドラッグで回転、Ctrl+中ドラッグでパン、Shift+中ドラッグでズーム、右ドラッグで回転。",
|
||||
"nav.tinkercad.help": "右ドラッグで回転、中ドラッグまたは Shift+右ドラッグでパン。",
|
||||
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
|
||||
@@ -1343,6 +1384,10 @@
|
||||
"nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。",
|
||||
"nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)",
|
||||
"needs two joints": "2つのジョイントが必要",
|
||||
"network.allow_once": "今回のみ許可",
|
||||
"network.always_allow": "このサーバーを常に許可",
|
||||
"network.prompt_detail": "このデザインはネットワーク上の場所 {host} にあるファイルにリンクしています。開くとそのコンピューターに接続し、Windows のサインイン情報が送信される場合があります。信頼できるサーバーだけを許可してください。",
|
||||
"network.prompt_title": "ネットワーク上の場所",
|
||||
"new configuration": "新規構成",
|
||||
"new parameter": "新規パラメータ",
|
||||
"no hole": "穴なし",
|
||||
@@ -1371,6 +1416,7 @@
|
||||
"pick the path": "パスを選択",
|
||||
"prefs.add_item": "項目を追加",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option でマニピュレーターの矢印も自由に動かす",
|
||||
"prefs.anti_aliasing_amd_dx12": "起動時のチェックでマルチサンプリングでは何も描画されず、1×では正常に描画されたため、アンチエイリアシングを1×に設定しました。上で別の設定を選択できます。再起動後のチェックに合格すると、その設定が適用されます。",
|
||||
"prefs.anti_aliasing_note": "輪郭のぎざぎざを滑らかにします。",
|
||||
"prefs.applies_on_restart": "再起動後に適用",
|
||||
"prefs.assistant": "アシスタント",
|
||||
@@ -1443,12 +1489,17 @@
|
||||
"prefs.mouse_mapping": "マウスの割り当て",
|
||||
"prefs.navigation": "ナビゲーション",
|
||||
"prefs.navigation_note": "マウス、トラックパッド、3Dマウスによるビューの操作。",
|
||||
"prefs.network_host_remove": "削除",
|
||||
"prefs.network_locations": "デザイン内のネットワーク上の場所を許可",
|
||||
"prefs.network_locations_note": "オフ: 別のコンピューター (\\\\server\\share) にあるデザインのリンク先ファイルは、ここに表示されたサーバー、または確認時に許可したサーバーからのみ開きます。",
|
||||
"prefs.network_locations_section": "ネットワーク上の場所",
|
||||
"prefs.not_on_gpu": "この GPU は非対応",
|
||||
"prefs.on": "オン",
|
||||
"prefs.opaque": "不透明",
|
||||
"prefs.orbit_snap": "回転中にShiftを押すと軸にスナップします。",
|
||||
"prefs.orientation": "方向",
|
||||
"prefs.overall_speed": "全体の速度",
|
||||
"prefs.placement": "配置",
|
||||
"prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する",
|
||||
"prefs.preview_choices": "選択した設定のプレビュー",
|
||||
"prefs.quality_default": "既定",
|
||||
@@ -1467,6 +1518,11 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
"prefs.shortcuts": "ショートカット",
|
||||
"prefs.sketch_exit_view": "スケッチ終了時のビュー",
|
||||
"prefs.sketch_exit_view.keep": "現在のビューを維持",
|
||||
"prefs.sketch_exit_view.previous": "前のビューに戻る",
|
||||
"prefs.sketch_exit_view.unless_rotated": "回転していなければ戻る",
|
||||
"prefs.sketch_exit_view_note": "「スケッチを終了」、キャンセル、Esc の 2 回押しの後にビューが移る先です。",
|
||||
"prefs.sketch_selection": "スケッチと選択",
|
||||
"prefs.sketch_selection_heading": "スケッチと選択",
|
||||
"prefs.sketch_selection_note": "スケッチのスナップとクリックによる選択。",
|
||||
@@ -1493,6 +1549,8 @@
|
||||
"prefs.units_precision": "単位と精度",
|
||||
"prefs.updates": "更新",
|
||||
"prefs.view": "ビューの動作",
|
||||
"prefs.view_confirm_corner": "ビューの隅に OK とキャンセル",
|
||||
"prefs.view_confirm_corner_note": "ダイアログまたはスケッチを開いている間、ビュー右上のチェックとバツが、ダイアログの OK とキャンセルと同じ操作を行うか、スケッチを終了またはキャンセルします。ビューナビゲーターはその下に移動します。",
|
||||
"prefs.viewport": "ビューポート",
|
||||
"prefs.wheel": "ホイール",
|
||||
"prefs.wheel_trackpad": "ホイールとトラックパッド",
|
||||
@@ -2180,12 +2238,18 @@
|
||||
"the parts library is not available": "パーツライブラリは利用できません",
|
||||
"thread.automatic_size": "サイズを自動選択",
|
||||
"thread.designation": "ねじの呼び",
|
||||
"thread.direction": "ねじの方向",
|
||||
"thread.iso_metric": "ISOメートルねじ",
|
||||
"thread.left_hand": "左ねじ",
|
||||
"thread.right_hand": "右ねじ",
|
||||
"thread.select_size": "サイズを選択",
|
||||
"timeline.delete_after_marker_one": "履歴マーカー以降の 1 個のフィーチャーを削除",
|
||||
"timeline.delete_after_marker_other": "履歴マーカー以降の {count} 個のフィーチャーを削除",
|
||||
"timeline.delete_folders_tip": "フィーチャーと入れ子のフォルダーを履歴に残します",
|
||||
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
|
||||
"timeline.feature_error": "エラー: {message}",
|
||||
"timeline.feature_hint": "ダブルクリックで編集 · ドラッグで並べ替え · 右クリックでその他の操作",
|
||||
"timeline.feature_warning": "警告: {message}",
|
||||
"timeline.group": "フィーチャーフォルダを新規作成",
|
||||
"timeline.move_many": "タイムライン項目を移動",
|
||||
"timeline.move_to_group": "フィーチャーをフォルダへ移動",
|
||||
@@ -2382,10 +2446,12 @@
|
||||
"ui.agent_chat_key_in_env": "キー設定済み",
|
||||
"ui.agent_chat_left": "左",
|
||||
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
|
||||
"ui.agent_chat_login_found": "ログイン情報あり",
|
||||
"ui.agent_chat_minimize": "細いバーに最小化",
|
||||
"ui.agent_chat_mode_all": "すべて",
|
||||
"ui.agent_chat_mode_ask": "確認",
|
||||
"ui.agent_chat_mode_edits": "編集",
|
||||
"ui.agent_chat_model_no_tools": "{model} は SolidCraft のツールを使えず、チャットのみ可能です。ツール対応のモデルを選んでください。",
|
||||
"ui.agent_chat_model_tip": "エージェントが使うモデル。選択はこのエージェントに対して記憶されます。",
|
||||
"ui.agent_chat_new": "新しいチャット",
|
||||
"ui.agent_chat_new_confirm": "新しいチャットを始めますか? 現在のチャットは消去されます。",
|
||||
@@ -2394,6 +2460,7 @@
|
||||
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
|
||||
"ui.agent_chat_no_picture": "クリップボードに画像がありません",
|
||||
"ui.agent_chat_no_tool_call": "モデルが SolidCraft のツールを使わなかったため、デザインは変更されませんでした。ツール呼び出しに対応したモデルを選んでください。エージェントチャットのガイドの「ローカルモデル」を参照してください。",
|
||||
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
|
||||
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
|
||||
"ui.agent_chat_not_installed": "未インストール",
|
||||
@@ -2482,6 +2549,7 @@
|
||||
"ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください",
|
||||
"ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください",
|
||||
"ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください",
|
||||
"ui.ai_http_too_large": "会話がこのエンドポイントには大きすぎます。新しいチャットを始めてください",
|
||||
"ui.ai_key": "API キー",
|
||||
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
|
||||
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
|
||||
@@ -2528,9 +2596,27 @@
|
||||
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
|
||||
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
|
||||
"ui.ai_testing": "テスト中…",
|
||||
"ui.ai_tool_arguments": "引数",
|
||||
"ui.ai_tool_details": "詳細",
|
||||
"ui.ai_tool_done": "完了",
|
||||
"ui.ai_tool_failed": "失敗",
|
||||
"ui.ai_tool_result": "結果",
|
||||
"ui.ai_tool_running": "実行中",
|
||||
"ui.ai_tool_sum_bodies_one": "{count} ボディ",
|
||||
"ui.ai_tool_sum_bodies_other": "{count} ボディ",
|
||||
"ui.ai_tool_sum_by": "距離 {value}",
|
||||
"ui.ai_tool_sum_edges_one": "{count} エッジ",
|
||||
"ui.ai_tool_sum_edges_other": "{count} エッジ",
|
||||
"ui.ai_tool_sum_faces_one": "{count} 面",
|
||||
"ui.ai_tool_sum_faces_other": "{count} 面",
|
||||
"ui.ai_tool_sum_features_one": "{count} フィーチャー",
|
||||
"ui.ai_tool_sum_features_other": "{count} フィーチャー",
|
||||
"ui.ai_tool_sum_measured": "測定: {what}",
|
||||
"ui.ai_tool_sum_more": "{steps}、ほか {count} 件",
|
||||
"ui.ai_tool_sum_radius": "R {value}",
|
||||
"ui.ai_tool_sum_saved": "{file} を保存しました",
|
||||
"ui.ai_tool_sum_screenshot": "スクリーンショット",
|
||||
"ui.ai_tool_sum_then": "{first}、続いて {next}",
|
||||
"ui.ai_tool_unfold": "クリックして結果を表示",
|
||||
"ui.ai_tool_waiting": "待機中",
|
||||
"ui.ai_toolbar_button": "アプリケーションバーにアシスタントボタンを表示",
|
||||
@@ -2790,6 +2876,7 @@
|
||||
"ui.heading_torus": "トーラス",
|
||||
"ui.heading_utilities": "ユーティリティ",
|
||||
"ui.heading_utility": "ユーティリティ",
|
||||
"ui.heading_weldments": "溶接構造",
|
||||
"ui.help": "ヘルプ",
|
||||
"ui.hide": "非表示",
|
||||
"ui.hideAxes": "軸を非表示",
|
||||
@@ -2876,6 +2963,11 @@
|
||||
"ui.move": "移動",
|
||||
"ui.multiple": "複数",
|
||||
"ui.named_views": "名前付きビュー",
|
||||
"ui.nav_bridge": "このコンピューター上のデバイスアプリで操作",
|
||||
"ui.nav_bridge_connected": "接続済み",
|
||||
"ui.nav_bridge_port": "ポート",
|
||||
"ui.nav_bridge_tip": "3D 入力デバイス専用のプログラム(自作 3D マウスのドライバーなど)がローカル接続でビューを動かすので、マウスポインターはそのまま使えます。SolidCraft はこのコンピューター上のこのポートにだけ接続します。",
|
||||
"ui.nav_bridge_waiting": "デバイスアプリを待機中",
|
||||
"ui.navigation": "ナビゲーション",
|
||||
"ui.needs_design": "{command}: 先にデザインを開いてください",
|
||||
"ui.newSketchGroup": "新しいスケッチグループ",
|
||||
@@ -2894,6 +2986,7 @@
|
||||
"ui.one_side": "片側",
|
||||
"ui.open": "開く",
|
||||
"ui.open_ends_tip": "どこにも接していない",
|
||||
"ui.open_linked_design": "リンクされたデザインを開く",
|
||||
"ui.open_preferences": "環境設定を開く",
|
||||
"ui.open_recent": "最近使った項目を開く",
|
||||
"ui.operation": "操作",
|
||||
@@ -2924,6 +3017,8 @@
|
||||
"ui.pinToToolbar": "ツールバーに固定",
|
||||
"ui.pipe": "パイプ",
|
||||
"ui.placement": "配置",
|
||||
"ui.placement_handles": "配置ハンドル",
|
||||
"ui.placement_handles_tip": "レスト、ボス、スナップ フィットのプレビューに表示されるハンドル: マーカーをドラッグしてモデル上を移動し、リングで回転します",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.plugins": "プラグイン",
|
||||
@@ -3094,6 +3189,7 @@
|
||||
"ui.total_angle": "合計角度",
|
||||
"ui.trackpad_pan_tip": "2本指スクロールでパンします。FusionではShift + 2本指スクロールで回転します。オフの場合はスクロールでズームします。マウスホイールとピンチ操作はズーム、サイドホイールは横方向のパンになります。",
|
||||
"ui.trim": "トリム",
|
||||
"ui.turn_up_axis": "上方向の軸を回転",
|
||||
"ui.twist": "ねじれ",
|
||||
"ui.two_finger_scroll_pans": "2本指スクロールでパン",
|
||||
"ui.two_sides": "両側",
|
||||
@@ -3111,6 +3207,7 @@
|
||||
"ui.updateToCurrentView": "現在のビューに更新",
|
||||
"ui.vertical_constraint": "垂直拘束",
|
||||
"ui.view_centre": "ビュー中心",
|
||||
"ui.viewport_theme_follow": "テーマに従う",
|
||||
"ui.visual_shaded_hidden_edges": "隠線付きシェーディング",
|
||||
"ui.visual_shaded_with_edges": "エッジ付きシェーディング",
|
||||
"ui.visual_style": "表示スタイル",
|
||||
@@ -3175,6 +3272,9 @@
|
||||
"update.up_to_date": "SolidCraft は最新です。",
|
||||
"update.update_now": "今すぐ更新",
|
||||
"update.you_have": "現在のバージョンは {version} です。",
|
||||
"view.auto_ortho_faces": "回転中は透視投影、面を正面に見ると平行投影",
|
||||
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
|
||||
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
|
||||
"web.open_full": "SolidCraft で開く",
|
||||
"web.open_link": "リンク先のデザインを開きますか?",
|
||||
"web.open_link_confirm": "保存していない変更があります。リンク先のデザインを新しいタブで開きますか?",
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"Profile / Edges": "프로파일 / 모서리",
|
||||
"Profiles or curves": "프로파일 또는 곡선",
|
||||
"Profiles/Curves": "프로파일/곡선",
|
||||
"Quantity": "양",
|
||||
"Quantity": "수량",
|
||||
"RECENT": "최근",
|
||||
"REFERENCE": "참조",
|
||||
"RULED SURFACE": "룰드 서피스",
|
||||
@@ -1151,6 +1151,7 @@
|
||||
"motion.key_here": "여기에 키프레임 추가",
|
||||
"motion.key_here_hint": "이 단계에 조인트의 현재 값으로 키프레임 추가",
|
||||
"motion.no_collisions": "연구 전체에서 충돌이 없습니다",
|
||||
"motion.plot_quantity": "양",
|
||||
"motion.step_back": "이전 단계",
|
||||
"motion.step_forward": "다음 단계",
|
||||
"motion.to_end": "끝으로",
|
||||
@@ -1163,6 +1164,7 @@
|
||||
"nav.fusion.help": "가운데 드래그는 이동, Shift+가운데 드래그는 회전, 오른쪽 드래그는 회전입니다",
|
||||
"nav.inventor.help": "가운데 드래그는 이동, Shift+가운데 드래그는 회전, Ctrl+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
|
||||
"nav.openscad.help": "빈 공간에서 왼쪽 드래그는 회전, Shift+왼쪽 드래그는 상자 선택, 스케치 형상 드래그는 편집입니다. 오른쪽 또는 가운데 드래그는 이동, Shift+오른쪽 또는 Shift+가운데 드래그는 확대/축소입니다",
|
||||
"nav.perspective.sketch": "스케치 편집 중에는 직교 뷰를 사용합니다. 원근 뷰를 전환하려면 스케치를 마치세요.",
|
||||
"nav.solidworks.help": "가운데 드래그는 회전, Ctrl+가운데 드래그는 이동, Shift+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
|
||||
"nav.tinkercad.help": "오른쪽 드래그는 회전, 가운데 또는 Shift+오른쪽 드래그는 이동입니다",
|
||||
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
|
||||
@@ -1306,6 +1308,8 @@
|
||||
"prefs.units_precision": "단위 및 정밀도",
|
||||
"prefs.updates": "업데이트",
|
||||
"prefs.view": "뷰 동작",
|
||||
"prefs.view_confirm_corner": "뷰 모서리에 확인 및 취소",
|
||||
"prefs.view_confirm_corner_note": "대화 상자나 스케치가 열려 있는 동안 뷰 오른쪽 위의 체크와 X 표시가 대화 상자의 확인과 취소와 같은 동작을 하거나 스케치를 마치고 취소합니다. 뷰 탐색기는 그 아래로 내려갑니다.",
|
||||
"prefs.viewport": "뷰포트",
|
||||
"prefs.wheel": "휠",
|
||||
"prefs.wheel_trackpad": "휠 및 트랙패드",
|
||||
@@ -1810,6 +1814,7 @@
|
||||
"ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요",
|
||||
"ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요",
|
||||
"ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요",
|
||||
"ui.ai_http_too_large": "대화가 이 엔드포인트에 비해 너무 큽니다. 새 채팅을 시작하세요",
|
||||
"ui.ai_key": "API 키",
|
||||
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
|
||||
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
|
||||
@@ -2103,6 +2108,7 @@
|
||||
"ui.heading_torus": "토러스",
|
||||
"ui.heading_utilities": "유틸리티",
|
||||
"ui.heading_utility": "유틸리티",
|
||||
"ui.heading_weldments": "용접 구조물",
|
||||
"ui.help": "도움말",
|
||||
"ui.hide": "숨기기",
|
||||
"ui.hideAxes": "축 숨기기",
|
||||
@@ -2200,6 +2206,7 @@
|
||||
"ui.one_side": "한 방향",
|
||||
"ui.open": "열기",
|
||||
"ui.open_ends_tip": "아무것과도 닿지 않음",
|
||||
"ui.open_linked_design": "연결된 디자인 열기",
|
||||
"ui.open_preferences": "환경 설정 열기",
|
||||
"ui.open_recent": "최근 항목 열기",
|
||||
"ui.operation": "작업",
|
||||
@@ -2401,6 +2408,7 @@
|
||||
"ui.up_axis_note": "뷰(축 뷰, 홈, 보기)에서 위쪽을 가리키는 축입니다. 디자인은 변경되지 않습니다.",
|
||||
"ui.updateToCurrentView": "현재 보기로 업데이트",
|
||||
"ui.view_centre": "뷰 중심",
|
||||
"ui.viewport_theme_follow": "테마 따르기",
|
||||
"ui.visual_shaded_hidden_edges": "음영 처리 및 숨겨진 모서리",
|
||||
"ui.visual_shaded_with_edges": "음영 처리 및 모서리",
|
||||
"ui.visual_style": "시각 스타일",
|
||||
|
||||
@@ -971,6 +971,7 @@
|
||||
"motion.key_here": "Kunci di sini",
|
||||
"motion.key_here_hint": "Tambah kekunci pada langkah ini dengan nilai sendi sekarang",
|
||||
"motion.no_collisions": "Tiada perlanggaran sepanjang kajian",
|
||||
"motion.plot_quantity": "Kuantiti",
|
||||
"motion.step_back": "Langkah ke belakang",
|
||||
"motion.step_forward": "Langkah ke hadapan",
|
||||
"motion.to_end": "Ke penghujung",
|
||||
@@ -1389,6 +1390,7 @@
|
||||
"ui.ai_http_busy": "titik akhir sibuk atau telah melebihi had; cuba lagi kemudian",
|
||||
"ui.ai_http_key": "kunci ditolak oleh titik akhir: semak dalam Tetapan > AI",
|
||||
"ui.ai_http_not_found": "titik akhir atau model tidak ditemui: semak URL asas dan nama model",
|
||||
"ui.ai_http_too_large": "perbualan terlalu besar untuk titik akhir ini; mulakan sembang baharu",
|
||||
"ui.ai_key": "Kunci API",
|
||||
"ui.ai_key_hint": "tidak wajib; disimpan dalam rantai kunci sistem",
|
||||
"ui.ai_key_needs_https": "Kunci hanya dihantar melalui HTTPS, atau ke komputer ini.",
|
||||
@@ -1682,6 +1684,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "UTILITI",
|
||||
"ui.heading_utility": "UTILITI",
|
||||
"ui.heading_weldments": "KIMPALAN",
|
||||
"ui.help": "Bantuan",
|
||||
"ui.hide": "Sembunyi",
|
||||
"ui.hideAxes": "Sembunyi Paksi",
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Sleutel hier",
|
||||
"motion.key_here_hint": "Voeg een sleutel toe op deze stap met de huidige waarde van de verbinding",
|
||||
"motion.no_collisions": "Geen botsingen in de studie",
|
||||
"motion.plot_quantity": "Aantal",
|
||||
"motion.step_back": "Stap terug",
|
||||
"motion.step_forward": "Stap verder",
|
||||
"motion.to_end": "Naar het einde",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"ui.ai_http_busy": "het eindpunt is bezet of boven zijn limiet; probeer het later opnieuw",
|
||||
"ui.ai_http_key": "het eindpunt weigerde de sleutel: controleer deze in Voorkeuren > AI",
|
||||
"ui.ai_http_not_found": "geen eindpunt of model gevonden: controleer de basis-URL en de modelnaam",
|
||||
"ui.ai_http_too_large": "het gesprek is te groot voor dit endpoint; begin een nieuwe chat",
|
||||
"ui.ai_key": "API-sleutel",
|
||||
"ui.ai_key_hint": "optioneel; bewaard in de sleutelhanger van het systeem",
|
||||
"ui.ai_key_needs_https": "Een sleutel wordt alleen via HTTPS of naar deze computer verzonden.",
|
||||
@@ -1624,6 +1626,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "HULPPROGRAMMA'S",
|
||||
"ui.heading_utility": "HULPPROGRAMMA",
|
||||
"ui.heading_weldments": "LASWERK",
|
||||
"ui.help": "Help",
|
||||
"ui.hide": "Verbergen",
|
||||
"ui.hideAxes": "Assen verbergen",
|
||||
|
||||
@@ -124,6 +124,11 @@
|
||||
"Don't ask again": "Nie pytaj ponownie",
|
||||
"Double-click to rename": "Kliknij dwukrotnie, aby zmienić nazwę",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dwukrotne kliknięcie środkowym przyciskiem w widoku: Widok główny (kadruje to, co widoczne), Dopasuj (kadruje to, co widoczne, zachowując orientację) lub nic",
|
||||
"Drag to move the boss across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć występ po modelu. Przyciąga się do punktów szkicu na ścianie.",
|
||||
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć podparcie po modelu. Przyciąga się do punktów szkicu na ścianie.",
|
||||
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć zatrzask po modelu. Przyciąga się do punktów szkicu na ścianie.",
|
||||
"Drag to point the catch in another direction.": "Przeciągnij, aby skierować zaczep w inną stronę.",
|
||||
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Przeciągnij, aby obrócić podparcie co 5°. Przytrzymaj Alt lub Ctrl, aby obracać swobodnie.",
|
||||
"Drive Joint": "Steruj złączem",
|
||||
"Duration": "Czas trwania",
|
||||
"Edges": "Krawędzie",
|
||||
@@ -568,6 +573,7 @@
|
||||
"cmd.document_metadata": "Właściwości projektu",
|
||||
"cmd.document_metadata_remove": "Usuń właściwość projektu",
|
||||
"cmd.document_metadata_set": "Zmień właściwości projektu",
|
||||
"cmd.document_revision": "Wersja projektu",
|
||||
"cmd.document_units": "Zmień jednostki projektu",
|
||||
"cmd.edit_checkpoint": "Punkt kontrolny",
|
||||
"cmd.edit_restore_checkpoint": "Przywróć punkt kontrolny",
|
||||
@@ -1026,6 +1032,7 @@
|
||||
"motion.key_here": "Wstaw klatkę kluczową tutaj",
|
||||
"motion.key_here_hint": "Dodaj klatkę kluczową w tym kroku z bieżącą wartością złącza",
|
||||
"motion.no_collisions": "Brak kolizji w trakcie badania",
|
||||
"motion.plot_quantity": "Liczba",
|
||||
"motion.step_back": "Krok wstecz",
|
||||
"motion.step_forward": "Krok naprzód",
|
||||
"motion.to_end": "Na koniec",
|
||||
@@ -1131,6 +1138,7 @@
|
||||
"prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.",
|
||||
"prefs.orientation": "Orientacja",
|
||||
"prefs.overall_speed": "Szybkość ogólna",
|
||||
"prefs.placement": "Umieszczanie",
|
||||
"prefs.preview_choices": "Podgląd wybranych opcji",
|
||||
"prefs.quality_default": "domyślnie",
|
||||
"prefs.quality_fastest": "najszybciej",
|
||||
@@ -1575,6 +1583,7 @@
|
||||
"ui.ai_http_busy": "punkt końcowy jest zajęty lub przekroczył limit; spróbuj ponownie później",
|
||||
"ui.ai_http_key": "punkt końcowy odrzucił klucz: sprawdź go w Preferencje > AI",
|
||||
"ui.ai_http_not_found": "nie ma takiego punktu końcowego lub modelu: sprawdź bazowy URL i nazwę modelu",
|
||||
"ui.ai_http_too_large": "rozmowa jest za duża dla tego punktu końcowego; rozpocznij nowy czat",
|
||||
"ui.ai_key": "Klucz API",
|
||||
"ui.ai_key_hint": "opcjonalny; przechowywany w systemowym pęku kluczy",
|
||||
"ui.ai_key_needs_https": "Klucz jest wysyłany tylko przez HTTPS albo do tego komputera.",
|
||||
@@ -1868,6 +1877,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "NARZĘDZIA",
|
||||
"ui.heading_utility": "NARZĘDZIE",
|
||||
"ui.heading_weldments": "SPAWANIE",
|
||||
"ui.help": "Pomoc",
|
||||
"ui.hide": "Ukryj",
|
||||
"ui.hideAxes": "Ukryj osie",
|
||||
@@ -1947,6 +1957,11 @@
|
||||
"ui.move": "Przesuń",
|
||||
"ui.multiple": "Wiele",
|
||||
"ui.named_views": "Widoki nazwane",
|
||||
"ui.nav_bridge": "Nawiguj programem urządzenia na tym komputerze",
|
||||
"ui.nav_bridge_connected": "Połączono",
|
||||
"ui.nav_bridge_port": "Port",
|
||||
"ui.nav_bridge_tip": "Własny program urządzenia wejściowego 3D (np. sterownik domowej myszy 3D) porusza widokiem przez połączenie lokalne, więc kursor myszy zostaje Twój. SolidCraft łączy się z nim na tym porcie, tylko na tym komputerze.",
|
||||
"ui.nav_bridge_waiting": "Czekam na program urządzenia",
|
||||
"ui.navigation": "Nawigacja",
|
||||
"ui.needs_design": "{command}: najpierw otwórz projekt",
|
||||
"ui.newSketchGroup": "Nowa grupa szkiców",
|
||||
@@ -1990,6 +2005,8 @@
|
||||
"ui.pinToToolbar": "Przypnij do paska narzędzi",
|
||||
"ui.pipe": "Rura",
|
||||
"ui.placement": "Umiejscowienie",
|
||||
"ui.placement_handles": "Uchwyty umieszczania",
|
||||
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
|
||||
"ui.plane": "Płaszczyzna",
|
||||
"ui.plane_at_angle": "Płaszczyzna pod kątem",
|
||||
"ui.plugins": "Wtyczki",
|
||||
|
||||
@@ -1145,6 +1145,7 @@
|
||||
"motion.key_here": "Inserir quadro-chave aqui",
|
||||
"motion.key_here_hint": "Adicionar um quadro-chave neste passo com o valor atual da junta",
|
||||
"motion.no_collisions": "Sem colisões durante o estudo",
|
||||
"motion.plot_quantity": "Quantidade",
|
||||
"motion.step_back": "Passo anterior",
|
||||
"motion.step_forward": "Próximo passo",
|
||||
"motion.to_end": "Para o fim",
|
||||
@@ -1704,6 +1705,7 @@
|
||||
"ui.ai_http_busy": "o endpoint está ocupado ou acima do limite; tente de novo mais tarde",
|
||||
"ui.ai_http_key": "o endpoint recusou a chave: verifique-a em Preferências > IA",
|
||||
"ui.ai_http_not_found": "endpoint ou modelo inexistente: verifique a URL base e o nome do modelo",
|
||||
"ui.ai_http_too_large": "a conversa é grande demais para este endpoint; comece um novo chat",
|
||||
"ui.ai_key": "Chave de API",
|
||||
"ui.ai_key_hint": "opcional; guardada no chaveiro do sistema",
|
||||
"ui.ai_key_needs_https": "Uma chave só é enviada por HTTPS ou para este computador.",
|
||||
@@ -2004,6 +2006,7 @@
|
||||
"ui.heading_torus": "TORO",
|
||||
"ui.heading_utilities": "UTILITÁRIOS",
|
||||
"ui.heading_utility": "UTILITÁRIO",
|
||||
"ui.heading_weldments": "SOLDAGEM",
|
||||
"ui.help": "Ajuda",
|
||||
"ui.hide": "Ocultar",
|
||||
"ui.hideAxes": "Ocultar eixos",
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Cheie aici",
|
||||
"motion.key_here_hint": "Adaugă o cheie la acest pas cu valoarea curentă a îmbinării",
|
||||
"motion.no_collisions": "Nicio coliziune în studiu",
|
||||
"motion.plot_quantity": "Cantitate",
|
||||
"motion.step_back": "Pas înapoi",
|
||||
"motion.step_forward": "Pas înainte",
|
||||
"motion.to_end": "La sfârșit",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"ui.ai_http_busy": "endpointul este ocupat sau și-a depășit limita; încercați mai târziu",
|
||||
"ui.ai_http_key": "endpointul a refuzat cheia: verificați-o în Preferințe > AI",
|
||||
"ui.ai_http_not_found": "nu există un astfel de endpoint sau model: verificați URL-ul de bază și numele modelului",
|
||||
"ui.ai_http_too_large": "conversația este prea mare pentru acest endpoint; începeți un chat nou",
|
||||
"ui.ai_key": "Cheie API",
|
||||
"ui.ai_key_hint": "opțional; păstrată în depozitul de chei al sistemului",
|
||||
"ui.ai_key_needs_https": "O cheie se trimite doar prin HTTPS sau către acest calculator.",
|
||||
@@ -1624,6 +1626,7 @@
|
||||
"ui.heading_torus": "TOR",
|
||||
"ui.heading_utilities": "UTILITARE",
|
||||
"ui.heading_utility": "UTILITAR",
|
||||
"ui.heading_weldments": "SUDURĂ",
|
||||
"ui.help": "Ajutor",
|
||||
"ui.hide": "Ascunde",
|
||||
"ui.hideAxes": "Ascunde axele",
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"Preview Row": "Строка предпросмотра",
|
||||
"Preview leaves the current design unchanged.": "Предпросмотр не меняет текущий дизайн.",
|
||||
"Profiles/Curves": "Профили/кривые",
|
||||
"Quantity": "Величина",
|
||||
"Quantity": "Количество",
|
||||
"RECENT": "НЕДАВНИЕ",
|
||||
"REFERENCE": "ССЫЛОЧНАЯ ГЕОМЕТРИЯ",
|
||||
"RULED SURFACE": "ЛИНЕЙЧАТАЯ ПОВЕРХНОСТЬ",
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Вставить ключевой кадр здесь",
|
||||
"motion.key_here_hint": "Добавить ключевой кадр на этом шаге с текущим значением сопряжения",
|
||||
"motion.no_collisions": "За всё исследование столкновений нет",
|
||||
"motion.plot_quantity": "Величина",
|
||||
"motion.step_back": "Шаг назад",
|
||||
"motion.step_forward": "Шаг вперёд",
|
||||
"motion.to_end": "В конец",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"ui.ai_http_busy": "конечная точка занята или превысила лимит; повторите позже",
|
||||
"ui.ai_http_key": "конечная точка отклонила ключ: проверьте его в Настройки > ИИ",
|
||||
"ui.ai_http_not_found": "нет такой конечной точки или модели: проверьте базовый URL и имя модели",
|
||||
"ui.ai_http_too_large": "разговор слишком велик для этой конечной точки; начните новый чат",
|
||||
"ui.ai_key": "Ключ API",
|
||||
"ui.ai_key_hint": "необязательно; хранится в системной связке ключей",
|
||||
"ui.ai_key_needs_https": "Ключ отправляется только по HTTPS или на этот компьютер.",
|
||||
@@ -1681,6 +1683,7 @@
|
||||
"ui.heading_torus": "ТОР",
|
||||
"ui.heading_utilities": "УТИЛИТЫ",
|
||||
"ui.heading_utility": "УТИЛИТА",
|
||||
"ui.heading_weldments": "СВАРКА",
|
||||
"ui.help": "Справка",
|
||||
"ui.hide": "Скрыть",
|
||||
"ui.hideAxes": "Скрыть оси",
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Nyckel här",
|
||||
"motion.key_here_hint": "Lägg till en nyckel vid detta steg med ledens nuvarande värde",
|
||||
"motion.no_collisions": "Inga kollisioner under studien",
|
||||
"motion.plot_quantity": "Antal",
|
||||
"motion.step_back": "Steg bakåt",
|
||||
"motion.step_forward": "Steg framåt",
|
||||
"motion.to_end": "Till slutet",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"ui.ai_http_busy": "slutpunkten är upptagen eller över sin gräns; försök igen senare",
|
||||
"ui.ai_http_key": "slutpunkten nekade nyckeln: kontrollera den under Inställningar > AI",
|
||||
"ui.ai_http_not_found": "ingen sådan slutpunkt eller modell: kontrollera bas-URL:en och modellnamnet",
|
||||
"ui.ai_http_too_large": "konversationen är för stor för den här slutpunkten; starta en ny chatt",
|
||||
"ui.ai_key": "API-nyckel",
|
||||
"ui.ai_key_hint": "valfritt; lagras i systemets nyckelring",
|
||||
"ui.ai_key_needs_https": "En nyckel skickas endast via HTTPS, eller till den här datorn.",
|
||||
@@ -1624,6 +1626,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "VERKTYG",
|
||||
"ui.heading_utility": "VERKTYG",
|
||||
"ui.heading_weldments": "SVETSNING",
|
||||
"ui.help": "Hjälp",
|
||||
"ui.hide": "Dölj",
|
||||
"ui.hideAxes": "Dölj axlar",
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"Preview Row": "Satırı önizle",
|
||||
"Preview leaves the current design unchanged.": "Önizleme geçerli tasarımı değiştirmez.",
|
||||
"Profiles/Curves": "Profiller/Eğriler",
|
||||
"Quantity": "Büyüklük",
|
||||
"Quantity": "Adet",
|
||||
"RECENT": "SON KULLANILANLAR",
|
||||
"REFERENCE": "REFERANS",
|
||||
"RULED SURFACE": "REGLE YÜZEY",
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "Buraya anahtar kare ekle",
|
||||
"motion.key_here_hint": "Bu adıma bağlantının mevcut değeriyle bir anahtar kare ekle",
|
||||
"motion.no_collisions": "Etüt boyunca çarpışma yok",
|
||||
"motion.plot_quantity": "Büyüklük",
|
||||
"motion.step_back": "Bir adım geri",
|
||||
"motion.step_forward": "Bir adım ileri",
|
||||
"motion.to_end": "Sona git",
|
||||
@@ -1388,6 +1389,7 @@
|
||||
"ui.ai_http_busy": "uç nokta meşgul veya sınırda; daha sonra tekrar deneyin",
|
||||
"ui.ai_http_key": "uç nokta anahtarı reddetti: Ayarlar > AI altında kontrol edin",
|
||||
"ui.ai_http_not_found": "Uç nokta veya model bulunamadı: temel URL'yi ve model adını kontrol edin",
|
||||
"ui.ai_http_too_large": "konuşma bu uç nokta için çok büyük; yeni bir sohbet başlatın",
|
||||
"ui.ai_key": "API anahtarı",
|
||||
"ui.ai_key_hint": "isteğe bağlı; sistem anahtar zincirinde saklanır",
|
||||
"ui.ai_key_needs_https": "Anahtar yalnızca HTTPS üzerinden veya bu bilgisayara gönderilir.",
|
||||
@@ -1681,6 +1683,7 @@
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.heading_utilities": "ARAÇLAR",
|
||||
"ui.heading_utility": "YARDIMCI ARAÇLAR",
|
||||
"ui.heading_weldments": "KAYNAK",
|
||||
"ui.help": "Yardım",
|
||||
"ui.hide": "Gizle",
|
||||
"ui.hideAxes": "Eksenleri gizle",
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
"Profile / Edges": "Профіль / ребра",
|
||||
"Profiles or curves": "Профілі або криві",
|
||||
"Profiles/Curves": "Профілі/криві",
|
||||
"Quantity": "Величина",
|
||||
"Quantity": "Кількість",
|
||||
"RECENT": "НЕДАВНІ",
|
||||
"REFERENCE": "ДОПОМІЖНА ГЕОМЕТРІЯ",
|
||||
"RULED SURFACE": "ЛІНІЙЧАСТА ПОВЕРХНЯ",
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"motion.key_here": "Вставити ключовий кадр тут",
|
||||
"motion.key_here_hint": "Додати ключовий кадр на цьому кроці з поточним значенням з’єднання",
|
||||
"motion.no_collisions": "За все дослідження зіткнень немає",
|
||||
"motion.plot_quantity": "Величина",
|
||||
"motion.step_back": "Крок назад",
|
||||
"motion.step_forward": "Крок уперед",
|
||||
"motion.to_end": "У кінець",
|
||||
@@ -1684,6 +1685,7 @@
|
||||
"ui.ai_http_busy": "кінцева точка зайнята або перевищила ліміт; спробуйте пізніше",
|
||||
"ui.ai_http_key": "кінцева точка відхилила ключ: перевірте його в Налаштування > ШІ",
|
||||
"ui.ai_http_not_found": "немає такої кінцевої точки чи моделі: перевірте базову URL-адресу й назву моделі",
|
||||
"ui.ai_http_too_large": "розмова завелика для цієї кінцевої точки; почніть новий чат",
|
||||
"ui.ai_key": "Ключ API",
|
||||
"ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів",
|
||||
"ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.",
|
||||
@@ -1977,6 +1979,7 @@
|
||||
"ui.heading_torus": "ТОР",
|
||||
"ui.heading_utilities": "УТИЛІТИ",
|
||||
"ui.heading_utility": "УТИЛІТА",
|
||||
"ui.heading_weldments": "ЗВАРЮВАННЯ",
|
||||
"ui.help": "Довідка",
|
||||
"ui.hide": "Приховати",
|
||||
"ui.hideAxes": "Приховати осі",
|
||||
|
||||
@@ -971,6 +971,7 @@
|
||||
"motion.key_here": "Đặt khóa tại đây",
|
||||
"motion.key_here_hint": "Thêm một khóa tại bước này với giá trị hiện tại của khớp nối",
|
||||
"motion.no_collisions": "Không có va chạm trong suốt quá trình khảo sát",
|
||||
"motion.plot_quantity": "Số lượng",
|
||||
"motion.step_back": "Lùi một bước",
|
||||
"motion.step_forward": "Tiến một bước",
|
||||
"motion.to_end": "Đến cuối",
|
||||
@@ -1389,6 +1390,7 @@
|
||||
"ui.ai_http_busy": "điểm cuối đang bận hoặc vượt quá giới hạn; vui lòng thử lại sau",
|
||||
"ui.ai_http_key": "điểm cuối từ chối khóa API: hãy kiểm tra trong Tùy chọn cài đặt > AI",
|
||||
"ui.ai_http_not_found": "không tìm thấy điểm cuối hoặc mô hình: hãy kiểm tra URL cơ sở và tên mô hình",
|
||||
"ui.ai_http_too_large": "cuộc trò chuyện quá lớn đối với điểm cuối này; hãy bắt đầu cuộc trò chuyện mới",
|
||||
"ui.ai_key": "Khóa API",
|
||||
"ui.ai_key_hint": "tùy chọn; lưu trữ bảo mật trong chuỗi khóa hệ thống",
|
||||
"ui.ai_key_needs_https": "Khóa chỉ được gửi qua HTTPS, hoặc trực tiếp trên máy tính này.",
|
||||
@@ -1682,6 +1684,7 @@
|
||||
"ui.heading_torus": "HÌNH XUYẾN",
|
||||
"ui.heading_utilities": "TIỆN ÍCH",
|
||||
"ui.heading_utility": "TIỆN ÍCH",
|
||||
"ui.heading_weldments": "KẾT CẤU HÀN",
|
||||
"ui.help": "Trợ giúp",
|
||||
"ui.hide": "Ẩn",
|
||||
"ui.hideAxes": "Ẩn Các trục",
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
"motion.key_here": "在此建立關鍵影格",
|
||||
"motion.key_here_hint": "在此步驟新增關鍵影格,使用接頭目前的值",
|
||||
"motion.no_collisions": "整個研究中沒有碰撞",
|
||||
"motion.plot_quantity": "數量",
|
||||
"motion.step_back": "上一步",
|
||||
"motion.step_forward": "下一步",
|
||||
"motion.to_end": "到結尾",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"ui.ai_http_busy": "端點繁忙或已達上限,請稍後再試",
|
||||
"ui.ai_http_key": "端點拒絕了金鑰:請在「偏好設定 > AI」中檢查",
|
||||
"ui.ai_http_not_found": "找不到該端點或模型:請檢查基礎 URL 和模型名稱",
|
||||
"ui.ai_http_too_large": "對話對此端點來說過大;請開始新的聊天",
|
||||
"ui.ai_key": "API 金鑰",
|
||||
"ui.ai_key_hint": "可選;儲存在系統鑰匙圈中",
|
||||
"ui.ai_key_needs_https": "金鑰只會透過 HTTPS 傳送,或傳送到本機。",
|
||||
@@ -1624,6 +1626,7 @@
|
||||
"ui.heading_torus": "圓環體",
|
||||
"ui.heading_utilities": "工具",
|
||||
"ui.heading_utility": "實用工具",
|
||||
"ui.heading_weldments": "焊件",
|
||||
"ui.help": "說明",
|
||||
"ui.hide": "隱藏",
|
||||
"ui.hideAxes": "隱藏軸",
|
||||
|
||||
+101
-1
@@ -173,7 +173,12 @@
|
||||
"Don't ask again": "不再询问",
|
||||
"Double-click to rename": "双击重命名",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "在视图中双击中键:主视图(主视图,框住所显示的内容)、适合窗口(框住所显示的内容,保持方向)或无动作",
|
||||
"Drag to move the boss across the model. It snaps to sketch points on the face.": "拖动以在模型上移动凸台。它会吸附到面上的草图点。",
|
||||
"Drag to move the hole; click to remove it": "拖动以移动孔,单击以删除",
|
||||
"Drag to move the rest across the model. It snaps to sketch points on the face.": "拖动以在模型上移动垫块。它会吸附到面上的草图点。",
|
||||
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "拖动以在模型上移动卡扣。它会吸附到面上的草图点。",
|
||||
"Drag to point the catch in another direction.": "拖动以将卡钩指向另一个方向。",
|
||||
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "拖动以 5° 为步长旋转垫块。按住 Alt 或 Ctrl 可自由旋转。",
|
||||
"Drive Joint": "驱动接头",
|
||||
"Duration": "时长",
|
||||
"Edges": "边",
|
||||
@@ -398,7 +403,7 @@
|
||||
"Profile / Edges": "轮廓 / 边",
|
||||
"Profiles or curves": "轮廓或曲线",
|
||||
"Profiles/Curves": "轮廓/曲线",
|
||||
"Quantity": "物理量",
|
||||
"Quantity": "数量",
|
||||
"RECENT": "最近使用",
|
||||
"REFERENCE": "参考",
|
||||
"RULED SURFACE": "直纹曲面",
|
||||
@@ -692,6 +697,7 @@
|
||||
"cmd.component_capture_position": "捕获位置",
|
||||
"cmd.component_copies": "查找相同副本",
|
||||
"cmd.component_create": "新建组件",
|
||||
"cmd.component_cycle_color": "循环切换组件颜色",
|
||||
"cmd.component_delete": "删除组件",
|
||||
"cmd.component_extract": "提取到文件",
|
||||
"cmd.component_from_bodies": "由实体创建组件",
|
||||
@@ -753,6 +759,7 @@
|
||||
"cmd.document_metadata": "设计属性",
|
||||
"cmd.document_metadata_remove": "删除设计属性",
|
||||
"cmd.document_metadata_set": "更改设计属性",
|
||||
"cmd.document_revision": "设计版本号",
|
||||
"cmd.document_units": "更改设计单位",
|
||||
"cmd.edit_checkpoint": "检查点",
|
||||
"cmd.edit_restore_checkpoint": "恢复检查点",
|
||||
@@ -776,6 +783,8 @@
|
||||
"cmd.inspect_accessibility": "可达性分析",
|
||||
"cmd.inspect_center_of_mass": "质心",
|
||||
"cmd.inspect_clearance": "间隙",
|
||||
"cmd.inspect_colored_components": "启用组件颜色",
|
||||
"cmd.inspect_colored_components_disable": "禁用组件颜色",
|
||||
"cmd.inspect_compare": "比较实体",
|
||||
"cmd.inspect_continuity": "连续性分析",
|
||||
"cmd.inspect_curvature_comb": "曲率梳分析",
|
||||
@@ -916,6 +925,7 @@
|
||||
"cmd.sheet_refold": "重新折弯",
|
||||
"cmd.sheet_rules": "列出钣金规则",
|
||||
"cmd.sheet_unfold": "展开",
|
||||
"cmd.sketch_arc3d": "三维圆弧",
|
||||
"cmd.sketch_arc_center_point": "中心点圆弧",
|
||||
"cmd.sketch_arc_tangent": "相切圆弧",
|
||||
"cmd.sketch_arc_three_point": "三点圆弧",
|
||||
@@ -1038,17 +1048,25 @@
|
||||
"cmd.view_back": "后视图",
|
||||
"cmd.view_bottom": "仰视图",
|
||||
"cmd.view_delete": "删除命名视图",
|
||||
"cmd.view_fit": "适合视图",
|
||||
"cmd.view_front": "前视图",
|
||||
"cmd.view_graphics_preset": "图形预设",
|
||||
"cmd.view_grid": "网格",
|
||||
"cmd.view_ground_shadow": "地面阴影",
|
||||
"cmd.view_home": "主视图",
|
||||
"cmd.view_isometric": "等轴测视图",
|
||||
"cmd.view_left": "左视图",
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_navigate": "导航视图",
|
||||
"cmd.view_navigation_bar": "导航栏",
|
||||
"cmd.view_normal_to": "垂直于所选面",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
"cmd.view_right": "右视图",
|
||||
"cmd.view_save": "新建命名视图",
|
||||
"cmd.view_spin": "连续环绕",
|
||||
"cmd.view_tool": "导航工具",
|
||||
"cmd.view_top": "俯视图",
|
||||
"cmd.view_visual_style": "视觉样式",
|
||||
"color.copy": "单击复制颜色值",
|
||||
"color.edit": "单击编辑颜色",
|
||||
"color.gamma": "以 0–255 伽马空间显示颜色值",
|
||||
@@ -1133,6 +1151,7 @@
|
||||
"dxf.progress.write": "正在写入 DXF (5/5)",
|
||||
"dxf.tolerance": "曲线公差",
|
||||
"dxf.tolerance_help": "仅导出可见实体和显示的草图。曲线使用直线段近似。公差越小,曲线越精细,但需要更多时间和内存。",
|
||||
"file.open.drawing_filter": "设计、CAD 文件、网格和图纸",
|
||||
"file_load.built": "已构建:{built}/{total} 个特征。",
|
||||
"file_load.converting": "正在转换实体 {done}/{total}",
|
||||
"file_load.inserting": "正在插入几何体",
|
||||
@@ -1174,6 +1193,26 @@
|
||||
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
|
||||
"help.solid.sweep": "沿路径拖动草图轮廓生成实体。用于管道、导轨、手柄等截面恒定且沿曲线延伸的零件。",
|
||||
"help.third_party": "第三方许可证",
|
||||
"hole_presets.confirm_delete": "确认删除",
|
||||
"hole_presets.delete": "删除",
|
||||
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
|
||||
"hole_presets.full": "个人库中已有64个预设。",
|
||||
"hole_presets.invalid_name": "请输入不含换行符或控制字符的简短名称。",
|
||||
"hole_presets.invalid_parameters": "请使用长度适当的有效表达式填写孔参数。",
|
||||
"hole_presets.missing": "所选预设已不可用。",
|
||||
"hole_presets.name": "预设名称",
|
||||
"hole_presets.name_exists": "同名预设已存在,请使用其他名称。",
|
||||
"hole_presets.name_hint": "唯一名称",
|
||||
"hole_presets.overwrite": "保存对所选预设的修改",
|
||||
"hole_presets.preset": "预设",
|
||||
"hole_presets.recall": "调用",
|
||||
"hole_presets.rename": "重命名",
|
||||
"hole_presets.save": "另存为新预设",
|
||||
"hole_presets.scope": "保存在您的配置中,适用于所有项目。",
|
||||
"hole_presets.select": "选择预设",
|
||||
"hole_presets.title": "个人预设",
|
||||
"hole_presets.unsupported_options": "此预设使用了当前版本无法调用的螺纹选项。",
|
||||
"hole_presets.unsupported_overwrite": "此预设包含无法安全覆盖的选项。",
|
||||
"home.back": "返回 {name}",
|
||||
"home.crashes": "崩溃报告",
|
||||
"home.design_workspace": "设计",
|
||||
@@ -1322,6 +1361,7 @@
|
||||
"motion.key_here": "在此添加关键帧",
|
||||
"motion.key_here_hint": "在此步骤添加关键帧,使用关节的当前值",
|
||||
"motion.no_collisions": "整个研究中没有碰撞",
|
||||
"motion.plot_quantity": "物理量",
|
||||
"motion.step_back": "上一步",
|
||||
"motion.step_forward": "下一步",
|
||||
"motion.to_end": "到结尾",
|
||||
@@ -1334,6 +1374,7 @@
|
||||
"nav.fusion.help": "中键拖动平移,Shift+中键拖动旋转,右键拖动旋转",
|
||||
"nav.inventor.help": "中键拖动平移,Shift+中键拖动旋转,Ctrl+中键拖动缩放,右键拖动旋转",
|
||||
"nav.openscad.help": "在空白处左键拖动旋转,Shift+左键拖动框选,拖动草图几何可编辑;右键或中键拖动平移,Shift+右键或 Shift+中键拖动缩放",
|
||||
"nav.perspective.sketch": "编辑草图时使用正交视图。完成草图后才能切换透视。",
|
||||
"nav.solidworks.help": "中键拖动旋转,Ctrl+中键拖动平移,Shift+中键拖动缩放,右键拖动旋转",
|
||||
"nav.tinkercad.help": "右键拖动旋转,中键或 Shift+右键拖动平移",
|
||||
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
|
||||
@@ -1343,6 +1384,10 @@
|
||||
"nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。",
|
||||
"nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)",
|
||||
"needs two joints": "需要两个接头",
|
||||
"network.allow_once": "仅允许一次",
|
||||
"network.always_allow": "始终允许此服务器",
|
||||
"network.prompt_detail": "此设计链接到网络位置 {host} 上的文件。打开它会连接到该计算机,并可能向其发送你的 Windows 登录信息。只允许你信任的服务器。",
|
||||
"network.prompt_title": "网络位置",
|
||||
"new configuration": "新配置",
|
||||
"new parameter": "新参数",
|
||||
"no hole": "无孔",
|
||||
@@ -1371,6 +1416,7 @@
|
||||
"pick the path": "选择路径",
|
||||
"prefs.add_item": "添加项目",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option 也可自由拖动操纵器箭头",
|
||||
"prefs.anti_aliasing_amd_dx12": "由于启动检查在启用多重采样时未绘制任何内容,而在1×时通过了检查,抗锯齿已设为1×。您可以在上方选择其他设置;重启后若检查通过,该设置将生效。",
|
||||
"prefs.anti_aliasing_note": "平滑锯齿边缘。",
|
||||
"prefs.applies_on_restart": "重启后生效",
|
||||
"prefs.assistant": "助手",
|
||||
@@ -1443,12 +1489,17 @@
|
||||
"prefs.mouse_mapping": "鼠标映射",
|
||||
"prefs.navigation": "导航",
|
||||
"prefs.navigation_note": "鼠标、触控板和3D鼠标如何移动视图。",
|
||||
"prefs.network_host_remove": "删除",
|
||||
"prefs.network_locations": "允许设计中的网络位置",
|
||||
"prefs.network_locations_note": "关闭:设计中位于其他计算机 (\\\\server\\share) 上的链接文件,只会从此处列出的服务器打开,或在询问时你允许的服务器打开。",
|
||||
"prefs.network_locations_section": "网络位置",
|
||||
"prefs.not_on_gpu": "此 GPU 不支持",
|
||||
"prefs.on": "开启",
|
||||
"prefs.opaque": "不透明",
|
||||
"prefs.orbit_snap": "旋转时按住Shift可捕捉到坐标轴。",
|
||||
"prefs.orientation": "方向",
|
||||
"prefs.overall_speed": "整体速度",
|
||||
"prefs.placement": "放置",
|
||||
"prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点",
|
||||
"prefs.preview_choices": "所选设置预览",
|
||||
"prefs.quality_default": "默认",
|
||||
@@ -1467,6 +1518,11 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
"prefs.shortcuts": "快捷键",
|
||||
"prefs.sketch_exit_view": "草图结束时的视图",
|
||||
"prefs.sketch_exit_view.keep": "保持当前视图",
|
||||
"prefs.sketch_exit_view.previous": "返回之前的视图",
|
||||
"prefs.sketch_exit_view.unless_rotated": "未旋转时返回",
|
||||
"prefs.sketch_exit_view_note": "“完成草图”、取消或连按两次 Esc 后视图的去向。",
|
||||
"prefs.sketch_selection": "草图与选择",
|
||||
"prefs.sketch_selection_heading": "草图和选择",
|
||||
"prefs.sketch_selection_note": "草图如何捕捉,以及单击如何选择。",
|
||||
@@ -1493,6 +1549,8 @@
|
||||
"prefs.units_precision": "单位与精度",
|
||||
"prefs.updates": "更新",
|
||||
"prefs.view": "视图行为",
|
||||
"prefs.view_confirm_corner": "在视图角落显示确定和取消",
|
||||
"prefs.view_confirm_corner_note": "打开对话框或草图时,视图右上角的勾号和叉号与对话框的确定和取消作用相同,或完成和取消草图。视图导航器会下移到它们下方。",
|
||||
"prefs.viewport": "视口",
|
||||
"prefs.wheel": "滚轮",
|
||||
"prefs.wheel_trackpad": "滚轮与触控板",
|
||||
@@ -2180,12 +2238,18 @@
|
||||
"the parts library is not available": "零件库不可用",
|
||||
"thread.automatic_size": "自动尺寸",
|
||||
"thread.designation": "螺纹标记",
|
||||
"thread.direction": "螺纹方向",
|
||||
"thread.iso_metric": "ISO 公制",
|
||||
"thread.left_hand": "左旋",
|
||||
"thread.right_hand": "右旋",
|
||||
"thread.select_size": "选择尺寸",
|
||||
"timeline.delete_after_marker_one": "删除历史标记之后的 1 个特征",
|
||||
"timeline.delete_after_marker_other": "删除历史标记之后的 {count} 个特征",
|
||||
"timeline.delete_folders_tip": "在历史记录中保留其中的特征和嵌套文件夹",
|
||||
"timeline.delete_many": "删除所选的 {count} 个特征",
|
||||
"timeline.feature_error": "错误:{message}",
|
||||
"timeline.feature_hint": "双击编辑 · 拖动以重新排序 · 右键查看更多操作",
|
||||
"timeline.feature_warning": "警告:{message}",
|
||||
"timeline.group": "新建特征文件夹",
|
||||
"timeline.move_many": "移动时间线项目",
|
||||
"timeline.move_to_group": "将特征移至文件夹",
|
||||
@@ -2382,10 +2446,12 @@
|
||||
"ui.agent_chat_key_in_env": "已设置密钥",
|
||||
"ui.agent_chat_left": "左侧",
|
||||
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
|
||||
"ui.agent_chat_login_found": "已找到登录信息",
|
||||
"ui.agent_chat_minimize": "最小化为细条",
|
||||
"ui.agent_chat_mode_all": "全部",
|
||||
"ui.agent_chat_mode_ask": "询问",
|
||||
"ui.agent_chat_mode_edits": "编辑",
|
||||
"ui.agent_chat_model_no_tools": "{model} 无法使用 SolidCraft 的工具,只能聊天。请选择支持工具的模型。",
|
||||
"ui.agent_chat_model_tip": "智能体使用的模型。你的选择会为此智能体记住。",
|
||||
"ui.agent_chat_new": "新对话",
|
||||
"ui.agent_chat_new_confirm": "开始新对话?当前对话将被清除。",
|
||||
@@ -2394,6 +2460,7 @@
|
||||
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_images": "该智能体不接收图片",
|
||||
"ui.agent_chat_no_picture": "剪贴板上没有图片",
|
||||
"ui.agent_chat_no_tool_call": "模型没有使用 SolidCraft 的工具,因此设计没有更改。请选择支持工具调用的模型;参见智能体聊天指南中的“本地模型”。",
|
||||
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
|
||||
"ui.agent_chat_none_found_short": "未找到智能体",
|
||||
"ui.agent_chat_not_installed": "未安装",
|
||||
@@ -2482,6 +2549,7 @@
|
||||
"ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试",
|
||||
"ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查",
|
||||
"ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称",
|
||||
"ui.ai_http_too_large": "对话对此端点来说过大,请开始新的聊天",
|
||||
"ui.ai_key": "API 密钥",
|
||||
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
|
||||
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
|
||||
@@ -2528,9 +2596,27 @@
|
||||
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
|
||||
"ui.ai_test_ok": "已连接:{count} 个模型",
|
||||
"ui.ai_testing": "正在测试…",
|
||||
"ui.ai_tool_arguments": "参数",
|
||||
"ui.ai_tool_details": "详细信息",
|
||||
"ui.ai_tool_done": "完成",
|
||||
"ui.ai_tool_failed": "失败",
|
||||
"ui.ai_tool_result": "结果",
|
||||
"ui.ai_tool_running": "运行中",
|
||||
"ui.ai_tool_sum_bodies_one": "{count} 个实体",
|
||||
"ui.ai_tool_sum_bodies_other": "{count} 个实体",
|
||||
"ui.ai_tool_sum_by": "距离 {value}",
|
||||
"ui.ai_tool_sum_edges_one": "{count} 条边",
|
||||
"ui.ai_tool_sum_edges_other": "{count} 条边",
|
||||
"ui.ai_tool_sum_faces_one": "{count} 个面",
|
||||
"ui.ai_tool_sum_faces_other": "{count} 个面",
|
||||
"ui.ai_tool_sum_features_one": "{count} 个特征",
|
||||
"ui.ai_tool_sum_features_other": "{count} 个特征",
|
||||
"ui.ai_tool_sum_measured": "已测量 {what}",
|
||||
"ui.ai_tool_sum_more": "{steps},另有 {count} 项",
|
||||
"ui.ai_tool_sum_radius": "R {value}",
|
||||
"ui.ai_tool_sum_saved": "已保存 {file}",
|
||||
"ui.ai_tool_sum_screenshot": "截图",
|
||||
"ui.ai_tool_sum_then": "{first},然后 {next}",
|
||||
"ui.ai_tool_unfold": "点击显示返回内容",
|
||||
"ui.ai_tool_waiting": "等待中",
|
||||
"ui.ai_toolbar_button": "在工具栏显示按钮",
|
||||
@@ -2790,6 +2876,7 @@
|
||||
"ui.heading_torus": "圆环体",
|
||||
"ui.heading_utilities": "工具",
|
||||
"ui.heading_utility": "实用工具",
|
||||
"ui.heading_weldments": "焊件",
|
||||
"ui.help": "帮助",
|
||||
"ui.hide": "隐藏",
|
||||
"ui.hideAxes": "隐藏轴",
|
||||
@@ -2876,6 +2963,11 @@
|
||||
"ui.move": "移动",
|
||||
"ui.multiple": "多个",
|
||||
"ui.named_views": "命名视图",
|
||||
"ui.nav_bridge": "使用本机上的设备程序导航",
|
||||
"ui.nav_bridge_connected": "已连接",
|
||||
"ui.nav_bridge_port": "端口",
|
||||
"ui.nav_bridge_tip": "3D 输入设备自带的程序(例如自制 3D 鼠标的驱动程序)通过本地连接移动视图,鼠标指针仍由你使用。SolidCraft 只在本机的此端口上连接它。",
|
||||
"ui.nav_bridge_waiting": "正在等待设备程序",
|
||||
"ui.navigation": "导航",
|
||||
"ui.needs_design": "{command}: 请先打开一个设计",
|
||||
"ui.newSketchGroup": "新建草图组",
|
||||
@@ -2894,6 +2986,7 @@
|
||||
"ui.one_side": "单侧",
|
||||
"ui.open": "打开",
|
||||
"ui.open_ends_tip": "未接触任何曲线",
|
||||
"ui.open_linked_design": "打开链接设计",
|
||||
"ui.open_preferences": "打开首选项",
|
||||
"ui.open_recent": "打开最近",
|
||||
"ui.operation": "操作",
|
||||
@@ -2924,6 +3017,8 @@
|
||||
"ui.pinToToolbar": "固定到工具栏",
|
||||
"ui.pipe": "管道",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "垫块、凸台或卡扣预览上的手柄:拖动标记在模型上移动,用转环旋转",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
@@ -3094,6 +3189,7 @@
|
||||
"ui.total_angle": "总角度",
|
||||
"ui.trackpad_pan_tip": "双指滚动平移;Fusion 预设下,Shift + 双指滚动旋转视图。关闭时滚动缩放。鼠标滚轮和双指捏合缩放,拇指滚轮横向平移。",
|
||||
"ui.trim": "修剪",
|
||||
"ui.turn_up_axis": "旋转向上轴",
|
||||
"ui.twist": "扭转",
|
||||
"ui.two_finger_scroll_pans": "双指滚动平移",
|
||||
"ui.two_sides": "双侧",
|
||||
@@ -3111,6 +3207,7 @@
|
||||
"ui.updateToCurrentView": "更新为当前视图",
|
||||
"ui.vertical_constraint": "垂直约束",
|
||||
"ui.view_centre": "视图中心",
|
||||
"ui.viewport_theme_follow": "跟随主题",
|
||||
"ui.visual_shaded_hidden_edges": "带隐藏边的着色",
|
||||
"ui.visual_shaded_with_edges": "带边着色",
|
||||
"ui.visual_style": "视觉样式",
|
||||
@@ -3175,6 +3272,9 @@
|
||||
"update.up_to_date": "SolidCraft 已是最新版本。",
|
||||
"update.update_now": "立即更新",
|
||||
"update.you_have": "当前版本为 {version}。",
|
||||
"view.auto_ortho_faces": "旋转时透视,正对表面时正交",
|
||||
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
|
||||
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
|
||||
"web.open_full": "在 SolidCraft 中打开",
|
||||
"web.open_link": "打开链接的设计?",
|
||||
"web.open_link_confirm": "有未保存的更改。要在新标签页中打开链接的设计吗?",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"window": [540, 360],
|
||||
"title_bar": 32,
|
||||
"icon_size": 148,
|
||||
"text_size": 12,
|
||||
"app": [132, 176],
|
||||
"applications": [408, 176]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draw the SolidCraft disk image window background (background.tiff).
|
||||
|
||||
A blueprint sheet: deep blue with a drafting grid, a double border with ticks, a circle round each
|
||||
icon, dash-dot centre lines, a leader arrow from the app to Applications and a small title block.
|
||||
Every line stops short of text. The window geometry comes from layout.json, shared with
|
||||
settings.py, so the art and the icons agree.
|
||||
|
||||
Authoring tool only (assets/AGENTS.md): run it after editing, commit the TIFF. Packaging never
|
||||
runs it. Needs Python 3.11+, Pillow and macOS `tiffutil`:
|
||||
|
||||
python3 -m venv target/dmg-art && target/dmg-art/bin/pip install pillow
|
||||
target/dmg-art/bin/python assets/platforms/macos/dmg/make_background.py [--preview DIR]
|
||||
|
||||
All coordinates are points; the TIFF holds a 1x and a 144 dpi 2x representation.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
FONTS = HERE.parents[2] / "fonts"
|
||||
LAYOUT = json.loads((HERE / "layout.json").read_text())
|
||||
W, H = LAYOUT["window"]
|
||||
ICON = LAYOUT["icon_size"]
|
||||
AX, CY = LAYOUT["app"]
|
||||
BX, _ = LAYOUT["applications"]
|
||||
SS = 6 # drawing scale: 3x the 2x output, reduced afterwards
|
||||
|
||||
PAPER_TOP, PAPER_BOTTOM = (28, 84, 158), (18, 58, 118)
|
||||
LINE = (226, 238, 252)
|
||||
PALE = (190, 215, 245)
|
||||
# Finder draws the icon names itself, black in Light and white in Dark appearance, and the art
|
||||
# cannot change that: each name sits on a mid-tone plate that both colours read on.
|
||||
PLATE = (92, 132, 196)
|
||||
PLATE_W, PLATE_H, PLATE_GAP = 116, 22, 8
|
||||
|
||||
|
||||
def px(v):
|
||||
return int(round(v * SS))
|
||||
|
||||
|
||||
def sans(size, weight):
|
||||
# IBMPlexSans-Variable axes: wght, wdth
|
||||
f = ImageFont.truetype(str(FONTS / "ibm-plex-sans/IBMPlexSans-Variable.ttf"), px(size))
|
||||
f.set_variation_by_axes([weight, 100])
|
||||
return f
|
||||
|
||||
|
||||
def mono(size):
|
||||
return ImageFont.truetype(str(FONTS / "ibm-plex-mono/IBMPlexMono-Regular.ttf"), px(size))
|
||||
|
||||
|
||||
def text(d, xy, s, f, fill, anchor="la", tracking=0.0):
|
||||
"""Draw text with letter spacing (points); anchor is the usual Pillow one for the whole run."""
|
||||
x, y = xy
|
||||
if tracking == 0:
|
||||
d.text((px(x), px(y)), s, font=f, fill=fill, anchor=anchor)
|
||||
return
|
||||
widths = [f.getlength(c) / SS for c in s]
|
||||
total = sum(widths) + tracking * (len(s) - 1)
|
||||
if anchor[0] == "m":
|
||||
x -= total / 2
|
||||
elif anchor[0] == "r":
|
||||
x -= total
|
||||
for c, w in zip(s, widths):
|
||||
d.text((px(x), px(y)), c, font=f, fill=fill, anchor="l" + anchor[1])
|
||||
x += w + tracking
|
||||
|
||||
|
||||
def dashdot(d, p0, p1, fill, width=1.0, pattern=(14, 3, 2, 3)):
|
||||
"""A centre line: long dash, gap, short dash, gap. Horizontal or vertical."""
|
||||
(x0, y0), (x1, y1) = p0, p1
|
||||
horiz = y0 == y1
|
||||
a, b = (x0, x1) if horiz else (y0, y1)
|
||||
pos, k = a, 0
|
||||
while pos < b:
|
||||
seg = min(pattern[k % 4], b - pos)
|
||||
if k % 2 == 0:
|
||||
q, r = ((pos, y0), (pos + seg, y0)) if horiz else ((x0, pos), (x0, pos + seg))
|
||||
d.line([(px(q[0]), px(q[1])), (px(r[0]), px(r[1]))], fill=fill, width=px(width))
|
||||
pos += seg
|
||||
k += 1
|
||||
|
||||
|
||||
def draw_sheet():
|
||||
img = Image.new("RGB", (px(W), px(H)))
|
||||
d = ImageDraw.Draw(img)
|
||||
for y in range(px(H)):
|
||||
t = y / px(H)
|
||||
d.line([(0, y), (px(W), y)], fill=tuple(round(a + (b - a) * t) for a, b in zip(PAPER_TOP, PAPER_BOTTOM)))
|
||||
|
||||
# Grid on its own layer so a soft radial mask can fade it towards the edges.
|
||||
grid = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||
g = ImageDraw.Draw(grid)
|
||||
for i in range(0, W + 1, 12):
|
||||
g.line([(px(i), 0), (px(i), px(H))], fill=(*PALE, 95 if i % 60 == 0 else 40), width=px(0.6))
|
||||
for j in range(0, H + 1, 12):
|
||||
g.line([(0, px(j)), (px(W), px(j))], fill=(*PALE, 95 if j % 60 == 0 else 40), width=px(0.6))
|
||||
mask = Image.radial_gradient("L").resize(img.size).point(lambda v: max(0, 255 - int(v * 0.7)))
|
||||
grid.putalpha(Image.composite(grid.getchannel("A"), Image.new("L", img.size, 0), mask))
|
||||
img = Image.alpha_composite(img.convert("RGBA"), grid)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# Double border with a tick every 60 pt.
|
||||
d.rectangle([px(10), px(10), px(W - 10), px(H - 10)], outline=(*LINE, 255), width=px(1.8))
|
||||
d.rectangle([px(15), px(15), px(W - 15), px(H - 15)], outline=(*LINE, 110), width=px(0.6))
|
||||
for t in range(60, W, 60):
|
||||
d.line([(px(t), px(10)), (px(t), px(15))], fill=(*LINE, 255), width=px(0.8))
|
||||
d.line([(px(t), px(H - 15)), (px(t), px(H - 10))], fill=(*LINE, 255), width=px(0.8))
|
||||
|
||||
# Title, top left. The left icon's centre line sits to its right and starts below it.
|
||||
text(d, (30, 24), "SolidCraft", sans(20, 600), (*LINE, 255))
|
||||
text(d, (31, 49), "PARAMETRIC 3D CAD", mono(7), (*PALE, 255), tracking=0.9)
|
||||
|
||||
# A circle round each icon, with centre lines that stop short of circles, plates and text.
|
||||
r = ICON / 2 + 6
|
||||
for cx in (AX, BX):
|
||||
d.ellipse([px(cx - r), px(CY - r), px(cx + r), px(CY + r)], outline=(*LINE, 170), width=px(1.1))
|
||||
cl = (*LINE, 120)
|
||||
dashdot(d, (30, CY), (AX - r - 10, CY), cl)
|
||||
dashdot(d, (BX + r + 10, CY), (W - 30, CY), cl)
|
||||
for cx in (AX, BX):
|
||||
dashdot(d, (cx, 66), (cx, CY - r - 6), cl)
|
||||
dashdot(d, (cx, CY + r + 34), (cx, H - 42), cl)
|
||||
|
||||
# Leader from the app to Applications: origin dot, line, arrowhead, a label above.
|
||||
x0, x1, y = AX + r + 8, BX - r - 8, CY + 7
|
||||
d.line([(px(x0 + 4), px(y)), (px(x1 - 10), px(y))], fill=(*LINE, 255), width=px(1.8))
|
||||
d.ellipse([px(x0 - 3), px(y - 3), px(x0 + 3), px(y + 3)], fill=(*LINE, 255))
|
||||
d.polygon([(px(x1), px(y)), (px(x1 - 11), px(y - 4.4)), (px(x1 - 11), px(y + 4.4))], fill=(*LINE, 255))
|
||||
text(d, ((x0 + x1) / 2, CY - 6), "DRAG TO INSTALL", mono(8.5), (*LINE, 255), "ms", tracking=1.4)
|
||||
|
||||
# Title block, bottom right: two cells.
|
||||
bx1, by1 = W - 16, H - 16
|
||||
d.rectangle([px(bx1 - 196), px(by1 - 18), px(bx1), px(by1)], outline=(*LINE, 255), width=px(1))
|
||||
d.line([(px(bx1 - 98), px(by1 - 18)), (px(bx1 - 98), px(by1))], fill=(*LINE, 255), width=px(1))
|
||||
text(d, (bx1 - 147, by1 - 8.5), "SHEET 1/1", mono(7), (*LINE, 255), "mm", tracking=1)
|
||||
text(d, (bx1 - 49, by1 - 8.5), "SCALE 1:1", mono(7), (*LINE, 255), "mm", tracking=1)
|
||||
|
||||
# Name plates where Finder draws the icon names.
|
||||
py = CY + ICON / 2 + PLATE_GAP
|
||||
for cx in (AX, BX):
|
||||
d.rounded_rectangle(
|
||||
[px(cx - PLATE_W / 2), px(py), px(cx + PLATE_W / 2), px(py + PLATE_H)], radius=px(6), fill=(*PLATE, 255)
|
||||
)
|
||||
|
||||
return img.convert("RGB")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--preview", help="also write the 1x and 2x PNGs to this directory")
|
||||
ap.add_argument("--out", default=str(HERE / "background.tiff"))
|
||||
args = ap.parse_args()
|
||||
|
||||
big = draw_sheet()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
one, two = Path(tmp, "bg.png"), Path(tmp, "bg@2x.png")
|
||||
big.resize((W, H), Image.LANCZOS).save(one, dpi=(72, 72))
|
||||
big.resize((W * 2, H * 2), Image.LANCZOS).save(two, dpi=(144, 144))
|
||||
# A multi-representation TIFF: Finder picks the 2x one on Retina screens.
|
||||
raw = Path(tmp, "bg.tiff")
|
||||
subprocess.run(["tiffutil", "-cat", str(one), str(two), "-out", str(raw)], check=True)
|
||||
subprocess.run(["tiffutil", "-lzw", str(raw), "-out", args.out], check=True)
|
||||
if args.preview:
|
||||
out = Path(args.preview)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for p in (one, two):
|
||||
(out / p.name).write_bytes(p.read_bytes())
|
||||
print("wrote", args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""dmgbuild settings for the SolidCraft disk image (packaging/macos/package.sh).
|
||||
|
||||
Passed with -D here=<this folder> -D app=<SolidCraft.app> -D readme=<README.txt>
|
||||
-D icns=<icon.icns>. dmgbuild writes the Finder layout (window, background, icon positions) straight into the image, so the build
|
||||
needs no Finder session and no AppleScript.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
here = defines["here"] # noqa: F821 (this folder; settings files have no __file__)
|
||||
layout = json.load(open(os.path.join(here, "layout.json")))
|
||||
width, height = layout["window"]
|
||||
|
||||
format = "UDZO"
|
||||
filesystem = "HFS+"
|
||||
# `dmgbuild -D` values arrive in `defines`.
|
||||
files = [defines["app"], defines["readme"]] # noqa: F821
|
||||
symlinks = {"Applications": "/Applications"}
|
||||
# The read-me stays on the disk for people who look for it, out of the window's calm layout.
|
||||
hide = ["README.txt"]
|
||||
icon = defines["icns"] # noqa: F821
|
||||
|
||||
background = os.path.join(here, "background.tiff")
|
||||
show_status_bar = False
|
||||
show_tab_view = False
|
||||
show_toolbar = False
|
||||
show_pathbar = False
|
||||
show_sidebar = False
|
||||
default_view = "icon-view"
|
||||
arrange_by = None
|
||||
# The rectangle is the whole window: the title bar (32 pt on macOS 27) sits above the content area,
|
||||
# which must be exactly the size of the background.
|
||||
window_rect = ((260, 260), (width, height + layout["title_bar"]))
|
||||
icon_size = layout["icon_size"]
|
||||
text_size = layout["text_size"]
|
||||
label_pos = "bottom"
|
||||
icon_locations = {
|
||||
os.path.basename(defines["app"]): tuple(layout["app"]), # noqa: F821
|
||||
"Applications": tuple(layout["applications"]),
|
||||
"README.txt": (width // 2, height + 200),
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
# Offline authoring dependencies only; not linked into the Rust application.
|
||||
numpy==2.3.5
|
||||
Pillow==12.3.0
|
||||
Pillow==13.0.0
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"accent": "#e8845e",
|
||||
"accent_soft": "#44281d",
|
||||
"agent_busy": "#6daff7",
|
||||
"agent_ok": "#89cf8e",
|
||||
"app_bar": "#1b1814",
|
||||
"app_bar_hover": "#e4e1dd14",
|
||||
"app_bar_hover_button": "#e4e1dd18",
|
||||
"app_bar_hover_strong": "#e4e1dd22",
|
||||
"app_bar_hover_subtle": "#e4e1dd0c",
|
||||
"app_bar_icon": [
|
||||
"#c3bdb5",
|
||||
"#706354",
|
||||
"#e8845e"
|
||||
],
|
||||
"app_bar_separator": "#5d5246",
|
||||
"app_bar_tab_active": "#29241f",
|
||||
"app_bar_text": "#c3bdb5",
|
||||
"axis_x": "#f85052",
|
||||
"axis_y": "#89cf8e",
|
||||
"axis_z": "#6daff7",
|
||||
"bend_down": "#ab9acd",
|
||||
"bend_up": "#89cf8e",
|
||||
"body": "#8b8378",
|
||||
"body_edge": "#1b1814",
|
||||
"body_selected": "#b8603f",
|
||||
"border": "#403930",
|
||||
"box_crossing": "#89cf8e",
|
||||
"box_window": "#6daff7",
|
||||
"chip": "#e8845e",
|
||||
"construction_geom": "#ceb687",
|
||||
"construction_plane": "#ceb68740",
|
||||
"continuity": [
|
||||
"#89cf8e",
|
||||
"#6daff7",
|
||||
"#e6af2b",
|
||||
"#f38bb7"
|
||||
],
|
||||
"control_border": "#5d5246",
|
||||
"control_fill": "#3c352d",
|
||||
"cube_border": "#706354",
|
||||
"cube_face": "#3c352d",
|
||||
"cube_hover": "#5d5246",
|
||||
"cube_text": "#e4e1dd",
|
||||
"dialog_bg": "#312b25",
|
||||
"dialog_body": "#312b25",
|
||||
"dialog_border": "#4b4238",
|
||||
"dialog_footer": "#29241f",
|
||||
"dialog_label": "#c3bdb5",
|
||||
"dialog_rule": "#403930",
|
||||
"dimension": "#c3bdb5",
|
||||
"dimension_driven": "#9d9285",
|
||||
"error": "#f85052",
|
||||
"field": "#3c352d",
|
||||
"form_accent": "#a6a2fc",
|
||||
"form_dim": "#9d9285",
|
||||
"form_error": "#ff6060",
|
||||
"form_error_fill": "#37292b",
|
||||
"form_fill": "#a6a2fc",
|
||||
"form_ink": "#ece6d9",
|
||||
"form_primary": "#e8845e",
|
||||
"form_soft": "#2f3043",
|
||||
"form_success": "#89cf8e",
|
||||
"form_success_fill": "#2c3631",
|
||||
"form_tab_ink": "#a6a2fc",
|
||||
"gizmo": [
|
||||
"#ff6060",
|
||||
"#98dfa2",
|
||||
"#7abfff"
|
||||
],
|
||||
"glyph_bg": "#312b25",
|
||||
"glyph_edge": "#5d5246",
|
||||
"glyph_text": "#ab9acd",
|
||||
"grid": "#373029",
|
||||
"grid_major": "#3c352d",
|
||||
"heading_bg": "#221e1a",
|
||||
"hover": "#4b4238",
|
||||
"hover_edge": "#1b1814",
|
||||
"hover_edge_halo": "#e4e1dd",
|
||||
"hover_profile": "#e4e1dd22",
|
||||
"hover_profile_edge": "#c3bdb5",
|
||||
"icon": "#c3bdb5",
|
||||
"icon_fill": "#5d5246",
|
||||
"icons.access": [
|
||||
"#89cf8e",
|
||||
"#f85052"
|
||||
],
|
||||
"icons.agent": "#aba3ff",
|
||||
"icons.axes": [
|
||||
"#f85052",
|
||||
"#89cf8e",
|
||||
"#6daff7"
|
||||
],
|
||||
"icons.curvature": [
|
||||
"#6daff7",
|
||||
"#89cf8e",
|
||||
"#f85052"
|
||||
],
|
||||
"icons.draft": [
|
||||
"#89cf8e",
|
||||
"#e6af2b"
|
||||
],
|
||||
"icons.floor": "#4b4238",
|
||||
"icons.fold": "#8a7b68",
|
||||
"icons.folder": "#ceb687",
|
||||
"icons.glint": "#fdfcfca0",
|
||||
"icons.highlight": "#e4e1dd",
|
||||
"icons.interference": "#f85052",
|
||||
"icons.marker_halo": "#1b181496",
|
||||
"icons.paint": [
|
||||
"#f85052",
|
||||
"#6daff7",
|
||||
"#e6af2b"
|
||||
],
|
||||
"icons.plane": "#dcc9a0",
|
||||
"icons.sheet": "#a2bedb",
|
||||
"icons.sheet_2": "#8fb0d2",
|
||||
"icons.sheet_light": "#dcd8d3",
|
||||
"icons.sheet_line": "#6daff7",
|
||||
"icons.sky": "#a2bedb",
|
||||
"icons.stripe": "#1b1814",
|
||||
"icons.warning": "#e6af2b",
|
||||
"interference": "#f85052",
|
||||
"logo": [
|
||||
"#f0a07f",
|
||||
"#e8845e",
|
||||
"#b1573a"
|
||||
],
|
||||
"manipulator": "#7abfff",
|
||||
"mesh_accent": "#cfc152",
|
||||
"mesh_fill": "#cfc152",
|
||||
"mesh_ink": "#cfc152",
|
||||
"mesh_soft": "#363624",
|
||||
"on_accent": "#1d1a17",
|
||||
"origin_plane": "#9d928538",
|
||||
"origin_plane_edge": "#9d9285c8",
|
||||
"origin_plane_hover": "#6daff770",
|
||||
"origin_point": "#c3bdb5",
|
||||
"overlay": "#221e1af0",
|
||||
"panel": "#221e1a",
|
||||
"panel_header": "#312b25",
|
||||
"preview_add": "#4c78a7",
|
||||
"preview_cut": "#f850526e",
|
||||
"preview_new": "#e6af2b",
|
||||
"preview_sketch": "#8eca96",
|
||||
"profile_fill": "#6daff71c",
|
||||
"render_accent": "#f38bb7",
|
||||
"render_fill": "#f38bb7",
|
||||
"render_ink": "#f38bb7",
|
||||
"render_soft": "#3d2c36",
|
||||
"rubber_band": "#7abfff",
|
||||
"section_cap": "#ceb687",
|
||||
"sel_edge": "#e8845e",
|
||||
"sel_edge_rim": "#f5b9a0",
|
||||
"sel_face": "#d4693f",
|
||||
"sel_profile": "#e8845eb0",
|
||||
"sel_profile_edge": "#e8845e",
|
||||
"sel_vertex": "#e8845e",
|
||||
"selected_bg": "#4d3329",
|
||||
"selected_ink": "#ece6d9",
|
||||
"sheet_fill": "#6daff7",
|
||||
"sheet_ink": "#6daff7",
|
||||
"sheet_soft": "#253242",
|
||||
"sketch": "#6daff7",
|
||||
"sketch_accent": "#8eca96",
|
||||
"sketch_centerline": "#c3bdb5",
|
||||
"sketch_construction": "#ceb687",
|
||||
"sketch_fixed": "#e4e1dd",
|
||||
"sketch_ink": "#8eca96",
|
||||
"sketch_locked": "#89cf8e",
|
||||
"sketch_lost": "#f85052",
|
||||
"sketch_point": "#e4e1dd",
|
||||
"sketch_point_free": "#6daff7",
|
||||
"sketch_projected": "#998cb3",
|
||||
"sketch_soft": "#2b3730",
|
||||
"subheading_bg": "#29241f",
|
||||
"surface_accent": "#ceb687",
|
||||
"surface_fill": "#ceb687",
|
||||
"surface_ink": "#ceb687",
|
||||
"surface_soft": "#36342e",
|
||||
"tab_on_fill_dark": "#1b1814",
|
||||
"text": "#ece6d9",
|
||||
"text_dim": "#9d9484",
|
||||
"timeline": "#221e1a",
|
||||
"timeline_item": "#312b25",
|
||||
"timeline_item_error": "#552f31",
|
||||
"timeline_marker": "#e8845e",
|
||||
"toolbar": "#221e1a",
|
||||
"triad": [
|
||||
"#f85052",
|
||||
"#89cf8e",
|
||||
"#6daff7"
|
||||
],
|
||||
"viewport_bottom": "#29241f",
|
||||
"viewport_top": "#312b25",
|
||||
"warning": "#e6af2b"
|
||||
},
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Clay Dark",
|
||||
"radii": {
|
||||
"radius_sm": 6,
|
||||
"radius_md": 9,
|
||||
"radius_lg": 13,
|
||||
"radius_xl": 18,
|
||||
"control_radius": 9,
|
||||
"sketch_mode_radius": 10
|
||||
},
|
||||
"borders": {
|
||||
"primary": {
|
||||
"width": 1,
|
||||
"color": "#4a433b"
|
||||
},
|
||||
"secondary": {
|
||||
"width": 1,
|
||||
"color": "#3b362f"
|
||||
},
|
||||
"tertiary": {
|
||||
"width": 1,
|
||||
"color": "#332f2a"
|
||||
},
|
||||
"container": {
|
||||
"width": 1,
|
||||
"color": "#443d35"
|
||||
},
|
||||
"control": {
|
||||
"width": 1,
|
||||
"color": "#4a433b"
|
||||
}
|
||||
},
|
||||
"icon_palette": {
|
||||
"ink": "#262320",
|
||||
"line": "#ece6d9",
|
||||
"passive-1": "#c5bdae",
|
||||
"passive-2": "#9a9283",
|
||||
"passive-3": "#6f6859",
|
||||
"guide": "#a6a093",
|
||||
"solid": [
|
||||
"#a0d7fb",
|
||||
"#65bdf3",
|
||||
"#4aa3d8"
|
||||
],
|
||||
"surface": [
|
||||
"#fcc28e",
|
||||
"#dea572",
|
||||
"#c38c59"
|
||||
],
|
||||
"mesh": [
|
||||
"#e8cd7d",
|
||||
"#cab060",
|
||||
"#b19747"
|
||||
],
|
||||
"form": [
|
||||
"#d5c4fd",
|
||||
"#b9a7df",
|
||||
"#a08fc4"
|
||||
],
|
||||
"sketch": [
|
||||
"#b0dbc0",
|
||||
"#94bea4",
|
||||
"#7ba58b"
|
||||
],
|
||||
"constraint": [
|
||||
"#febbb9",
|
||||
"#fb8e8d",
|
||||
"#e57071"
|
||||
],
|
||||
"construction": [
|
||||
"#fcc28e",
|
||||
"#dea572",
|
||||
"#c38c59"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"base": "light",
|
||||
"colors": {
|
||||
"accent": "#bf5634",
|
||||
"accent_soft": "#f0d8ca",
|
||||
"agent_busy": "#015ba6",
|
||||
"agent_ok": "#257231",
|
||||
"app_bar": "#e6dfd3",
|
||||
"app_bar_hover": "#14110d14",
|
||||
"app_bar_hover_button": "#14110d18",
|
||||
"app_bar_hover_strong": "#14110d22",
|
||||
"app_bar_hover_subtle": "#14110d0c",
|
||||
"app_bar_icon": [
|
||||
"#423e36",
|
||||
"#b7b1a7",
|
||||
"#bf5634"
|
||||
],
|
||||
"app_bar_separator": "#b7b1a7",
|
||||
"app_bar_tab_active": "#faf7f1",
|
||||
"app_bar_text": "#423e36",
|
||||
"axis_x": "#c71529",
|
||||
"axis_y": "#257231",
|
||||
"axis_z": "#015ba6",
|
||||
"bend_down": "#705e91",
|
||||
"bend_up": "#257231",
|
||||
"body": "#c8c0b3",
|
||||
"body_edge": "#423e36",
|
||||
"body_selected": "#e2967a",
|
||||
"border": "#e0d8c9",
|
||||
"box_crossing": "#257231",
|
||||
"box_window": "#015ba6",
|
||||
"chip": "#bf5634",
|
||||
"construction_geom": "#7d6535",
|
||||
"construction_plane": "#7d653540",
|
||||
"continuity": [
|
||||
"#257231",
|
||||
"#015ba6",
|
||||
"#c08a1a",
|
||||
"#b14578"
|
||||
],
|
||||
"control_border": "#dcd3c2",
|
||||
"control_fill": "#fdfaf3",
|
||||
"cube_border": "#b7b1a7",
|
||||
"cube_face": "#fdfaf3",
|
||||
"cube_hover": "#dcd3c2",
|
||||
"cube_text": "#14110d",
|
||||
"dialog_bg": "#faf7f1",
|
||||
"dialog_body": "#faf7f1",
|
||||
"dialog_border": "#dcd3c2",
|
||||
"dialog_footer": "#f4eee3",
|
||||
"dialog_label": "#423e36",
|
||||
"dialog_rule": "#e0d8c9",
|
||||
"dimension": "#423e36",
|
||||
"dimension_driven": "#726a5d",
|
||||
"error": "#c71529",
|
||||
"field": "#fdfaf3",
|
||||
"form_accent": "#5844bc",
|
||||
"form_dim": "#726a5d",
|
||||
"form_error": "#c71529",
|
||||
"form_error_fill": "#f4e0de",
|
||||
"form_fill": "#5844bc",
|
||||
"form_ink": "#2a251f",
|
||||
"form_primary": "#bf5634",
|
||||
"form_soft": "#e4e0e5",
|
||||
"form_success": "#257231",
|
||||
"form_success_fill": "#e4e9df",
|
||||
"form_tab_ink": "#5844bc",
|
||||
"gizmo": [
|
||||
"#b00018",
|
||||
"#096125",
|
||||
"#004c9b"
|
||||
],
|
||||
"glyph_bg": "#fdfaf3",
|
||||
"glyph_edge": "#dcd3c2",
|
||||
"glyph_text": "#604b83",
|
||||
"grid": "#f3ecdf",
|
||||
"grid_major": "#e6dfd3",
|
||||
"heading_bg": "#f2e9db",
|
||||
"hover": "#e6dfd2",
|
||||
"hover_edge": "#14110d",
|
||||
"hover_edge_halo": "#fefdfb",
|
||||
"hover_profile": "#14110d22",
|
||||
"hover_profile_edge": "#423e36",
|
||||
"icon": "#423e36",
|
||||
"icon_fill": "#dcd3c2",
|
||||
"icons.access": [
|
||||
"#257231",
|
||||
"#c71529"
|
||||
],
|
||||
"icons.agent": "#5844bc",
|
||||
"icons.axes": [
|
||||
"#c71529",
|
||||
"#257231",
|
||||
"#015ba6"
|
||||
],
|
||||
"icons.curvature": [
|
||||
"#015ba6",
|
||||
"#257231",
|
||||
"#c71529"
|
||||
],
|
||||
"icons.draft": [
|
||||
"#257231",
|
||||
"#e6af2b"
|
||||
],
|
||||
"icons.floor": "#726a5d",
|
||||
"icons.fold": "#938b7c",
|
||||
"icons.folder": "#c9a96a",
|
||||
"icons.glint": "#ffffffa0",
|
||||
"icons.highlight": "#fefdfb",
|
||||
"icons.interference": "#c71529",
|
||||
"icons.marker_halo": "#14110d96",
|
||||
"icons.paint": [
|
||||
"#c71529",
|
||||
"#015ba6",
|
||||
"#e6af2b"
|
||||
],
|
||||
"icons.plane": "#e3cf9f",
|
||||
"icons.sheet": "#b9cde2",
|
||||
"icons.sheet_2": "#a5bfd9",
|
||||
"icons.sheet_light": "#f1e9da",
|
||||
"icons.sheet_line": "#015ba6",
|
||||
"icons.sky": "#b9cde2",
|
||||
"icons.stripe": "#14110d",
|
||||
"icons.warning": "#e6af2b",
|
||||
"interference": "#c71529",
|
||||
"logo": [
|
||||
"#d0704b",
|
||||
"#bf5634",
|
||||
"#92411f"
|
||||
],
|
||||
"manipulator": "#004c9b",
|
||||
"mesh_accent": "#7e6900",
|
||||
"mesh_fill": "#7e6900",
|
||||
"mesh_ink": "#6b5903",
|
||||
"mesh_soft": "#e7e3d8",
|
||||
"on_accent": "#fffefb",
|
||||
"origin_plane": "#726a5d38",
|
||||
"origin_plane_edge": "#726a5dc8",
|
||||
"origin_plane_hover": "#015ba670",
|
||||
"origin_point": "#423e36",
|
||||
"overlay": "#fefdfbeb",
|
||||
"panel": "#f4eee3",
|
||||
"panel_header": "#f2e9db",
|
||||
"preview_add": "#4f8fd6",
|
||||
"preview_cut": "#c715296e",
|
||||
"preview_new": "#e6af2b",
|
||||
"preview_sketch": "#2d713b",
|
||||
"profile_fill": "#015ba614",
|
||||
"render_accent": "#ac1f6a",
|
||||
"render_fill": "#ac1f6a",
|
||||
"render_ink": "#ac1f6a",
|
||||
"render_soft": "#eadedf",
|
||||
"rubber_band": "#004c9b",
|
||||
"section_cap": "#7d6535",
|
||||
"sel_edge": "#bf5634",
|
||||
"sel_edge_rim": "#e8a98b",
|
||||
"sel_face": "#d4714c",
|
||||
"sel_profile": "#bf5634a0",
|
||||
"sel_profile_edge": "#bf5634",
|
||||
"sel_vertex": "#bf5634",
|
||||
"selected_bg": "#f3ddd0",
|
||||
"selected_ink": "#2a251f",
|
||||
"sheet_fill": "#015ba6",
|
||||
"sheet_ink": "#015ba6",
|
||||
"sheet_soft": "#dee2e3",
|
||||
"sketch": "#015ba6",
|
||||
"sketch_accent": "#2d713b",
|
||||
"sketch_centerline": "#423e36",
|
||||
"sketch_construction": "#7d6535",
|
||||
"sketch_fixed": "#14110d",
|
||||
"sketch_ink": "#2d713b",
|
||||
"sketch_locked": "#257231",
|
||||
"sketch_lost": "#c71529",
|
||||
"sketch_point": "#14110d",
|
||||
"sketch_point_free": "#015ba6",
|
||||
"sketch_projected": "#705e91",
|
||||
"sketch_soft": "#e1e3dc",
|
||||
"subheading_bg": "#f4eee3",
|
||||
"surface_accent": "#7d6535",
|
||||
"surface_fill": "#7d6535",
|
||||
"surface_ink": "#6a562e",
|
||||
"surface_soft": "#e7e3db",
|
||||
"tab_on_fill_dark": "#14110d",
|
||||
"text": "#2a251f",
|
||||
"text_dim": "#675e50",
|
||||
"timeline": "#f4eee3",
|
||||
"timeline_item": "#fdfaf3",
|
||||
"timeline_item_error": "#f2c5c8",
|
||||
"timeline_marker": "#bf5634",
|
||||
"toolbar": "#f4eee3",
|
||||
"triad": [
|
||||
"#c71529",
|
||||
"#257231",
|
||||
"#015ba6"
|
||||
],
|
||||
"viewport_bottom": "#ebe4d4",
|
||||
"viewport_top": "#f7f2e8",
|
||||
"warning": "#956000"
|
||||
},
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Clay",
|
||||
"radii": {
|
||||
"radius_sm": 6,
|
||||
"radius_md": 9,
|
||||
"radius_lg": 13,
|
||||
"radius_xl": 18,
|
||||
"control_radius": 9,
|
||||
"sketch_mode_radius": 10
|
||||
},
|
||||
"borders": {
|
||||
"primary": {
|
||||
"width": 1,
|
||||
"color": "#cfc4af"
|
||||
},
|
||||
"secondary": {
|
||||
"width": 1,
|
||||
"color": "#ddd3c0"
|
||||
},
|
||||
"tertiary": {
|
||||
"width": 1,
|
||||
"color": "#e3dbca"
|
||||
},
|
||||
"container": {
|
||||
"width": 1,
|
||||
"color": "#d6cbb7"
|
||||
},
|
||||
"control": {
|
||||
"width": 1,
|
||||
"color": "#d0c5b0"
|
||||
}
|
||||
},
|
||||
"icon_palette": {
|
||||
"ink": "#2a251f",
|
||||
"line": "#4b4338",
|
||||
"passive-1": "#e6e0d4",
|
||||
"passive-2": "#b9b0a0",
|
||||
"passive-3": "#8a8272",
|
||||
"guide": "#7a7263",
|
||||
"solid": [
|
||||
"#dbe9fe",
|
||||
"#81b3fa",
|
||||
"#1875eb"
|
||||
],
|
||||
"surface": [
|
||||
"#fde3c4",
|
||||
"#daa662",
|
||||
"#a06f28"
|
||||
],
|
||||
"mesh": [
|
||||
"#fee7a2",
|
||||
"#d1ad37",
|
||||
"#937717"
|
||||
],
|
||||
"form": [
|
||||
"#ede2ff",
|
||||
"#c39afc",
|
||||
"#8b62bf"
|
||||
],
|
||||
"sketch": [
|
||||
"#b1fdc5",
|
||||
"#72c78b",
|
||||
"#388e56"
|
||||
],
|
||||
"constraint": [
|
||||
"#fee0dd",
|
||||
"#f98e87",
|
||||
"#d73339"
|
||||
],
|
||||
"construction": [
|
||||
"#fde3c4",
|
||||
"#daa662",
|
||||
"#a06f28"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -150,10 +150,13 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
map_plane_ref(m, base);
|
||||
map_geo_ref(m, point);
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => {
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, line, .. } => {
|
||||
map_plane_ref(m, base);
|
||||
*axis_origin = apply_point(m, *axis_origin);
|
||||
*axis_dir = apply_vector(m, *axis_dir);
|
||||
if let Some(g) = line {
|
||||
map_geo_ref(m, g);
|
||||
}
|
||||
}
|
||||
// Named origin planes and construction planes are the component's own.
|
||||
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
|
||||
|
||||
@@ -114,11 +114,17 @@ fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<Str
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
|
||||
PlaneRef::ThroughPoint { base, point } => {
|
||||
remap_plane(base, ids, bodies, depth + 1)?;
|
||||
remap_geo(point, ids, bodies, depth + 1)?;
|
||||
}
|
||||
PlaneRef::AtAngle { base, line, .. } => {
|
||||
remap_plane(base, ids, bodies, depth + 1)?;
|
||||
if let Some(g) = line {
|
||||
remap_geo(g, ids, bodies, depth + 1)?;
|
||||
}
|
||||
}
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
if let Some(name) = name {
|
||||
*name = remap_topology(name, ids, bodies);
|
||||
@@ -209,6 +215,9 @@ impl crate::Feature {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => checked_geo(geo, 0)?,
|
||||
_ => {}
|
||||
}
|
||||
// Feature names are remapped once here; the geometry pass below handles only
|
||||
@@ -269,6 +278,9 @@ impl crate::Feature {
|
||||
remap_geo(r, ids, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -289,7 +301,13 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::AtAngle { base, line, .. } => {
|
||||
checked_plane(base, depth + 1)?;
|
||||
if let Some(g) = line {
|
||||
checked_geo(g, depth + 1)?;
|
||||
}
|
||||
}
|
||||
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Identification colours, independent of materials and appearances.
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// Automatic colours select this palette in creation order; cycling uses the same order.
|
||||
/// Colors are generated by picking colors on a color wheel, with 0.9 brightness and 0.8 saturation.
|
||||
/// First pass is 3 colors, 120 huedegrees from each other. Second pass starts with 60 degrees from
|
||||
/// the first one. Then third and fourth pass fill 60 and 30 degrees gaps between already generated
|
||||
/// colors of the palette.
|
||||
pub const PALETTE: [[u8; 3]; 24] = [
|
||||
[230, 161, 92],
|
||||
[92, 230, 161],
|
||||
[161, 92, 230],
|
||||
[161, 230, 92],
|
||||
[92, 161, 230],
|
||||
[230, 92, 161],
|
||||
[230, 230, 92],
|
||||
[92, 230, 92],
|
||||
[92, 230, 230],
|
||||
[92, 92, 230],
|
||||
[230, 92, 230],
|
||||
[230, 92, 92],
|
||||
[230, 195, 92],
|
||||
[195, 230, 92],
|
||||
[126, 230, 92],
|
||||
[92, 230, 126],
|
||||
[92, 230, 195],
|
||||
[92, 195, 230],
|
||||
[92, 126, 230],
|
||||
[126, 92, 230],
|
||||
[195, 92, 230],
|
||||
[230, 92, 195],
|
||||
[230, 92, 126],
|
||||
[230, 126, 92],
|
||||
];
|
||||
|
||||
/// Select a colour in creation order, wrapping at the end of the palette.
|
||||
pub fn automatic_color(index: u64) -> [u8; 3] {
|
||||
PALETTE.get((index % PALETTE.len() as u64) as usize).copied().unwrap_or([230, 161, 92])
|
||||
}
|
||||
|
||||
pub fn next_color(color: [u8; 3]) -> [u8; 3] {
|
||||
let index = PALETTE.iter().position(|c| *c == color).map_or(0, |i| (i + 1) % PALETTE.len());
|
||||
PALETTE.get(index).copied().unwrap_or([230, 161, 92])
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn component_identification_color(&self, id: u64) -> [u8; 3] {
|
||||
self.components.iter().enumerate().find(|(_, c)| c.id == id).map_or_else(
|
||||
|| automatic_color(0),
|
||||
|(position, c)| c.identification_color.unwrap_or_else(|| automatic_color(c.identification_color_index.unwrap_or(position as u64))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fill older records in stored order and repair stale counters without changing saved indices.
|
||||
pub(crate) fn normalise_component_color_indices(&mut self) -> crate::Result<()> {
|
||||
let mut next = self.next_component_color_index;
|
||||
for component in &self.components {
|
||||
if let Some(index) = component.identification_color_index {
|
||||
next = next.max(index.checked_add(1).ok_or_else(|| crate::DocError::Invalid("component colour sequence exhausted".into()))?);
|
||||
}
|
||||
}
|
||||
for component in &mut self.components {
|
||||
if component.identification_color_index.is_none() {
|
||||
let following = next.checked_add(1).ok_or_else(|| crate::DocError::Invalid("component colour sequence exhausted".into()))?;
|
||||
component.identification_color_index = Some(next);
|
||||
next = following;
|
||||
}
|
||||
}
|
||||
self.next_component_color_index = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_components_default_to_stable_colors_and_overrides_round_trip() {
|
||||
let mut doc = Document::default();
|
||||
let id = doc.add_component(Some("Part"), 0).unwrap();
|
||||
let automatic = doc.component_identification_color(id);
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["components"][0].as_object_mut().unwrap().remove("identification_color");
|
||||
if let Some(pending) = legacy.get_mut("format_pending").and_then(|v| v.as_array_mut()) {
|
||||
pending.retain(|name| name != "component-identification-colors");
|
||||
}
|
||||
let restored = Document::from_json(&legacy.to_string()).unwrap();
|
||||
assert_eq!(restored.component_identification_color(id), automatic);
|
||||
assert_eq!(restored.components[0].identification_color, None);
|
||||
let override_color = next_color(automatic);
|
||||
assert_ne!(override_color, automatic);
|
||||
doc.components[0].identification_color = Some(override_color);
|
||||
doc.components[0].name = "Renamed".into();
|
||||
let saved = Document::from_json(&doc.to_json()).unwrap();
|
||||
assert_eq!(saved.component_identification_color(id), override_color);
|
||||
assert_eq!(saved.components[0].identification_color, Some(override_color));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_order_wraps_and_survives_deletion_reordering_and_reopening() {
|
||||
let mut doc = Document::default();
|
||||
let mut ids = Vec::new();
|
||||
for index in 0..25 {
|
||||
// IDs also belong to occurrences, so their numeric values are not colour indices.
|
||||
let id = doc.add_component(None, 0).unwrap();
|
||||
assert_eq!(doc.component_identification_color(id), PALETTE[index % PALETTE.len()]);
|
||||
ids.push(id);
|
||||
}
|
||||
let retained = ids[1];
|
||||
doc.components.retain(|c| c.id != ids[0]);
|
||||
doc.occurrences.retain(|o| o.component != ids[0]);
|
||||
doc.components.reverse();
|
||||
let mut restored = Document::from_json(&doc.to_json()).unwrap();
|
||||
assert_eq!(restored.component_identification_color(retained), PALETTE[1]);
|
||||
let next = restored.add_component(None, 0).unwrap();
|
||||
assert_eq!(restored.component_identification_color(next), PALETTE[1]);
|
||||
assert_eq!(restored.next_component_color_index, 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_order_and_overrides_are_preserved_and_stale_counters_repaired() {
|
||||
let mut doc = Document::default();
|
||||
let first = doc.add_component(None, 0).unwrap();
|
||||
let second = doc.add_component(None, first).unwrap();
|
||||
doc.components[1].identification_color = Some([1, 2, 3]);
|
||||
let mut json: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json.as_object_mut().unwrap().remove("next_component_color_index");
|
||||
for component in json["components"].as_array_mut().unwrap() {
|
||||
component.as_object_mut().unwrap().remove("identification_color_index");
|
||||
}
|
||||
if let Some(pending) = json.get_mut("format_pending").and_then(|v| v.as_array_mut()) {
|
||||
pending.retain(|name| name != "sequential-component-colors");
|
||||
}
|
||||
let mut restored = Document::from_json(&json.to_string()).unwrap();
|
||||
assert_eq!(restored.components[0].identification_color_index, Some(0));
|
||||
assert_eq!(restored.components[1].identification_color_index, Some(1));
|
||||
assert_eq!(restored.component_identification_color(first), PALETTE[0]);
|
||||
assert_eq!(restored.component_identification_color(second), [1, 2, 3]);
|
||||
let third = restored.add_component(None, 0).unwrap();
|
||||
assert_eq!(restored.component_identification_color(third), PALETTE[2]);
|
||||
restored.next_component_color_index = 0;
|
||||
let mut repaired = Document::from_json(&restored.to_json()).unwrap();
|
||||
let fourth = repaired.add_component(None, 0).unwrap();
|
||||
assert_eq!(repaired.component_identification_color(fourth), PALETTE[3]);
|
||||
let mut hostile: serde_json::Value = serde_json::from_str(&repaired.to_json()).unwrap();
|
||||
hostile["components"][0]["identification_color_index"] = u64::MAX.into();
|
||||
assert!(Document::from_json(&hostile.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycling_visits_every_palette_color_and_wraps() {
|
||||
let original = automatic_color(123);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut color = original;
|
||||
for _ in 0..PALETTE.len() {
|
||||
assert!(seen.insert(color));
|
||||
color = next_color(color);
|
||||
}
|
||||
assert_eq!(color, original);
|
||||
assert_eq!(automatic_color(0), PALETTE[0]);
|
||||
assert_eq!(automatic_color(1), PALETTE[1]);
|
||||
assert_eq!(automatic_color(24), PALETTE[0]);
|
||||
}
|
||||
}
|
||||
+83
-19
@@ -162,6 +162,11 @@ fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&
|
||||
|
||||
/// The edge picked, as a polyline.
|
||||
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
|
||||
edge_pick(st, at, name).map(|(_, _, pts)| pts)
|
||||
}
|
||||
|
||||
/// The edge picked: its body, its index there (as `Body::edges` lists it) and its polyline.
|
||||
fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec<Vec3>)> {
|
||||
let mut at = at;
|
||||
if let Some(n) = name {
|
||||
for b in bodies(st) {
|
||||
@@ -171,23 +176,33 @@ fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<V
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut best: Option<(f64, Vec<Vec3>)> = None;
|
||||
let mut best: Option<(f64, &ModelBody, usize, Vec<Vec3>)> = None;
|
||||
for b in bodies(st) {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
let Ok(edges) = b.body.edges(tol) else { continue };
|
||||
for e in edges {
|
||||
for (i, e) in edges.into_iter().enumerate() {
|
||||
let d = e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, e.points));
|
||||
if best.as_ref().is_none_or(|(bd, ..)| d < *bd) {
|
||||
best = Some((d, b, i, e.points));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((d, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok(pts),
|
||||
Some((d, b, i, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok((b, i, pts)),
|
||||
_ => Err(bad(format!("no edge at {at:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
|
||||
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
|
||||
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let exact = kernel::edge_circle(&b.body, index).filter(|c| {
|
||||
let tol = (b.body.size() * 1e-6).max(1e-9) * 10.0;
|
||||
pts.iter().all(|p| (p.dist(c.center) - c.radius).abs() <= tol && (*p - c.center).dot(*c.axis).abs() <= tol)
|
||||
});
|
||||
exact.map(|c| (c.center, *c.axis)).or_else(|| as_circle(pts).map(|(c, n, _)| (c, n)))
|
||||
}
|
||||
|
||||
/// The most edge names a vertex reference keeps.
|
||||
const MAX_VERTEX_EDGES: usize = 16;
|
||||
|
||||
@@ -306,13 +321,52 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge or face of the model as a line: along a straight edge, through the centre of a
|
||||
/// circular edge along its normal, or the axis of a cylinder, cone or torus face.
|
||||
fn model_line(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
match g {
|
||||
GeoRef::Edge { at, name } => {
|
||||
let (b, i, pts) = edge_pick(st, *at, name)?;
|
||||
as_line(&pts).or_else(|| edge_circle(b, i, &pts)).ok_or_else(|| bad("the edge is neither straight nor circular"))
|
||||
}
|
||||
GeoRef::Face { at, name } => {
|
||||
let (b, fi, _) = face_of(st, *at, name)?;
|
||||
match kernel::face_surface(&b.body, fi) {
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
|
||||
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
|
||||
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
|
||||
_ => Err(bad("the face is not a cylinder, cone or torus")),
|
||||
}
|
||||
}
|
||||
_ => Err(bad("pick a straight or circular edge, or a cylinder, cone or torus face")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis a feature turns about when an edge or face of the model is picked for it (a revolve,
|
||||
/// a circular pattern, a plane at an angle), as [`model_line`] finds it. The direction is
|
||||
/// oriented the same way however the edge or surface runs (its largest component positive), so
|
||||
/// the feature keeps its turn when an edit rebuilds the edge reversed.
|
||||
pub fn model_axis(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
let (o, d) = model_line(st, g)?;
|
||||
let d = d.normalized().filter(|_| o.is_finite()).ok_or_else(|| bad("the axis has no direction"))?;
|
||||
Ok((o, canonical_dir(d)))
|
||||
}
|
||||
|
||||
/// `d` or `-d`, whichever has its largest component (the first of equal ones) positive: how
|
||||
/// [`model_axis`] orients an axis, for a preview of one to point the same way.
|
||||
pub fn canonical_dir(d: Vec3) -> Vec3 {
|
||||
let c = [d.x, d.y, d.z];
|
||||
let big = c.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
|
||||
match c.iter().find(|v| v.abs() >= big - 1e-9) {
|
||||
Some(v) if *v < 0.0 => -d,
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference as a line (origin, unit direction).
|
||||
pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
match g {
|
||||
GeoRef::Edge { at, name } => {
|
||||
let pts = edge_points(ctx.st, *at, name)?;
|
||||
as_line(&pts).or_else(|| as_circle(&pts).map(|(c, n, _)| (c, n))).ok_or_else(|| bad("the edge is neither straight nor circular"))
|
||||
}
|
||||
GeoRef::Edge { .. } | GeoRef::Face { .. } => model_line(ctx.st, g),
|
||||
GeoRef::SketchCurve { sketch, curve } => {
|
||||
let pts = sketch_curve_points(ctx, *sketch, curve)?;
|
||||
as_line(&pts).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a line")))
|
||||
@@ -330,15 +384,6 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
ConstructGeom::Axis { origin, dir } => Ok((*origin, *dir)),
|
||||
_ => Err(bad(format!("`{name}` is not an axis"))),
|
||||
},
|
||||
GeoRef::Face { at, name } => {
|
||||
let (b, fi, _) = face_of(ctx.st, *at, name)?;
|
||||
match kernel::face_surface(&b.body, fi) {
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
|
||||
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
|
||||
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
|
||||
_ => Err(bad("the face is not a cylinder, cone or torus")),
|
||||
}
|
||||
}
|
||||
_ => Err(bad("pick an edge, line or axis")),
|
||||
}
|
||||
}
|
||||
@@ -602,6 +647,20 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
}
|
||||
|
||||
impl GeoRef {
|
||||
/// Where a picked edge or face is now, found the way evaluation finds it (by its persistent
|
||||
/// name, else at the picked point): a point on it. `None` for other references and for an
|
||||
/// edge or face that is gone.
|
||||
pub fn picked_point(&self, st: &ModelState) -> Option<Vec3> {
|
||||
match self {
|
||||
GeoRef::Edge { at, name } => {
|
||||
let on = name.as_deref().and_then(|n| bodies(st).find_map(|b| crate::naming::find_edge(b, n, *at).ok())).map_or(*at, |(q, _)| q);
|
||||
edge_pick(st, on, &None).ok().map(|_| on)
|
||||
}
|
||||
GeoRef::Face { at, name } => face_of(st, *at, name).ok().map(|(_, _, p)| p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the reference's world points (component frames).
|
||||
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
|
||||
match self {
|
||||
@@ -710,7 +769,12 @@ impl PlaneRef {
|
||||
refs.push(point.as_mut());
|
||||
refs
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
|
||||
PlaneRef::AtAngle { base, line, .. } => {
|
||||
let mut v = base.refs_mut();
|
||||
v.extend(line.as_deref_mut());
|
||||
v
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
|
||||
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{DocError, Result, expr};
|
||||
|
||||
/// Units per millimetre and the unit's name. Unknown names use millimetres.
|
||||
pub fn unit_scale(units: &str) -> (f64, &'static str) {
|
||||
match units {
|
||||
@@ -39,6 +43,142 @@ pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
|
||||
format!("{} {u}", length_number(mm, units, decimals))
|
||||
}
|
||||
|
||||
/// The decimal separator of a number, independent of UI language.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DecimalMarker {
|
||||
Point,
|
||||
Comma,
|
||||
}
|
||||
|
||||
/// How an exact tie in the prepared decimal digits is rounded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Ties {
|
||||
HalfEven,
|
||||
HalfAwayFromZero,
|
||||
}
|
||||
|
||||
/// Number presentation for non-UI consumers. This does not change the legacy length helpers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct NumberFormat {
|
||||
pub marker: DecimalMarker,
|
||||
pub groups: bool,
|
||||
pub leading_zero: bool,
|
||||
pub trailing_zeros: bool,
|
||||
pub ties: Ties,
|
||||
}
|
||||
|
||||
impl Default for NumberFormat {
|
||||
fn default() -> Self {
|
||||
Self { marker: DecimalMarker::Point, groups: false, leading_zero: true, trailing_zeros: true, ties: Ties::HalfEven }
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatted text without a unit suffix, and the rounded number in the display unit.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Shown {
|
||||
pub text: String,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Format an internal value (millimetres or radians) in a strictly validated display unit.
|
||||
pub fn format_value(value: f64, kind: expr::Kind, unit: &str, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
let (unit_kind, scale) = expr::unit_info(unit).ok_or_else(|| DocError::Invalid("unknown number display unit".into()))?;
|
||||
if unit_kind != kind {
|
||||
return Err(DocError::Invalid("number display unit has a different kind".into()));
|
||||
}
|
||||
format_display(value / scale, scale, decimals, f)
|
||||
}
|
||||
|
||||
/// Format a unit-less number with the same validation and decimal rounding policy.
|
||||
pub fn format_number(x: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
format_display(x, 1.0, decimals, f)
|
||||
}
|
||||
|
||||
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
// The unit table supplies a positive finite scale. Its decimal step must be at most
|
||||
// 1e-9 internal units; the precision floor can make the effective tie window narrower.
|
||||
let unit_resolution = (scale.log10() + 9.0).ceil().max(0.0) as usize;
|
||||
let resolution = unit_resolution.max(usize::from(decimals) + 3);
|
||||
let (rounded, value) = round_decimal(x, decimals, resolution, f.ties)?;
|
||||
let (integer, fraction) = rounded.split_once('.').unwrap_or((&rounded, ""));
|
||||
let fraction = if f.trailing_zeros { fraction } else { fraction.trim_end_matches('0') };
|
||||
let integer = if !f.leading_zero && integer == "0" && !fraction.is_empty() { "" } else { integer };
|
||||
let mut text = String::new();
|
||||
if value.is_sign_negative() {
|
||||
text.push('−');
|
||||
}
|
||||
grouped_digits(&mut text, integer, true, f.groups);
|
||||
if !fraction.is_empty() {
|
||||
text.push(match f.marker {
|
||||
DecimalMarker::Point => '.',
|
||||
DecimalMarker::Comma => ',',
|
||||
});
|
||||
grouped_digits(&mut text, fraction, false, f.groups);
|
||||
}
|
||||
Ok(Shown { text, value })
|
||||
}
|
||||
|
||||
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
|
||||
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
|
||||
if decimals > 8 {
|
||||
return Err(DocError::Invalid("number precision must be at most 8 decimal places".into()));
|
||||
}
|
||||
if !x.is_finite() || x.abs() > 1e12 {
|
||||
return Err(DocError::Invalid("display value must be finite with magnitude at most 1e12".into()));
|
||||
}
|
||||
let prepared = format!("{:.*}", resolution, x.abs());
|
||||
let (integer, fraction) = prepared.split_once('.').unwrap_or((&prepared, ""));
|
||||
let places = usize::from(decimals);
|
||||
let mut digits: Vec<u8> = integer.bytes().chain(fraction.bytes().take(places)).collect();
|
||||
let discarded = fraction.as_bytes().get(places..).unwrap_or_default();
|
||||
let up = match discarded.first().copied() {
|
||||
Some(b'6'..=b'9') => true,
|
||||
Some(b'5') => {
|
||||
discarded.get(1..).unwrap_or_default().iter().any(|d| *d != b'0')
|
||||
|| match ties {
|
||||
Ties::HalfEven => digits.last().is_some_and(|d| !(d - b'0').is_multiple_of(2)),
|
||||
Ties::HalfAwayFromZero => true,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if up {
|
||||
let mut carry = true;
|
||||
for d in digits.iter_mut().rev() {
|
||||
if *d == b'9' {
|
||||
*d = b'0';
|
||||
} else {
|
||||
*d += 1;
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
digits.insert(0, b'1');
|
||||
}
|
||||
}
|
||||
if places > 0 {
|
||||
let point = digits.len().checked_sub(places).ok_or_else(|| DocError::Invalid("invalid prepared number".into()))?;
|
||||
digits.insert(point, b'.');
|
||||
}
|
||||
let rounded = String::from_utf8(digits).map_err(|_| DocError::Invalid("invalid prepared digits".into()))?;
|
||||
let magnitude = rounded.parse::<f64>().map_err(|_| DocError::Invalid("invalid rounded number".into()))?;
|
||||
let negative = x.is_sign_negative() && magnitude != 0.0;
|
||||
Ok((rounded, if negative { -magnitude } else { magnitude }))
|
||||
}
|
||||
|
||||
fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool) {
|
||||
for (i, digit) in digits.chars().enumerate() {
|
||||
if groups && digits.len() >= 5 && i > 0 && if integer { (digits.len() - i).is_multiple_of(3) } else { i.is_multiple_of(3) } {
|
||||
text.push('\u{202f}');
|
||||
}
|
||||
text.push(digit);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_length, length_number, length_precision, unit_scale};
|
||||
@@ -121,3 +261,359 @@ mod tests {
|
||||
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod number_tests {
|
||||
use super::*;
|
||||
use expr::Kind::{Angle, Length, Unitless};
|
||||
|
||||
fn pair(x: f64, dp: u8, even: &str, away: &str) {
|
||||
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
|
||||
let shown = format_number(x, dp, &NumberFormat { ties, ..NumberFormat::default() }).unwrap();
|
||||
assert_eq!(shown.text, expected, "x={x:?}, dp={dp}, ties={ties:?}");
|
||||
let parsed: f64 = expected.replace('−', "-").parse().unwrap();
|
||||
assert_eq!(shown.value, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_exact_binary_ties() {
|
||||
for (x, dp, even, away) in [
|
||||
(0.125, 2, "0.12", "0.13"),
|
||||
(0.375, 2, "0.38", "0.38"),
|
||||
(12.5, 0, "12", "13"),
|
||||
(-12.5, 0, "−12", "−13"),
|
||||
(13.5, 0, "14", "14"),
|
||||
(-13.5, 0, "−14", "−14"),
|
||||
] {
|
||||
pair(x, dp, even, away);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_decimal_ties() {
|
||||
for (x, even, away) in [(2.675, "2.68", "2.68"), (2.665, "2.66", "2.67"), (1.005, "1.00", "1.01"), (0.285, "0.28", "0.29")] {
|
||||
pair(x, 2, even, away);
|
||||
}
|
||||
// The existing UI policy deliberately remains different.
|
||||
assert_eq!(length_number(2.665, "mm", 2), "2.67");
|
||||
assert_eq!(length_number(2.675, "mm", 2), "2.67");
|
||||
assert_eq!(length_number(1.005, "mm", 2), "1.00");
|
||||
assert_eq!(length_number(0.285, "mm", 2), "0.28");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_tie_zone_edges_and_precision_floor() {
|
||||
for (x, expected) in [(61.49999999999999, "62"), (61.4999999996, "62"), (61.4999999994, "61"), (61.4999997, "61"), (61.4996, "61")] {
|
||||
pair(x, 0, expected, expected);
|
||||
}
|
||||
pair(1.0000000049, 8, "1.00000000", "1.00000000");
|
||||
pair(2.4501, 1, "2.5", "2.5");
|
||||
pair(2.5499, 1, "2.5", "2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_carries() {
|
||||
pair(9.995, 2, "10.00", "10.00");
|
||||
pair(-9.995, 2, "−10.00", "−10.00");
|
||||
pair(99.5, 0, "100", "100");
|
||||
pair(0.999999995, 8, "1.00000000", "1.00000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_units_and_aliases() {
|
||||
let f = NumberFormat::default();
|
||||
for (x, kind, unit, dp, expected) in [
|
||||
(25.4, Length, "in", 3, "1.000"),
|
||||
(25.4 * (1.0 + 1e-12), Length, "in", 3, "1.000"),
|
||||
(25.4, Length, "in", 0, "1"),
|
||||
(12.5, Length, "cm", 2, "1.25"),
|
||||
(3810.0, Length, "ft", 1, "12.5"),
|
||||
(std::f64::consts::PI / 6.0, Angle, "deg", 1, "30.0"),
|
||||
(1.0, Angle, "deg", 3, "57.296"),
|
||||
(1.0, Angle, "rad", 3, "1.000"),
|
||||
(0.001, Length, "um", 3, "1.000"),
|
||||
(0.001, Length, "µm", 3, "1.000"),
|
||||
(1e-6, Length, "nm", 3, "1.000"),
|
||||
(0.0254, Length, "mil", 3, "1.000"),
|
||||
(914.4, Length, "yd", 3, "1.000"),
|
||||
(1e6, Length, "km", 3, "1.000"),
|
||||
(25.4, Length, " inch ", 3, "1.000"),
|
||||
(std::f64::consts::PI, Angle, "°", 1, "180.0"),
|
||||
(std::f64::consts::PI, Angle, "degrees", 1, "180.0"),
|
||||
(1.25, Unitless, "", 2, "1.25"),
|
||||
(1.25, Unitless, "none", 2, "1.25"),
|
||||
(1.25, Unitless, "unitless", 2, "1.25"),
|
||||
] {
|
||||
let shown = format_value(x, kind, unit, dp, &f).unwrap();
|
||||
assert_eq!(shown.text, expected, "{x:?} {kind:?} in {unit:?}");
|
||||
assert_eq!(shown.value, expected.parse::<f64>().unwrap());
|
||||
}
|
||||
for (kind, unit) in [(Length, "deg"), (Angle, "mm"), (Length, ""), (Length, "parsec"), (Unitless, "mm"), (Length, "IN")] {
|
||||
assert!(matches!(format_value(1.0, kind, unit, 2, &f), Err(DocError::Invalid(_))), "{kind:?} {unit:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_unit_resolution_edges() {
|
||||
let f = NumberFormat { ties: Ties::HalfAwayFromZero, ..NumberFormat::default() };
|
||||
for (kind, unit, resolution) in [
|
||||
(Length, "mm", 9),
|
||||
(Length, "cm", 10),
|
||||
(Length, "in", 11),
|
||||
(Length, "m", 12),
|
||||
(Length, "ft", 12),
|
||||
(Angle, "deg", 8),
|
||||
(Unitless, "", 9),
|
||||
(Length, "nm", 3),
|
||||
(Length, "km", 15),
|
||||
] {
|
||||
let scale = expr::unit_info(unit).unwrap().1;
|
||||
let quantum = 10.0_f64.powi(-resolution);
|
||||
for (distance, expected) in [(0.4, "1"), (0.6, "0")] {
|
||||
let internal = (0.5 - distance * quantum) * scale;
|
||||
assert_eq!(
|
||||
format_value(internal, kind, unit, 0, &f).unwrap().text,
|
||||
expected,
|
||||
"{unit:?}, resolution={resolution}, distance={distance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_zero_sign_caps_and_shown_value() {
|
||||
for x in [-0.0, -0.004, f64::from_bits(1)] {
|
||||
let shown = format_number(x, 2, &NumberFormat::default()).unwrap();
|
||||
assert_eq!(shown.text, "0.00");
|
||||
assert_eq!(shown.value.to_bits(), 0.0_f64.to_bits());
|
||||
}
|
||||
pair(-1.5, 1, "−1.5", "−1.5");
|
||||
pair(12.5, 8, "12.50000000", "12.50000000");
|
||||
let f = NumberFormat::default();
|
||||
for dp in [9, 255] {
|
||||
assert!(matches!(format_number(12.5, dp, &f), Err(DocError::Invalid(_))));
|
||||
assert!(matches!(format_value(25.4, Length, "in", dp, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e13, -1e13, 1e12_f64.next_up(), -1e12_f64.next_up()] {
|
||||
assert!(matches!(format_number(x, 2, &f), Err(DocError::Invalid(_))), "{x:?}");
|
||||
assert!(matches!(format_value(x, Length, "mm", 2, &f), Err(DocError::Invalid(_))), "{x:?}");
|
||||
}
|
||||
for x in [-1e12, 1e12] {
|
||||
assert_eq!(format_number(x, 8, &f).unwrap().value, x);
|
||||
}
|
||||
assert_eq!(format_value(1e15, Length, "m", 8, &f).unwrap().value, 1e12, "cap is in display units");
|
||||
assert_eq!(format_number(2.665, 2, &f).unwrap().value, 2.66);
|
||||
assert_eq!(format_value(25.4, Length, "in", 3, &f).unwrap().value, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_format_options_and_group_boundaries() {
|
||||
let base = NumberFormat::default();
|
||||
let comma = NumberFormat { marker: DecimalMarker::Comma, ..base };
|
||||
assert_eq!(format_number(2.665, 2, &comma).unwrap().text, "2,66");
|
||||
let no_leading = NumberFormat { leading_zero: false, ..base };
|
||||
assert_eq!(format_number(0.5, 3, &no_leading).unwrap().text, ".500");
|
||||
let trim = NumberFormat { trailing_zeros: false, ..base };
|
||||
assert_eq!(format_number(25.0, 2, &trim).unwrap().text, "25");
|
||||
assert_eq!(format_number(25.5, 2, &trim).unwrap().text, "25.5");
|
||||
assert_eq!(format_number(0.5, 3, &NumberFormat { leading_zero: false, ..trim }).unwrap().text, ".5");
|
||||
let groups = NumberFormat { groups: true, ..base };
|
||||
for (x, dp, expected) in [
|
||||
(12345.6789, 4, "12\u{202f}345.6789"),
|
||||
(1.23456, 5, "1.234\u{202f}56"),
|
||||
(1234.5678, 4, "1234.5678"),
|
||||
(123456.0, 0, "123\u{202f}456"),
|
||||
(1234.0, 0, "1234"),
|
||||
(12345.0, 0, "12\u{202f}345"),
|
||||
(1.23456789, 8, "1.234\u{202f}567\u{202f}89"),
|
||||
(-12345.6789, 4, "−12\u{202f}345.6789"),
|
||||
(9999.9, 0, "10\u{202f}000"),
|
||||
] {
|
||||
assert_eq!(format_number(x, dp, &groups).unwrap().text, expected, "{x:?}, dp={dp}");
|
||||
}
|
||||
assert_eq!(format_number(12345.6789, 4, &NumberFormat { groups: true, ..comma }).unwrap().text, "12\u{202f}345,6789");
|
||||
assert_eq!(format_number(1.23450, 5, &NumberFormat { groups: true, ..trim }).unwrap().text, "1.2345", "group printed digits after trimming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_provisional_leading_zero_for_rounded_zero() {
|
||||
// Provisional #5290 assumption: a fractional zero also omits the integer zero.
|
||||
let f = NumberFormat { leading_zero: false, ..NumberFormat::default() };
|
||||
for x in [0.0, -0.0, -0.004] {
|
||||
assert_eq!(format_number(x, 2, &f).unwrap().text, ".00");
|
||||
assert_eq!(format_number(x, 0, &f).unwrap().text, "0");
|
||||
assert_eq!(format_number(x, 2, &NumberFormat { trailing_zeros: false, ..f }).unwrap().text, "0");
|
||||
}
|
||||
assert_eq!(format_number(-0.5, 2, &f).unwrap().text, "−.50");
|
||||
assert_eq!(format_number(0.0, 2, &NumberFormat { marker: DecimalMarker::Comma, ..f }).unwrap().text, ",00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_provisional_serde_convention_and_strict_fields() {
|
||||
// Provisional #5290 assumption, matching expr::Kind and other doc enums.
|
||||
let f: NumberFormat = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(f, NumberFormat::default());
|
||||
assert_eq!(
|
||||
serde_json::to_value(f).unwrap(),
|
||||
serde_json::json!({
|
||||
"marker": "point", "groups": false, "leading_zero": true,
|
||||
"trailing_zeros": true, "ties": "half_even"
|
||||
})
|
||||
);
|
||||
let f: NumberFormat = serde_json::from_value(serde_json::json!({"marker":"comma", "ties":"half_away_from_zero"})).unwrap();
|
||||
assert_eq!(f.marker, DecimalMarker::Comma);
|
||||
assert_eq!(f.ties, Ties::HalfAwayFromZero);
|
||||
assert!(f.leading_zero && f.trailing_zeros && !f.groups);
|
||||
assert_eq!(serde_json::from_value::<NumberFormat>(serde_json::to_value(f).unwrap()).unwrap(), f);
|
||||
for bad in [serde_json::json!({"locale":"en"}), serde_json::json!({"marker":"Point"}), serde_json::json!({"ties":"unknown"})] {
|
||||
assert!(serde_json::from_value::<NumberFormat>(bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn random(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
*state ^= *state << 17;
|
||||
*state
|
||||
}
|
||||
|
||||
/// Compare the printed decimal exactly with the input's binary fraction. A second f64
|
||||
/// subtraction can exceed the bound solely because parsing the correct text rounds again.
|
||||
fn decimal_within_bound(x: f64, text: &str, dp: u8, resolution: usize) -> bool {
|
||||
let unsigned = text.trim_start_matches('-');
|
||||
let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
|
||||
let coefficient: u128 = format!("{whole}{fraction}").parse().unwrap();
|
||||
let coefficient = coefficient * 10_u128.pow(u32::from(dp) - fraction.len() as u32);
|
||||
let extra = 10_u128.pow((resolution - usize::from(dp)) as u32);
|
||||
// Multiply |text - input| <= (10^-dp + 10^-resolution)/2 by 2*10^resolution.
|
||||
let center = 2 * coefficient * extra;
|
||||
let radius = extra + 1;
|
||||
let bits = x.abs().to_bits();
|
||||
let exponent = ((bits >> 52) & 0x7ff) as u32;
|
||||
let mantissa = u128::from(bits & ((1_u64 << 52) - 1));
|
||||
let (mantissa, shift) = if exponent == 0 { (mantissa, 1074) } else { (mantissa | (1_u128 << 52), 1075 - exponent) };
|
||||
let input = 2 * mantissa * 10_u128.pow(resolution as u32);
|
||||
// Input is below 2^104 at the supported resolutions. A shifted bound above u128's
|
||||
// range is therefore certainly above input; saturation preserves the comparison.
|
||||
let shifted = |n: u128| {
|
||||
if n == 0 {
|
||||
0
|
||||
} else if shift >= 128 || n > (u128::MAX >> shift) {
|
||||
u128::MAX
|
||||
} else {
|
||||
n << shift
|
||||
}
|
||||
};
|
||||
shifted(center.saturating_sub(radius)) <= input && input <= shifted(center + radius)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_large_magnitude_decimal_error_bound() {
|
||||
let x = 34947628153.461784;
|
||||
let shown = format_number(x, 5, &NumberFormat::default()).unwrap();
|
||||
assert_eq!(shown.text, "34947628153.46178");
|
||||
assert!(decimal_within_bound(x, &shown.text, 5, 9));
|
||||
assert!(!decimal_within_bound(x, "34947628153.46177", 5, 9));
|
||||
// Another exact binary tie whose rounded decimal has an inexact f64 representation.
|
||||
let x = 100000000000.25;
|
||||
let shown = format_number(x, 1, &NumberFormat::default()).unwrap();
|
||||
assert_eq!(shown.text, "100000000000.2");
|
||||
assert!(decimal_within_bound(x, &shown.text, 1, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_seeded_100k_properties() {
|
||||
// Reference resolutions derived by hand from the unit table, not from the formatter.
|
||||
let units = [
|
||||
("mm", 9),
|
||||
("cm", 10),
|
||||
("m", 12),
|
||||
("in", 11),
|
||||
("ft", 12),
|
||||
("yd", 12),
|
||||
("km", 15),
|
||||
("um", 6),
|
||||
("µm", 6),
|
||||
("nm", 3),
|
||||
("mil", 8),
|
||||
("inch", 11),
|
||||
("deg", 8),
|
||||
("rad", 9),
|
||||
("°", 8),
|
||||
("degree", 8),
|
||||
("degrees", 8),
|
||||
("", 9),
|
||||
("none", 9),
|
||||
("unitless", 9),
|
||||
(" in ", 11),
|
||||
("parsec", 9),
|
||||
("IN", 9),
|
||||
("\0", 9),
|
||||
("../../mm", 9),
|
||||
("🦀", 9),
|
||||
];
|
||||
let mut seed = 0x5290_a735_19d2_8bc1;
|
||||
let (mut accepted, mut rejected, mut ordered) = (0, 0, 0);
|
||||
for case in 0..100_000 {
|
||||
let x = f64::from_bits(random(&mut seed));
|
||||
let dp = (random(&mut seed) % 13) as u8;
|
||||
let bits = random(&mut seed);
|
||||
let f = NumberFormat {
|
||||
marker: if bits & 1 == 0 { DecimalMarker::Point } else { DecimalMarker::Comma },
|
||||
groups: bits & 2 != 0,
|
||||
leading_zero: bits & 4 != 0,
|
||||
trailing_zeros: bits & 8 != 0,
|
||||
ties: if bits & 16 == 0 { Ties::HalfEven } else { Ties::HalfAwayFromZero },
|
||||
};
|
||||
let kind = match random(&mut seed) % 3 {
|
||||
0 => Length,
|
||||
1 => Angle,
|
||||
_ => Unitless,
|
||||
};
|
||||
let (unit, base_resolution) = units[(random(&mut seed) % units.len() as u64) as usize];
|
||||
let bare = case % 2 == 0;
|
||||
let info = if bare { Some((Unitless, 1.0)) } else { expr::unit_info(unit).filter(|(k, _)| *k == kind) };
|
||||
let call = |v| if bare { format_number(v, dp, &f) } else { format_value(v, kind, unit, dp, &f) };
|
||||
let result = call(x);
|
||||
let display = info.map(|(_, scale)| x / scale);
|
||||
let valid = dp <= 8 && display.is_some_and(|v| v.is_finite() && v.abs() <= 1e12);
|
||||
if !valid {
|
||||
rejected += 1;
|
||||
assert!(matches!(result, Err(DocError::Invalid(_))), "case {case}: {x:?}, dp={dp}, {kind:?}, {unit:?}: {result:?}");
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
let shown = result.unwrap();
|
||||
let canonical = shown.text.replace(',', ".").replace('\u{202f}', "").replace('−', "-");
|
||||
let parsed: f64 = canonical.parse().unwrap();
|
||||
assert_eq!(shown.value.to_bits(), parsed.to_bits(), "case {case}: {shown:?}");
|
||||
assert!(!shown.text.contains('-'));
|
||||
if parsed == 0.0 {
|
||||
assert!(!shown.value.is_sign_negative() && !shown.text.starts_with('−'));
|
||||
}
|
||||
let step = 10.0_f64.powi(-i32::from(dp));
|
||||
let resolution = if bare { 9 } else { base_resolution }.max(usize::from(dp) + 3);
|
||||
assert!(
|
||||
decimal_within_bound(display.unwrap(), &canonical, dp, resolution),
|
||||
"case {case}: input={x:?}, dp={dp}, unit={unit:?}, {shown:?}"
|
||||
);
|
||||
if parsed != 0.0 {
|
||||
assert_eq!(parsed.is_sign_negative(), x.is_sign_negative());
|
||||
}
|
||||
// Shown.value already has display units; converting it again would be wrong.
|
||||
assert_eq!(format_number(shown.value, dp, &f).unwrap().text, shown.text, "idempotence case {case}");
|
||||
// Fixed settings/units; move by >=2 display steps and several input ULPs.
|
||||
// Unlike next_up alone, this requires actual output increases, not just equal zeros.
|
||||
let scale = info.unwrap().1;
|
||||
let delta = (2.0 * step * scale).max(x.abs() * f64::EPSILON * 4.0);
|
||||
let y = x + delta;
|
||||
if let Ok(next) = call(y) {
|
||||
ordered += 1;
|
||||
assert!(y > x && next.value > shown.value, "monotonicity case {case}: {x:?}->{y:?}, dp={dp}, unit={unit:?}: {shown:?}->{next:?}");
|
||||
}
|
||||
}
|
||||
assert!(accepted > 10_000 && rejected > 10_000 && ordered > 10_000, "coverage: accepted={accepted}, rejected={rejected}, ordered={ordered}");
|
||||
eprintln!("seed=0x5290a73519d28bc1 cases=100000 accepted={accepted} rejected={rejected} strictly_ordered={ordered}");
|
||||
}
|
||||
}
|
||||
|
||||
+198
-23
@@ -15,6 +15,21 @@ pub const MAX_PARAMS: usize = 10_000;
|
||||
/// numbers, and far from overflowing the next id.
|
||||
pub const MAX_FEATURE_ID: u64 = 1 << 52;
|
||||
|
||||
/// Screw direction independent of the cylinder's placement and the picked end.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThreadHandedness {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
}
|
||||
|
||||
impl ThreadHandedness {
|
||||
pub fn is_right(&self) -> bool {
|
||||
*self == Self::Right
|
||||
}
|
||||
}
|
||||
|
||||
/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…)
|
||||
/// are created by dimensions and features.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -57,8 +72,17 @@ pub enum PlaneRef {
|
||||
/// Another plane moved along its normal until it goes through a point (a vertex, sketch
|
||||
/// point or construction point), so it follows the point.
|
||||
ThroughPoint { base: Box<PlaneRef>, point: Box<GeoRef> },
|
||||
/// Another plane rotated about a line (in world coordinates) by an angle expression.
|
||||
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: Expr },
|
||||
/// Another plane rotated about a line (in world coordinates) by an angle expression. With
|
||||
/// `line`, an edge or round face of the model ([`crate::construct::model_axis`]), the line is
|
||||
/// found there on each evaluation and `axis_origin`/`axis_dir` are where it was when picked.
|
||||
AtAngle {
|
||||
base: Box<PlaneRef>,
|
||||
axis_origin: Vec3,
|
||||
axis_dir: Vec3,
|
||||
angle: Expr,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
line: Option<Box<GeoRef>>,
|
||||
},
|
||||
/// A construction plane feature (by name).
|
||||
Construction { name: FeatureName },
|
||||
/// `base` seen from its other side: the normal reversed by a half turn about the base's
|
||||
@@ -98,13 +122,36 @@ pub enum PlaneRef {
|
||||
|
||||
/// Complete source curves of an outer boundary and each of its holes. Unlike a
|
||||
/// span multiset, this identity is independent of adaptive spline subdivision.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
///
|
||||
/// A loop is a set of curves: `outer` is kept sorted, and `holes` sorted by their own ids, as
|
||||
/// the sketch's profiles produce them. Any start and either direction of the same loop is
|
||||
/// accepted and made canonical on load, so a design written by hand or by another writer still
|
||||
/// opens (#6499).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct WholeProfileBoundary {
|
||||
pub outer: Vec<String>,
|
||||
pub holes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WholeProfileBoundary {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
/// The written form, before the loop order is dropped.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Written {
|
||||
outer: Vec<String>,
|
||||
holes: Vec<Vec<String>>,
|
||||
}
|
||||
let Written { mut outer, mut holes } = Written::deserialize(deserializer)?;
|
||||
outer.sort();
|
||||
for hole in &mut holes {
|
||||
hole.sort();
|
||||
}
|
||||
holes.sort();
|
||||
Ok(Self { outer, holes })
|
||||
}
|
||||
}
|
||||
|
||||
/// Which closed profiles of a sketch a feature uses.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -371,6 +418,9 @@ pub enum AxisRef {
|
||||
World { axis: String },
|
||||
/// Any line in the sketch plane, in world coordinates.
|
||||
Line { origin: Position, dir: Dir },
|
||||
/// An edge or round face of the model (a straight or circular edge, a cylinder, cone or
|
||||
/// torus face), found again on each evaluation: [`crate::construct::model_axis`].
|
||||
Model { geo: GeoRef },
|
||||
/// A construction axis feature, by name, where it is when the revolve is evaluated.
|
||||
Construction { name: FeatureName },
|
||||
}
|
||||
@@ -729,6 +779,9 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Thread depth")]
|
||||
thread_depth: Option<Expr>,
|
||||
/// Screw direction of the cosmetic thread; older holes are right-handed.
|
||||
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
|
||||
handedness: ThreadHandedness,
|
||||
/// To Object: drill down to the face (or point) here instead of `depth`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to: Option<FacePick>,
|
||||
@@ -751,6 +804,8 @@ pub enum FeatureKind {
|
||||
length: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
modeled: bool,
|
||||
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
|
||||
handedness: ThreadHandedness,
|
||||
},
|
||||
/// Ruled loft through profiles of several sketches, in order.
|
||||
Loft {
|
||||
@@ -1273,6 +1328,14 @@ pub enum FeatureKind {
|
||||
/// The catch's height along the arm.
|
||||
#[walk(length = "Catch length")]
|
||||
catch_length: Expr,
|
||||
/// The base extends this far below the face (none: the arm starts at the face).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Base height")]
|
||||
base_height: Option<Expr>,
|
||||
/// Make the snap fit its own body instead of joining the target, so it can be
|
||||
/// patterned or moved on its own.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
new_body: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<BodyName>,
|
||||
},
|
||||
@@ -1957,6 +2020,12 @@ pub struct SketchPoints {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Component {
|
||||
pub id: u64,
|
||||
/// Optional identification colour, separate from normal appearances.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identification_color: Option<[u8; 3]>,
|
||||
/// Creation sequence used to select an automatic identification colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identification_color_index: Option<u64>,
|
||||
pub name: String,
|
||||
/// Parent component (0 = the root).
|
||||
#[serde(default)]
|
||||
@@ -2115,6 +2184,11 @@ pub enum PatternKind {
|
||||
alignment: solidcraft_sketch::PatternAlignment,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reversed: bool,
|
||||
/// The edge or round face of the model the copies turn about, found again on each
|
||||
/// evaluation ([`crate::construct::model_axis`]); `origin` and `axis` are where it was
|
||||
/// when picked.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
axis_ref: Option<GeoRef>,
|
||||
/// A construction axis the copies turn about, by name, followed when it moves;
|
||||
/// `origin` and `axis` are where it was when the pattern was made.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -2450,6 +2524,33 @@ pub struct Feature {
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
/// Where in `body_names` the names of the first `count` bodies the feature builds are kept,
|
||||
/// in the order it builds them. The same index for most features. An Import keeps its names
|
||||
/// by the file's body index, and builds no body for the file bodies it skips, so its built
|
||||
/// bodies pass their slots by (#2175).
|
||||
pub fn body_name_slots(&self, count: usize) -> Vec<usize> {
|
||||
let skip: std::collections::HashSet<u64> = match &self.kind {
|
||||
FeatureKind::Import { skip, .. } => skip.iter().copied().collect(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
(0..count.saturating_add(skip.len())).filter(|k| !skip.contains(&(*k as u64))).take(count).collect()
|
||||
}
|
||||
|
||||
/// Keep `made`, the names the feature's bodies have now (in the order it builds them), as
|
||||
/// its stored names, so they stay as they are whatever comes and goes before it. An Import's
|
||||
/// skipped file bodies keep the names stored for them.
|
||||
pub fn pin_body_names(&mut self, made: &[String]) {
|
||||
let slots = self.body_name_slots(made.len());
|
||||
self.body_names.resize(slots.last().map_or(0, |s| s + 1), String::new());
|
||||
for (slot, name) in slots.iter().zip(made) {
|
||||
if let Some(stored) = self.body_names.get_mut(*slot) {
|
||||
stored.clone_from(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inside_str() -> String {
|
||||
"inside".into()
|
||||
}
|
||||
@@ -2499,6 +2600,11 @@ pub struct DesignLink {
|
||||
pub feature: u64,
|
||||
}
|
||||
|
||||
/// A name the design's counter hands out (`Body1`).
|
||||
pub fn is_counted_body_name(name: &str) -> bool {
|
||||
name.strip_prefix("Body").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) && n.parse::<usize>().is_ok())
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
@@ -2536,6 +2642,9 @@ pub struct Document {
|
||||
/// Components below the root, as a tree (by parent).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub components: Vec<Component>,
|
||||
/// Next automatic colour sequence number; deletions never rewind it.
|
||||
#[serde(default)]
|
||||
pub next_component_color_index: u64,
|
||||
/// Placements of components in their parents (each non-root component has at least one).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub occurrences: Vec<crate::Occurrence>,
|
||||
@@ -2800,6 +2909,7 @@ impl Document {
|
||||
next_id: 1,
|
||||
reserved_body_names: Default::default(),
|
||||
components: Vec::new(),
|
||||
next_component_color_index: 0,
|
||||
occurrences: Vec::new(),
|
||||
assembly: Default::default(),
|
||||
sheet: Default::default(),
|
||||
@@ -2824,19 +2934,25 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Rename body `old` to `new` where the design keeps things by body name outside its
|
||||
/// features: materials, appearances, configuration columns, offsets, components and the
|
||||
/// features: materials, appearances, plastic rules, configuration columns, offsets, components and the
|
||||
/// browser's groups and order. (The features are the rename command's to change.)
|
||||
///
|
||||
/// A name the design numbered (`Body1`) is not used again after the rename: the design's
|
||||
/// counter starts over at every evaluation, so without this a design that is evaluated
|
||||
/// again hands the freed name to the next body (#4957).
|
||||
/// again hands the freed name to the next body (#4957). Every counted name is reserved as
|
||||
/// soon as a body has it (`Session::pin_body_names`), so this only matters for a design
|
||||
/// that has not been through a command since then. `new` may be a reserved name: a rename
|
||||
/// is deliberate, and the body then has what the design keeps under it.
|
||||
pub fn rename_body_keys(&mut self, old: &str, new: &str) {
|
||||
if old.strip_prefix("Body").and_then(|n| n.parse::<usize>().ok()).is_some() {
|
||||
if is_counted_body_name(old) {
|
||||
self.reserved_body_names.insert(old.to_string());
|
||||
}
|
||||
if let Some(m) = self.materials.remove(old) {
|
||||
self.materials.insert(new.to_string(), m);
|
||||
}
|
||||
if let Some(rule) = self.plastic.assigned.remove(old) {
|
||||
self.plastic.assigned.insert(new.to_string(), rule);
|
||||
}
|
||||
if let Some(c) = self.appearances.bodies.remove(old) {
|
||||
self.appearances.bodies.insert(new.to_string(), c);
|
||||
}
|
||||
@@ -2889,8 +3005,10 @@ impl Document {
|
||||
continue;
|
||||
}
|
||||
let now = made(a.id);
|
||||
// The stored slot of each body built now (an Import's pass its skipped bodies by).
|
||||
let at: std::collections::HashMap<usize, &String> = b.body_name_slots(now.len()).into_iter().zip(&now).collect();
|
||||
for (k, name) in b.body_names.iter().enumerate() {
|
||||
let current = now.get(k);
|
||||
let current = at.get(&k).copied();
|
||||
// Slots the rename left alone, and the ones it set to the names they have now.
|
||||
if a.body_names.get(k) == Some(name) || name.trim().is_empty() || current == Some(name) {
|
||||
continue;
|
||||
@@ -2939,21 +3057,30 @@ impl Document {
|
||||
*self == normalized
|
||||
}
|
||||
|
||||
/// Only components' part properties (metadata and free properties) changed: nothing to
|
||||
/// Only part properties changed (the fields `with_part_properties_of` names): nothing to
|
||||
/// evaluate. The same whole-Document proof as [`Document::differs_only_in_render`].
|
||||
pub fn differs_only_in_part_properties(&self, previous: &Document) -> bool {
|
||||
if self.components.len() != previous.components.len()
|
||||
|| self.components.iter().zip(&previous.components).any(|(a, b)| a.id != b.id)
|
||||
|| self.components == previous.components
|
||||
{
|
||||
return false;
|
||||
previous.with_part_properties_of(self).is_some_and(|normalized| normalized == *self)
|
||||
}
|
||||
|
||||
/// This design with `other`'s part properties, or `None` when they are this design's
|
||||
/// already (then nothing is copied). The part properties are the fields that change no
|
||||
/// geometry: the design's metadata, and each component's metadata and free properties. A
|
||||
/// field that joins them is compared and copied here, and nowhere else.
|
||||
fn with_part_properties_of(&self, other: &Document) -> Option<Document> {
|
||||
let same = self.metadata == other.metadata
|
||||
&& self.components.iter().zip(&other.components).all(|(c, o)| c.metadata == o.metadata && c.properties == o.properties);
|
||||
if same {
|
||||
return None;
|
||||
}
|
||||
let mut normalized = previous.clone();
|
||||
for (n, c) in normalized.components.iter_mut().zip(&self.components) {
|
||||
n.metadata = c.metadata.clone();
|
||||
n.properties = c.properties.clone();
|
||||
let mut out = self.clone();
|
||||
out.metadata.clone_from(&other.metadata);
|
||||
// Paired by position: if the components themselves differ, so do the designs.
|
||||
for (c, o) in out.components.iter_mut().zip(&other.components) {
|
||||
c.metadata.clone_from(&o.metadata);
|
||||
c.properties.clone_from(&o.properties);
|
||||
}
|
||||
*self == normalized
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
|
||||
@@ -3114,6 +3241,9 @@ impl Document {
|
||||
if self.components.len() >= 10_000 {
|
||||
return Err(DocError::Invalid("too many components".into()));
|
||||
}
|
||||
self.normalise_component_color_indices()?;
|
||||
let color_index = self.next_component_color_index;
|
||||
let next_color_index = color_index.checked_add(1).ok_or_else(|| DocError::Invalid("component colour sequence exhausted".into()))?;
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
|
||||
let name = match name.map(str::trim) {
|
||||
@@ -3126,7 +3256,8 @@ impl Document {
|
||||
format!("Component{k}")
|
||||
}
|
||||
};
|
||||
self.components.push(Component { id, name, parent, ..Default::default() });
|
||||
self.components.push(Component { id, name, parent, identification_color_index: Some(color_index), ..Default::default() });
|
||||
self.next_component_color_index = next_color_index;
|
||||
let occurrence = self.add_occurrence(id, parent, crate::IDENTITY)?;
|
||||
// The design's first part is grounded, so joints and drags have something fixed to work
|
||||
// from (#2804); later parts and copies start free. Only creation decides this: opening a
|
||||
@@ -3598,12 +3729,19 @@ impl Document {
|
||||
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
|
||||
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line } => {
|
||||
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
|
||||
let a = Self::eval_in(vals, angle, Kind::Angle)?;
|
||||
let (axis_origin, axis_dir) = match line {
|
||||
Some(g) => {
|
||||
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
|
||||
crate::construct::model_axis(st, g)?
|
||||
}
|
||||
None => (*axis_origin, *axis_dir),
|
||||
};
|
||||
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
|
||||
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
|
||||
let o = *axis_origin + rot(b.origin - *axis_origin);
|
||||
let o = axis_origin + rot(b.origin - axis_origin);
|
||||
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
|
||||
}
|
||||
PlaneRef::Flipped { base, about_y } => Ok(self.resolve_plane_in(vals, base, depth + 1, st)?.flipped(*about_y)),
|
||||
@@ -3652,6 +3790,7 @@ impl Document {
|
||||
/// parameters (in place).
|
||||
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
self.apply_point_values(vals, sk)?;
|
||||
self.apply_pattern_values(vals, sk)?;
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(p) = &c.param {
|
||||
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
|
||||
@@ -3683,6 +3822,43 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluate the retained pattern inputs before solving its associative relations.
|
||||
pub fn apply_pattern_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
use solidcraft_sketch::SketchPatternKind;
|
||||
self.apply_pattern_quantities(vals, sk)?;
|
||||
for index in 0..sk.patterns.len() {
|
||||
let Some(pattern) = sk.patterns.get(index) else { continue };
|
||||
let mut kind = pattern.kind.clone();
|
||||
for (key, expression) in &pattern.expressions {
|
||||
let unit = match key.as_str() {
|
||||
"count" | "count2" => Kind::Unitless,
|
||||
"spacing" | "spacing2" => Kind::Length,
|
||||
"angle" => Kind::Angle,
|
||||
_ => return Err(DocError::Invalid(format!("unknown sketch pattern input `{key}`"))),
|
||||
};
|
||||
let value = crate::eval::val(vals, expression, unit).map_err(|e| DocError::Expr(format!("pattern {} `{key}`: {e}", pattern.id)))?;
|
||||
let count = || -> Result<usize> {
|
||||
if !(1.0..=500.0).contains(&value) || value.fract() != 0.0 {
|
||||
return Err(DocError::Invalid(format!("pattern `{key}` must be a positive integer from 1 to 500")));
|
||||
}
|
||||
Ok(value as usize)
|
||||
};
|
||||
match (&mut kind, key.as_str()) {
|
||||
(SketchPatternKind::Circular { count: n, .. } | SketchPatternKind::Rectangular { count: n, .. }, "count") => *n = count()?,
|
||||
(SketchPatternKind::Rectangular { count2, .. }, "count2") => *count2 = count()?,
|
||||
(SketchPatternKind::Rectangular { spacing, .. }, "spacing") => *spacing = value,
|
||||
(SketchPatternKind::Rectangular { spacing2, .. }, "spacing2") => *spacing2 = value,
|
||||
(SketchPatternKind::Circular { angle, .. }, "angle") => *angle = value.to_degrees(),
|
||||
_ => return Err(DocError::Invalid(format!("invalid sketch pattern input `{key}`"))),
|
||||
}
|
||||
}
|
||||
if kind != pattern.kind {
|
||||
sk.update_pattern(index, kind)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the points placed by parameters to where their expressions come to (in place). Such
|
||||
/// a point is fixed, so the solve keeps it there.
|
||||
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
@@ -3778,7 +3954,6 @@ impl Document {
|
||||
moved_from: Option<&Sketch>,
|
||||
analysis: solidcraft_sketch::Analysis,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.apply_pattern_quantities(vals, sk)?;
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
|
||||
+396
-86
@@ -68,6 +68,9 @@ pub struct ModelBody {
|
||||
/// [`kernel::measure`] of `body` (with the body it was measured on), worked out once and
|
||||
/// shared by clones: inspecting a design again measures only the bodies that changed.
|
||||
measured: Arc<OnceLock<(Body, kernel::Result<kernel::BodyMeasure>)>>,
|
||||
/// [`Body::is_surface`] of `body` (with the body it was measured on), worked out once and
|
||||
/// shared by clones.
|
||||
surface: Arc<OnceLock<(Body, bool)>>,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
@@ -93,6 +96,16 @@ impl ModelBody {
|
||||
source_key: 0,
|
||||
outline: Arc::new(OnceLock::new()),
|
||||
measured: Arc::new(OnceLock::new()),
|
||||
surface: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A surface body: some shell does not close ([`Body::is_surface`]), worked out once.
|
||||
pub fn is_surface(&self) -> bool {
|
||||
match self.surface.get_or_init(|| (self.body.clone(), self.body.is_surface())) {
|
||||
(measured, s) if measured.same(&self.body) => *s,
|
||||
// The body was replaced in this cell's holder since: look again.
|
||||
_ => self.body.is_surface(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +348,8 @@ pub struct ThreadInfo {
|
||||
pub radius: f64,
|
||||
pub pitch: f64,
|
||||
pub internal: bool,
|
||||
#[serde(default)]
|
||||
pub handedness: crate::ThreadHandedness,
|
||||
pub axis_point: Vec3,
|
||||
pub axis: UnitVec3,
|
||||
/// Threaded part along the axis from `axis_point`.
|
||||
@@ -409,13 +424,21 @@ pub(crate) fn format_thread(d: f64, p: f64) -> String {
|
||||
/// The automatic ISO metric designation for a cylindrical face, using the same fit check as
|
||||
/// a thread feature without an explicit size. This query does not change the model.
|
||||
pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
|
||||
thread_on(st, 0, p, None, None, false).map(|t| t.designation)
|
||||
thread_on(st, 0, p, None, None, false, crate::ThreadHandedness::Right).map(|t| t.designation)
|
||||
}
|
||||
|
||||
/// A cosmetic thread on the cylindrical face at `p`. A `turned` (modelled) thread also fits a
|
||||
/// shaft thicker than it, which it turns down to its major diameter first.
|
||||
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>, turned: bool) -> Result<ThreadInfo> {
|
||||
thread_on_impl(st, feature, p, designation, length, false, turned)
|
||||
fn thread_on(
|
||||
st: &ModelState,
|
||||
feature: u64,
|
||||
p: Vec3,
|
||||
designation: Option<&str>,
|
||||
length: Option<f64>,
|
||||
turned: bool,
|
||||
handedness: crate::ThreadHandedness,
|
||||
) -> Result<ThreadInfo> {
|
||||
thread_on_impl(st, feature, p, designation, length, false, turned, handedness)
|
||||
}
|
||||
|
||||
// Tapped holes admit verified Unified and parallel pipe sizes; the separate Thread feature remains metric,
|
||||
@@ -428,6 +451,7 @@ fn thread_on_impl(
|
||||
length: Option<f64>,
|
||||
tapped: bool,
|
||||
turned: bool,
|
||||
handedness: crate::ThreadHandedness,
|
||||
) -> Result<ThreadInfo> {
|
||||
let (body, cyl) = st
|
||||
.bodies
|
||||
@@ -488,6 +512,7 @@ fn thread_on_impl(
|
||||
radius: cyl.radius,
|
||||
pitch,
|
||||
internal: cyl.internal,
|
||||
handedness,
|
||||
axis_point: cyl.axis_point,
|
||||
axis: cyl.axis,
|
||||
start: a,
|
||||
@@ -578,7 +603,59 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
if !stations.is_finite() || stations > 720.0 {
|
||||
return Err(DocError::Invalid("the modelled thread is too fine for this diameter; use a cosmetic thread".into()));
|
||||
}
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, *k, p, (s1 - s0) / p, stations as usize)?)
|
||||
// The kernel rotates positively about its sweep axis. Reversing both that axis and the
|
||||
// pitch mirrors the angular motion while the lift stays +k * p per turn. The physical
|
||||
// axis, start/end, radial profile and station count remain exactly as for a right thread.
|
||||
let (sweep_axis, sweep_pitch) = match t.handedness {
|
||||
crate::ThreadHandedness::Right => (*k, p),
|
||||
crate::ThreadHandedness::Left => (-*k, -p),
|
||||
};
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, sweep_axis, sweep_pitch, (s1 - s0) / p, stations as usize)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod thread_handedness_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thread_handedness_groove_reflects_exactly_about_the_physical_axis() {
|
||||
for direction in [Vec3::Z, Vec3::X, Vec3::new(1.0, 2.0, 3.0)] {
|
||||
for internal in [false, true] {
|
||||
let axis = UnitVec3::new(direction).unwrap();
|
||||
let radius = if internal { 2.5 } else { 3.0 };
|
||||
let mut info = ThreadInfo {
|
||||
feature: 1,
|
||||
body: "Stock".into(),
|
||||
designation: "M6x1".into(),
|
||||
major_diameter: 6.0,
|
||||
radius,
|
||||
pitch: 1.0,
|
||||
internal,
|
||||
handedness: crate::ThreadHandedness::Right,
|
||||
axis_point: Vec3::new(7.0, -5.0, 2.0),
|
||||
axis,
|
||||
start: 1.3,
|
||||
end: 4.3,
|
||||
};
|
||||
let right = thread_groove(&info, radius, (false, true)).unwrap();
|
||||
info.handedness = crate::ThreadHandedness::Left;
|
||||
let left = thread_groove(&info, radius, (false, true)).unwrap();
|
||||
// Reflection in the radial plane of the initial profile, independent of the
|
||||
// helix parameterization: the projected coordinate along the axis stays fixed.
|
||||
let seed = if axis.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
|
||||
let normal = (seed - *axis * axis.dot(seed)).normalized().unwrap();
|
||||
assert_eq!(right.positions.len(), left.positions.len());
|
||||
for (r, l) in right.positions.iter().zip(&left.positions) {
|
||||
let expected = *r - normal * (2.0 * (*r - info.axis_point).dot(normal));
|
||||
assert!((*l - expected).len() < 1e-10, "physical reflection: {direction:?}/{internal}");
|
||||
assert!(((*r - *l).dot(*axis)).abs() < 1e-10, "axial extent must not change");
|
||||
}
|
||||
let (r, l) = (right.measure(), left.measure());
|
||||
assert!(r.volume > 0.0 && l.volume > 0.0, "outward, watertight sweep");
|
||||
assert!((r.volume - l.volume).abs() < r.volume * 1e-12, "exact tool volume: {r:?}/{l:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a modelled external thread turns off a shaft thicker than its major diameter before it
|
||||
@@ -943,6 +1020,8 @@ pub struct EvalCache {
|
||||
order: std::collections::VecDeque<u64>,
|
||||
/// What the stored results cost together.
|
||||
bytes: usize,
|
||||
/// The byte budget (0: [`CACHE_BYTES`]).
|
||||
pub(crate) budget: usize,
|
||||
pub hits: usize,
|
||||
}
|
||||
|
||||
@@ -984,7 +1063,8 @@ impl EvalCache {
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
// One result is always kept, so a single state larger than the budget cannot empty the
|
||||
// cache and force a full recompute on every undo.
|
||||
while self.order.len() > CACHE_ENTRIES || (self.bytes > CACHE_BYTES && self.order.len() > 1) {
|
||||
let budget = if self.budget == 0 { CACHE_BYTES } else { self.budget };
|
||||
while self.order.len() > CACHE_ENTRIES || (self.bytes > budget && self.order.len() > 1) {
|
||||
if let Some(k) = self.order.pop_front()
|
||||
&& let Some(old) = self.map.remove(&k)
|
||||
{
|
||||
@@ -1086,6 +1166,22 @@ impl ModelState {
|
||||
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
|
||||
}
|
||||
|
||||
/// [`ModelState::retained_bytes`] of what this state holds that `before` does not: bodies
|
||||
/// and sketches it shares with `before` are already paid for by the cached state that made
|
||||
/// them. Charging every cached state its whole model let a design whose model is large next
|
||||
/// to the budget keep only its last few states, so setting a parameter back recomputed what
|
||||
/// had dropped out.
|
||||
fn added_bytes(&self, before: &ModelState) -> usize {
|
||||
let bodies: usize = self
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| !before.bodies.iter().any(|o| o.body.same_shape(&b.body)))
|
||||
.map(|b| if b.bytes > 0 { b.bytes } else { b.body.retained_bytes() })
|
||||
.sum();
|
||||
let sketches: usize = self.sketches.iter().filter(|s| !before.sketches.iter().any(|o| Arc::ptr_eq(o, s))).map(|s| s.retained_bytes()).sum();
|
||||
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
|
||||
}
|
||||
|
||||
pub fn body(&self, name: &str) -> Option<&ModelBody> {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
@@ -1103,8 +1199,8 @@ impl ModelState {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// [`ModelState::warm_meshes`], and every body's display plan at the current display quality
|
||||
/// (what a GPU viewport draws).
|
||||
|
||||
/// Prepare reference meshes and display plans at the current quality for ahead jobs.
|
||||
pub fn warm_display(&self) {
|
||||
let q = crate::display_quality::current();
|
||||
let planned = |b: &ModelBody| b.plan.get(q.index()).is_none_or(|cell| cell.get().is_some());
|
||||
@@ -1114,6 +1210,19 @@ impl ModelState {
|
||||
let _ = b.display_plan();
|
||||
});
|
||||
}
|
||||
|
||||
/// Prepare reference picking geometry and the GPU plan/highlight mesh at exactly the
|
||||
/// requested quality. The first viewport frame then consumes initialized caches only.
|
||||
pub fn warm_display_at(&self, quality: crate::display_quality::DisplayQuality, go_on: impl Fn() -> bool + Sync) {
|
||||
kernel::for_each_on_threads(&self.bodies, 16, |body| {
|
||||
if go_on() {
|
||||
let _ = body.mesh();
|
||||
}
|
||||
if go_on() {
|
||||
let _ = body.plan_mesh_at(quality);
|
||||
}
|
||||
});
|
||||
}
|
||||
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
|
||||
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
|
||||
}
|
||||
@@ -1170,6 +1279,10 @@ pub struct Model {
|
||||
step_digests: StepDigests,
|
||||
/// The document context the last `evaluate` ran in ([`context_digest`]).
|
||||
context: u64,
|
||||
/// The last active result of each feature hidden by the history marker, with its document
|
||||
/// context. These share the existing states, so rolling back does not surrender them to
|
||||
/// the bounded cache. A result leaves this map when its feature is active again or removed.
|
||||
rolled_results: std::collections::HashMap<u64, (u64, FeatureResult)>,
|
||||
/// Results another copy of this model evaluated (ahead, off the window thread) that
|
||||
/// `evaluate` takes for features they match ([`Model::prime`]), with that copy's context.
|
||||
primed: Option<(u64, Arc<std::collections::HashMap<u64, FeatureResult>>)>,
|
||||
@@ -1183,6 +1296,7 @@ fn param_names(f: &Feature) -> Vec<String> {
|
||||
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
|
||||
}
|
||||
@@ -1330,13 +1444,28 @@ fn feature_json(f: &Feature, steps: &StepDigests) -> String {
|
||||
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
|
||||
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
|
||||
/// match `stored` entity for entity is not adopted.
|
||||
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) {
|
||||
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) -> bool {
|
||||
if stored.patterns != solved.patterns {
|
||||
// Rebuild authored copies without storing topology derived from links or offsets.
|
||||
let mut updated = stored.clone();
|
||||
for pattern in &solved.patterns {
|
||||
let Some(index) = updated.patterns.iter().position(|p| p.id == pattern.id) else { return false };
|
||||
if updated.update_pattern(index, pattern.kind.clone()).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if updated.patterns != solved.patterns || !adopt_solution(&mut updated, solved) {
|
||||
return false;
|
||||
}
|
||||
*stored = updated;
|
||||
return true;
|
||||
}
|
||||
if stored.points.len() != solved.points.len()
|
||||
|| stored.curves.len() != solved.curves.len()
|
||||
|| stored.constraints.len() != solved.constraints.len()
|
||||
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
|
||||
p.pos = q.pos;
|
||||
@@ -1358,6 +1487,7 @@ fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sk
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The values features are evaluated with: the parameters, and the design's length unit.
|
||||
@@ -1412,9 +1542,45 @@ fn chain_fingerprint(
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
// A pattern or mirror of features replays its sources' definitions wherever they are in the
|
||||
// timeline. One made with the timeline rolled back comes before them, where the chain doesn't
|
||||
// cover them: its copies kept the size the sources had when they were made.
|
||||
if let FeatureKind::Pattern { features, bodies, .. } | FeatureKind::Mirror { features, bodies, .. } = &f.kind
|
||||
&& bodies.is_empty()
|
||||
&& !features.is_empty()
|
||||
{
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
for g in replayed_sources(doc, features) {
|
||||
fingerprint(doc, 0, g, vals, false, steps).hash(&mut h);
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
fp
|
||||
}
|
||||
|
||||
/// The features a pattern or mirror of `features` replays: each source, and the sources of one
|
||||
/// that is itself a pattern or mirror of features (see `copy_plan`), each once.
|
||||
fn replayed_sources<'a>(doc: &'a Document, features: &[crate::FeatureName]) -> Vec<&'a Feature> {
|
||||
let mut out: Vec<&Feature> = Vec::new();
|
||||
let mut todo: Vec<String> = crate::plain(features);
|
||||
// A bound on the walk, as on the copy plan's nesting: a design can't make it run on.
|
||||
for _ in 0..1024 {
|
||||
let Some(name) = todo.pop() else { break };
|
||||
let Ok(g) = source_feature(doc, &name) else { continue };
|
||||
if out.iter().any(|x| x.id == g.id) {
|
||||
continue;
|
||||
}
|
||||
if let FeatureKind::Pattern { features, bodies, .. } | FeatureKind::Mirror { features, bodies, .. } = &g.kind
|
||||
&& bodies.is_empty()
|
||||
{
|
||||
todo.extend(crate::plain(features));
|
||||
}
|
||||
out.push(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
|
||||
/// metal and plastic rules (and the parameters they use), components and body placement.
|
||||
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
|
||||
@@ -1864,6 +2030,7 @@ impl Model {
|
||||
s.report.keeps_orientation()
|
||||
&& doc.sketch(s.feature).is_ok_and(|stored| {
|
||||
stored.driving_values() != s.sketch.driving_values()
|
||||
|| stored.patterns != s.sketch.patterns
|
||||
|| s.sketch.points.iter().zip(&stored.points).any(|(p, q)| p.link.is_some() && p.id == q.id && p.pos != q.pos)
|
||||
})
|
||||
})
|
||||
@@ -1935,6 +2102,8 @@ impl Model {
|
||||
let mut restored = 0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let ctx = context_digest(doc, &vals);
|
||||
let previous_context = self.context;
|
||||
let mut rolled_results = std::mem::take(&mut self.rolled_results);
|
||||
self.context = ctx;
|
||||
self.step_digests = StepDigests::of(doc, &self.step_digests);
|
||||
let primed = self.primed.as_ref().filter(|(context, _)| !self.no_cache && *context == ctx).map(|(_, results)| results.clone());
|
||||
@@ -1947,6 +2116,20 @@ impl Model {
|
||||
let watched = crate::progress::begin(doc.features.len());
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
// Retain at most one active result per rolled-back feature, even across several
|
||||
// marker moves. Build the map anew so deleted features release their states too.
|
||||
let rolled = rolled_results.remove(&f.id);
|
||||
let rolled = if self.no_cache {
|
||||
None
|
||||
} else if rolled_back {
|
||||
let active = self.results.get(i).filter(|r| r.id == f.id && !r.skipped && !r.stale && !r.unkept);
|
||||
if let Some(saved) = active.map(|r| (previous_context, r.clone())).or(rolled) {
|
||||
self.rolled_results.insert(f.id, saved);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
rolled.filter(|(context, _)| *context == ctx).map(|(_, r)| r)
|
||||
};
|
||||
// Opening was cancelled: the rest is skipped and the result discarded.
|
||||
let cancelled = watched.step(i);
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
|
||||
@@ -2021,8 +2204,9 @@ impl Model {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Evaluated ahead on these same inputs (the same chain of definitions from the start).
|
||||
if let Some(r) = primed.as_ref().and_then(|p| p.get(&f.id)).filter(|r| r.fingerprint == fp && !r.stale && !r.skipped) {
|
||||
// An active result hidden by the marker, or evaluated ahead on these same inputs
|
||||
// (the same chain of definitions from the start). Changed inputs never reuse it.
|
||||
if let Some(r) = rolled.as_ref().filter(current).or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current)) {
|
||||
restored += 1;
|
||||
if r.error.is_none() {
|
||||
state = r.state.clone();
|
||||
@@ -2032,6 +2216,7 @@ impl Model {
|
||||
continue;
|
||||
}
|
||||
recomputed += 1;
|
||||
let before = state.clone();
|
||||
let t0 = now();
|
||||
let mut next = (*state).clone();
|
||||
// The names the design must not use again travel with the state (#4957, #4302).
|
||||
@@ -2044,52 +2229,15 @@ impl Model {
|
||||
// Face references by name: move each face point onto its named face (on the body the
|
||||
// feature names, when it names one and the face is there).
|
||||
let renamed;
|
||||
// Cylindrical emboss resolves its named face in the selected body's frame
|
||||
// and certifies an analytic anchor; mesh projection here would undo that.
|
||||
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
|
||||
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
|
||||
let mut g = f.clone();
|
||||
let mut notes = Vec::new();
|
||||
// A hole's position is a place on its face, not a pick of the face: it follows
|
||||
// the face along the hole's axis and never slides sideways to the face's nearest
|
||||
// point when the face no longer reaches it.
|
||||
let hole_axis = match &f.kind {
|
||||
FeatureKind::Hole { direction, points: None | Some(crate::SketchPoints { single: true, .. }), .. } => direction.normalized(),
|
||||
_ => None,
|
||||
};
|
||||
let fst = face_point_state(doc, &state, &f.kind, f.component);
|
||||
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match crate::name_lookup::locate_face_on_body(&fst, f.kind.face_body(), n, *p) {
|
||||
Some(hit) => {
|
||||
if let Some(note) = hit.note(n) {
|
||||
notes.push(note);
|
||||
}
|
||||
match (k, hole_axis) {
|
||||
(0, Some(axis)) => match hit.along(&fst, *p, axis) {
|
||||
Some(q) => *p = q,
|
||||
// The place still lies on the face (the axis may run along it).
|
||||
None if hit.on_point => {}
|
||||
None => notes.push(format!(
|
||||
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
|
||||
p.x, p.y, p.z
|
||||
)),
|
||||
},
|
||||
_ => *p = hit.point,
|
||||
}
|
||||
}
|
||||
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
|
||||
let f = match with_named_face_points(doc, &state, f) {
|
||||
Some((g, notes)) => {
|
||||
if !notes.is_empty() {
|
||||
warning = Some(notes.join("; "));
|
||||
}
|
||||
renamed = g;
|
||||
&renamed
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
warning = Some(notes.join("; "));
|
||||
}
|
||||
renamed = g;
|
||||
&renamed
|
||||
} else {
|
||||
f
|
||||
None => f,
|
||||
};
|
||||
// A parameter with an error fails the features using it, with that error.
|
||||
let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}")));
|
||||
@@ -2158,7 +2306,7 @@ impl Model {
|
||||
warning: warning.clone(),
|
||||
boolean_diagnostics: boolean_diagnostics.clone(),
|
||||
ms,
|
||||
bytes: state.retained_bytes(),
|
||||
bytes: state.added_bytes(&before),
|
||||
state: state.clone(),
|
||||
};
|
||||
if let Some(k) = &self.kept.0 {
|
||||
@@ -2294,7 +2442,7 @@ impl Model {
|
||||
|
||||
/// Set every result's fingerprint to `doc`'s, so the next evaluation reuses every result
|
||||
/// (after a change that builds nothing differently).
|
||||
fn refingerprint(&mut self, doc: &Document) {
|
||||
pub fn refingerprint(&mut self, doc: &Document) {
|
||||
let (vals, _) = evaluation_values(doc);
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
self.step_digests = StepDigests::of(doc, &self.step_digests);
|
||||
@@ -2408,6 +2556,11 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
&& !n.is_empty()
|
||||
&& state.body(n).is_none()
|
||||
{
|
||||
// A pinned `BodyN` counts as handed out, so a design evaluated again from its stored
|
||||
// names ends with the counter it had when the counter numbered them.
|
||||
if let Some(number) = n.strip_prefix("Body").filter(|_| crate::is_counted_body_name(n)).and_then(|d| d.parse::<usize>().ok()) {
|
||||
state.body_counter = state.body_counter.max(number);
|
||||
}
|
||||
return n.clone();
|
||||
}
|
||||
// Bodies past the stored names (a profile added after the feature was made) are numbered
|
||||
@@ -4006,17 +4159,15 @@ fn blend_body(
|
||||
Ok((Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]), used, tapered))
|
||||
}
|
||||
|
||||
/// The revolve axis in the sketch's 2D coordinates (a point on it, its direction). `construct`
|
||||
/// gives a construction axis, by name, in the sketch's 3D frame.
|
||||
pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef, construct: &dyn Fn(&str) -> Result<(Vec3, Vec3)>) -> Result<(Vec2, Vec2)> {
|
||||
let in_plane = |origin: Vec3, dir: Vec3| {
|
||||
let pl = &ss.plane;
|
||||
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
|
||||
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin).abs() > 1e-6 {
|
||||
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
|
||||
}
|
||||
Ok((pl.to_local(origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
};
|
||||
/// The revolve axis in the sketch's plane coordinates (a point and a direction). `st` is the model
|
||||
/// an axis on an edge or face of it is found in; `construct` gives a construction axis, by name, in
|
||||
/// the sketch's 3D frame.
|
||||
pub(crate) fn revolve_axis(
|
||||
ss: &SolvedSketch,
|
||||
axis: &AxisRef,
|
||||
st: &ModelState,
|
||||
construct: &dyn Fn(&str) -> Result<(Vec3, Vec3)>,
|
||||
) -> Result<(Vec2, Vec2)> {
|
||||
match axis {
|
||||
AxisRef::SketchLine { curve } => {
|
||||
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?;
|
||||
@@ -4046,14 +4197,38 @@ pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef, construct: &dyn Fn
|
||||
}
|
||||
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
}
|
||||
AxisRef::Line { origin, dir } => in_plane(origin.0, dir.0),
|
||||
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, origin.0, dir.0),
|
||||
AxisRef::Model { geo } => {
|
||||
let (o, d) = crate::construct::model_axis(st, geo)?;
|
||||
line_in_sketch_plane(ss, o, d)
|
||||
}
|
||||
AxisRef::Construction { name } => {
|
||||
let (origin, dir) = construct(name)?;
|
||||
in_plane(origin, dir)
|
||||
let (o, d) = construct(name)?;
|
||||
line_in_sketch_plane(ss, o, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The model a revolve axis is found in: what construction geometry of component `comp` is built
|
||||
/// on when the axis is an edge or face of it (only then is it worth placing).
|
||||
pub(super) fn axis_state<'a>(doc: &Document, st: &'a ModelState, comp: u64, axis: &AxisRef) -> std::borrow::Cow<'a, ModelState> {
|
||||
match axis {
|
||||
AxisRef::Model { .. } => construction_state(doc, st, comp),
|
||||
_ => std::borrow::Cow::Borrowed(st),
|
||||
}
|
||||
}
|
||||
|
||||
/// A world line through `origin` along `dir` in the sketch's plane coordinates, or an error when it
|
||||
/// does not lie in the sketch plane.
|
||||
fn line_in_sketch_plane(ss: &SolvedSketch, origin: Vec3, dir: Vec3) -> Result<(Vec2, Vec2)> {
|
||||
let pl = &ss.plane;
|
||||
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
|
||||
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin).abs() > 1e-6 {
|
||||
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
|
||||
}
|
||||
Ok((pl.to_local(origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
}
|
||||
|
||||
/// A construction axis `name` of the timeline so far, as evaluated, in the frame of component
|
||||
/// `comp`: a point on it and its direction. Without a document (`doc` None) it is taken in its
|
||||
/// own component's frame.
|
||||
@@ -5215,7 +5390,8 @@ fn feature_tools_with_constructor(
|
||||
f.component,
|
||||
)?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) = revolve_axis(&ss, axis, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
|
||||
let (o, d) =
|
||||
revolve_axis(&ss, axis, &axis_state(doc, st, f.component, axis), &|name| construction_axis_in(Some(doc), st, name, f.component))?;
|
||||
// To Object is in this feature's component frame: the model as seen from there.
|
||||
let tst = if to.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) };
|
||||
// Where the tool meets its targets is measured where they are shown (#3383).
|
||||
@@ -5627,10 +5803,18 @@ fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<crate::Expr
|
||||
Ok(Region2 { outer: solidcraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
|
||||
}
|
||||
|
||||
fn map_wire(wire: &mut solidcraft_sketch::Wire, matrix: &Mat) {
|
||||
pub fn map_wire(wire: &mut solidcraft_sketch::Wire, matrix: &Mat) {
|
||||
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
|
||||
*point = crate::apply_point(matrix, *point);
|
||||
}
|
||||
if let Some(arc) = &mut wire.arc {
|
||||
let normal = crate::apply_vector(matrix, arc.normal);
|
||||
arc.center = crate::apply_point(matrix, arc.center);
|
||||
arc.start = crate::apply_point(matrix, arc.start);
|
||||
arc.end = crate::apply_point(matrix, arc.end);
|
||||
arc.normal = normal.normalized().unwrap_or(arc.normal);
|
||||
arc.radius = arc.center.dist(arc.start);
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature's sketch as its component sees it: a sketch of another component that is placed
|
||||
@@ -6029,10 +6213,12 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed, axis_feature } => {
|
||||
let (origin, axis) = match axis_feature {
|
||||
Some(name) => construction_axis_in(Some(doc), st, name, component)?,
|
||||
None => (origin.0, axis.0),
|
||||
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed, axis_ref, axis_feature } => {
|
||||
// On an edge or round face: about it as it is now; on a construction axis: where it is.
|
||||
let (origin, axis) = match (axis_ref, axis_feature) {
|
||||
(Some(g), _) => crate::construct::model_axis(&construction_state(doc, st, component), g)?,
|
||||
(None, Some(name)) => construction_axis_in(Some(doc), st, name, component)?,
|
||||
(None, None) => (origin.0, axis.0),
|
||||
};
|
||||
let n = count(c)?;
|
||||
let total = val(vals, angle, Kind::Angle)?;
|
||||
@@ -6158,6 +6344,54 @@ fn copied_features(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelSta
|
||||
/// own sources, at each of its transforms moved by each of ours. Those copies are numbered after
|
||||
/// `mats`' own (which keep the numbers they always had), each nested source in a block of its
|
||||
/// own, so no two copies of one feature share an instance. Suppressed sources copy nothing.
|
||||
/// `f` with each face point moved onto its named face (on the body the feature names, when it
|
||||
/// names one and the face is there) in `state`, and notes for names that no longer find their
|
||||
/// face. `None` when `f` names no face.
|
||||
fn with_named_face_points(doc: &Document, state: &ModelState, f: &Feature) -> Option<(Feature, Vec<String>)> {
|
||||
// Cylindrical emboss resolves its named face in the selected body's frame
|
||||
// and certifies an analytic anchor; mesh projection here would undo that.
|
||||
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
|
||||
if explicit_emboss || !f.face_names.iter().any(|n| !n.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut g = f.clone();
|
||||
let mut notes = Vec::new();
|
||||
// A hole's position is a place on its face, not a pick of the face: it follows
|
||||
// the face along the hole's axis and never slides sideways to the face's nearest
|
||||
// point when the face no longer reaches it.
|
||||
let hole_axis = match &f.kind {
|
||||
FeatureKind::Hole { direction, points: None | Some(crate::SketchPoints { single: true, .. }), .. } => direction.normalized(),
|
||||
_ => None,
|
||||
};
|
||||
let fst = face_point_state(doc, state, &f.kind, f.component);
|
||||
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match crate::name_lookup::locate_face_on_body(&fst, f.kind.face_body(), n, *p) {
|
||||
Some(hit) => {
|
||||
if let Some(note) = hit.note(n) {
|
||||
notes.push(note);
|
||||
}
|
||||
match (k, hole_axis) {
|
||||
(0, Some(axis)) => match hit.along(&fst, *p, axis) {
|
||||
Some(q) => *p = q,
|
||||
// The place still lies on the face (the axis may run along it).
|
||||
None if hit.on_point => {}
|
||||
None => notes.push(format!(
|
||||
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
|
||||
p.x, p.y, p.z
|
||||
)),
|
||||
},
|
||||
_ => *p = hit.point,
|
||||
}
|
||||
}
|
||||
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
|
||||
}
|
||||
}
|
||||
Some((g, notes))
|
||||
}
|
||||
|
||||
fn copy_plan<'a>(
|
||||
doc: &'a Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
@@ -6344,8 +6578,11 @@ fn replay(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Each copy's faces are named as the source feature's, with the instance.
|
||||
let tools = feature_tools_with_constructor(doc, vals, src, st, &mut None)?;
|
||||
// Each copy's faces are named as the source feature's, with the instance. Its face
|
||||
// points follow their named faces, as the feature's own did (#2121): a To Object
|
||||
// extrude's copies reach the face where it is now, not where it was picked.
|
||||
let moved = with_named_face_points(doc, st, src).map(|(g, _)| g);
|
||||
let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, &mut None)?;
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let start = copies.len();
|
||||
for t in &tools {
|
||||
@@ -6355,13 +6592,38 @@ fn replay(
|
||||
}
|
||||
}
|
||||
if op == Operation::Cut {
|
||||
for instance in instances {
|
||||
if let Some(tools) = copies.get(instance) {
|
||||
for instance in &instances {
|
||||
if let Some(tools) = copies.get(instance.clone()) {
|
||||
total += 1;
|
||||
missed += usize::from(copy_misses(st, f, &targets, tools));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copies of a cut through sheet metal are cut-outs of its flat pattern, as the cut is
|
||||
// (#6841): each machined into the sheets in turn, then cut from the other targets.
|
||||
if op == Operation::Cut && matches!(src.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() && instances.len() == mats.len() {
|
||||
let mut handled = Vec::with_capacity(mats.len());
|
||||
for (m, instance) in mats.iter().zip(&instances) {
|
||||
let bodies: Vec<Body> = copies.get(instance.clone()).unwrap_or_default().iter().map(|t| t.body.clone()).collect();
|
||||
handled.push(more::sheet_cut_copy(f, src, m, st, &bodies, &targets, warning)?);
|
||||
}
|
||||
if handled.iter().any(|h| !h.is_empty()) {
|
||||
// Copies the same sheets took are cut from the rest at once.
|
||||
let mut groups: Vec<(Vec<String>, Vec<Traced>)> = Vec::new();
|
||||
let mut rest = copies.into_iter();
|
||||
for (done, instance) in handled.into_iter().zip(&instances) {
|
||||
let tools: Vec<Traced> = rest.by_ref().take(instance.len()).collect();
|
||||
match groups.iter_mut().find(|(d, _)| *d == done) {
|
||||
Some((_, all)) => all.extend(tools),
|
||||
None => groups.push((done, tools)),
|
||||
}
|
||||
}
|
||||
for (done, tools) in groups {
|
||||
apply_beside_sheet_cut(doc, st, f, tools, op, &targets, &done)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// New bodies from a pattern join their source when they touch it (as one feature would).
|
||||
if f.body_names.is_empty() {
|
||||
apply_traced(doc, st, f, copies, op, &targets)?;
|
||||
@@ -6626,11 +6888,12 @@ pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line } => PlaneRef::AtAngle {
|
||||
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
|
||||
axis_origin: *axis_origin,
|
||||
axis_dir: *axis_dir,
|
||||
angle: angle.clone(),
|
||||
line: line.clone(),
|
||||
},
|
||||
PlaneRef::Flipped { base, about_y } => {
|
||||
PlaneRef::Flipped { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), about_y: *about_y }
|
||||
@@ -6855,7 +7118,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
apply_traced(doc, st, f, tools, *operation, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::Hole { thread, thread_depth, diameter, .. } => {
|
||||
FeatureKind::Hole { thread, thread_depth, diameter, handedness, .. } => {
|
||||
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
|
||||
if thread_depth.is_some() && thread.is_none() {
|
||||
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
|
||||
@@ -6882,7 +7145,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
} else {
|
||||
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
|
||||
};
|
||||
let mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false)?;
|
||||
let mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false, *handedness)?;
|
||||
if let Some(depth) = thread_depth {
|
||||
let available = t.end - t.start;
|
||||
if depth > available {
|
||||
@@ -6905,12 +7168,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Thread { face, designation, length, modeled } => {
|
||||
FeatureKind::Thread { face, designation, length, modeled, handedness } => {
|
||||
let l = match length {
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?),
|
||||
None => None,
|
||||
};
|
||||
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled)?;
|
||||
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled, *handedness)?;
|
||||
if !*modeled {
|
||||
st.threads.push(t);
|
||||
return Ok(());
|
||||
@@ -7623,6 +7886,7 @@ fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std:
|
||||
fn plane_is_fixed(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true,
|
||||
PlaneRef::AtAngle { line: Some(_), .. } => false,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_is_fixed(base),
|
||||
_ => false,
|
||||
}
|
||||
@@ -7633,6 +7897,7 @@ fn plane_is_fixed(plane: &PlaneRef) -> bool {
|
||||
fn plane_reads_geometry(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } | PlaneRef::Construction { .. } => false,
|
||||
PlaneRef::AtAngle { line: Some(_), .. } => true,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_reads_geometry(base),
|
||||
PlaneRef::ThroughPoint { .. }
|
||||
| PlaneRef::Midplane { .. }
|
||||
@@ -8118,6 +8383,51 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod display_preparation_tests {
|
||||
use super::*;
|
||||
|
||||
fn state() -> ModelState {
|
||||
let body = kernel::box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
|
||||
ModelState { bodies: vec![ModelBody::new("Box".into(), body, 1)], ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_display_reads_all_three_caches_without_cold_geometry_or_quality_changes() {
|
||||
use crate::display_quality::{DisplayQuality, current};
|
||||
let state = state();
|
||||
let body = &state.bodies[0];
|
||||
let quality = DisplayQuality::Fine;
|
||||
let index = quality.index();
|
||||
let current = current();
|
||||
let tolerance = body.display_tol();
|
||||
assert!(body.mesh.get().is_none() && body.plan[index].get().is_none() && body.plan_mesh[index].get().is_none());
|
||||
state.warm_display_at(quality, || true);
|
||||
// Inspect the caches before invoking any consumer that could initialize them.
|
||||
let reference = body.mesh.get().unwrap();
|
||||
let plan = body.plan[index].get().unwrap().as_ref().unwrap();
|
||||
let display = body.plan_mesh[index].get().unwrap().as_ref().unwrap();
|
||||
assert!(Arc::ptr_eq(reference, &body.mesh()));
|
||||
assert!(Arc::ptr_eq(plan, &body.display_plan_at(quality).unwrap()));
|
||||
assert!(Arc::ptr_eq(display, &body.plan_mesh_at(quality).unwrap()));
|
||||
assert_eq!(crate::display_quality::current(), current);
|
||||
assert_eq!(body.display_tol(), tolerance);
|
||||
for other in DisplayQuality::ALL.into_iter().filter(|other| *other != quality) {
|
||||
assert!(body.plan[other.index()].get().is_none() && body.plan_mesh[other.index()].get().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_display_preparation_leaves_cold_caches_untouched() {
|
||||
let state = state();
|
||||
state.warm_display_at(crate::display_quality::DisplayQuality::Normal, || false);
|
||||
let body = &state.bodies[0];
|
||||
assert!(body.mesh.get().is_none());
|
||||
assert!(body.plan.iter().all(|slot| slot.get().is_none()));
|
||||
assert!(body.plan_mesh.iter().all(|slot| slot.get().is_none()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests {
|
||||
use super::*;
|
||||
|
||||
+62
-10
@@ -933,13 +933,61 @@ pub(super) fn sheet_cut(
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
|
||||
if !any_eligible_sheet(f, st, targets) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(sketch.0).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
// The outlines of the sketch's text: the sheet remembers where its letters went.
|
||||
let Some(cut) = extrude_cut(st, f)? else { return Ok(Vec::new()) };
|
||||
sheet_prism_cut(f, st, &cut.plane, &cut.regions, cut.operation, tools, targets, constructor, cut.bound, &cut.text, warning)
|
||||
}
|
||||
|
||||
/// A copy of the extrude cut `src` that the pattern `f` makes, moved by `m`: machined into the
|
||||
/// sheets as the cut itself is, in the moved sketch plane (#6841). `tools` are the copy's. Only
|
||||
/// for a cut moved without turning over: a mirrored copy, and any other feature, comes back
|
||||
/// unhandled (no sheets) for the caller to apply as before.
|
||||
pub(super) fn sheet_cut_copy(
|
||||
f: &Feature,
|
||||
src: &Feature,
|
||||
m: &Mat,
|
||||
st: &mut ModelState,
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
if !any_eligible_sheet(f, st, targets) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(cut) = extrude_cut(st, src)? else { return Ok(Vec::new()) };
|
||||
if cut.operation != Operation::Cut {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (x, y) = (crate::apply_vector(m, cut.plane.x), crate::apply_vector(m, cut.plane.y));
|
||||
if x.cross(y).dot(crate::apply_vector(m, cut.plane.normal())) <= 0.0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(plane) = Plane::new(apply_point(m, cut.plane.origin), x, y) else { return Ok(Vec::new()) };
|
||||
sheet_prism_cut(f, st, &plane, &cut.regions, cut.operation, tools, targets, None, cut.bound, &cut.text, warning)
|
||||
}
|
||||
|
||||
/// What an untapered extrude cut (or intersect) machines into a sheet.
|
||||
struct ExtrudeCut {
|
||||
plane: Plane,
|
||||
regions: Vec<Region2>,
|
||||
/// The outlines of the sketch's text: the sheet remembers where its letters went.
|
||||
text: Vec<Seg2>,
|
||||
operation: Operation,
|
||||
/// Up to a face: the tools' own footprints bound it.
|
||||
bound: bool,
|
||||
}
|
||||
|
||||
/// The sketch plane, regions and text of `f`'s extrude; None for any other feature, a tapered
|
||||
/// extrude or a missing sketch.
|
||||
fn extrude_cut(st: &ModelState, f: &Feature) -> Result<Option<ExtrudeCut>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(None) };
|
||||
if extent.taper.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(ss) = st.sketch(sketch.0) else { return Ok(None) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||||
let text: Vec<Seg2> = ss
|
||||
.sketch
|
||||
.links
|
||||
@@ -948,7 +996,7 @@ pub(super) fn sheet_cut(
|
||||
.flat_map(|l| ss.sketch.link_curves(&l.id))
|
||||
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
|
||||
Ok(Some(ExtrudeCut { plane: ss.plane, regions, text, operation: *operation, bound: extent.to.is_some() }))
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets (else its participants, else its component's
|
||||
@@ -2005,19 +2053,22 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
apply_traced(doc, st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, base_height, new_body, body } => {
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let size = |what: &str, e: &str| plastic_size("snap fit", what, len(e)?);
|
||||
let (l, t, w) = (size("length", length)?, size("thickness", thickness)?, size("width", width)?);
|
||||
let (cd, cl) = (size("catch_depth", catch_depth)?, size("catch_length", catch_length)?);
|
||||
let bh = base_height.as_deref().map(|e| size("base_height", e)).transpose()?.unwrap_or(0.0);
|
||||
if !(cl < l) {
|
||||
return Err(DocError::Invalid("the snap fit's catch must be shorter than its arm".into()));
|
||||
}
|
||||
let (x, y, z) = local_frame(direction.0, Some(hook.0))?;
|
||||
let delta = (l * 0.05).clamp(1e-3, 0.5);
|
||||
// Side profile (x = toward the catch, y = up the arm), extruded across the width.
|
||||
let pts =
|
||||
[Vec2::new(-t, -delta), Vec2::new(0.0, -delta), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
|
||||
// The base reaches `base_height` below the face; a joined arm bites `delta` into
|
||||
// the wall, a separate body sits flush on it.
|
||||
let y0 = if *new_body { -bh } else { -delta - bh };
|
||||
let pts = [Vec2::new(-t, y0), Vec2::new(0.0, y0), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
|
||||
let o = position.0 - y * (w / 2.0);
|
||||
let pl = Plane::new(o, x, z).ok_or_else(|| DocError::Invalid("snap fit frame".into()))?;
|
||||
let mut lp = Loop2::polygon(&pts);
|
||||
@@ -2028,7 +2079,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
|
||||
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect();
|
||||
apply_traced(doc, st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let op = if *new_body { Operation::NewBody } else { Operation::Join };
|
||||
apply_traced(doc, st, f, tool, op, std::slice::from_ref(&target))
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, position.0)?;
|
||||
|
||||
@@ -304,7 +304,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let (o, d) = revolve_axis(&ss, axis, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
|
||||
let (o, d) = revolve_axis(&ss, axis, &super::axis_state(doc, st, f.component, axis), &|name| {
|
||||
construction_axis_in(Some(doc), st, name, f.component)
|
||||
})?;
|
||||
let a = val(vals, angle, Kind::Angle)?;
|
||||
let (start, sweep) = match (angle2, symmetric) {
|
||||
(Some(a2), _) => {
|
||||
|
||||
+87
-8
@@ -611,6 +611,8 @@ pub fn length_unit_or_mm(units: &str) -> &str {
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Tok>,
|
||||
/// Each token's byte range in the source: whether a fraction is written closed up.
|
||||
spans: Vec<(usize, usize)>,
|
||||
pos: usize,
|
||||
lookup: &'a dyn Fn(&str) -> Result<Value, DocError>,
|
||||
depth: usize,
|
||||
@@ -670,35 +672,79 @@ impl Parser<'_> {
|
||||
}
|
||||
}
|
||||
fn unary(&mut self) -> Result<Value, DocError> {
|
||||
self.signed(true)
|
||||
}
|
||||
/// A signed operand. `glue`: a number takes the unit written after it (not in an exponent).
|
||||
fn signed(&mut self, glue: bool) -> Result<Value, DocError> {
|
||||
match self.peek() {
|
||||
Some(Tok::Op('-')) => {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let v = self.unary()?;
|
||||
let v = self.signed(glue)?;
|
||||
self.depth -= 1;
|
||||
Ok(Value { v: -v.v, ..v })
|
||||
}
|
||||
Some(Tok::Op('+')) => {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let v = self.unary()?;
|
||||
let v = self.signed(glue)?;
|
||||
self.depth -= 1;
|
||||
Ok(v)
|
||||
}
|
||||
_ => self.power(),
|
||||
_ => self.power(glue),
|
||||
}
|
||||
}
|
||||
fn power(&mut self) -> Result<Value, DocError> {
|
||||
let base = self.atom()?;
|
||||
fn power(&mut self, glue: bool) -> Result<Value, DocError> {
|
||||
let number = matches!(self.peek(), Some(Tok::Num(_)));
|
||||
let at = self.pos;
|
||||
let mut base = self.atom()?;
|
||||
// A number and the unit after it are one quantity, bound tighter than `*` and `/`:
|
||||
// `10 mm / 2 mm` is 5, not (10 mm / 2) mm = 5 mm². The unit keeps its own power
|
||||
// (`3 mm^2`). A name that is not a unit (`2 width`) stays an ordinary product.
|
||||
if glue && number {
|
||||
// A fraction of two numbers before a unit is one value, as sizes are written:
|
||||
// `1/2"` and `1/2 in` are half an inch (as the mixed number `2 1/8"` is one value).
|
||||
// After a division only written closed up: `1 in / 1/2 in` is 2, but
|
||||
// `width / 10 / 2 mm` is (width / 10) / 2 mm.
|
||||
if let (Some(Tok::Op('/')), Some(Tok::Num(d))) = (self.peek(), self.toks.get(self.pos + 1))
|
||||
&& self.unit_at(self.pos + 2)
|
||||
&& (!self.after_division(at) || self.closed_up(at, self.pos + 1))
|
||||
{
|
||||
let d = Value::num(*d);
|
||||
self.pos += 2;
|
||||
base = mul(base, d, true)?;
|
||||
}
|
||||
if self.unit_at(self.pos) {
|
||||
let u = self.power(false)?;
|
||||
return mul(base, u, false);
|
||||
}
|
||||
}
|
||||
if let Some(Tok::Op('^')) = self.peek() {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let e = self.unary()?;
|
||||
// The exponent is a plain number: `2^3 mm` stays 8 mm.
|
||||
let e = self.signed(false)?;
|
||||
self.depth -= 1;
|
||||
return pow(base, e);
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
/// Whether the operand starting at token `i` is divided by: a `/` before it, past any signs.
|
||||
fn after_division(&self, i: usize) -> bool {
|
||||
let mut k = i;
|
||||
while k > 0 && matches!(self.toks.get(k - 1), Some(Tok::Op('+' | '-'))) {
|
||||
k -= 1;
|
||||
}
|
||||
k > 0 && matches!(self.toks.get(k - 1), Some(Tok::Op('/')))
|
||||
}
|
||||
/// Whether tokens `a` to `b` are written without spaces between them.
|
||||
fn closed_up(&self, a: usize, b: usize) -> bool {
|
||||
(a..b).all(|i| matches!((self.spans.get(i), self.spans.get(i + 1)), (Some(x), Some(y)) if x.1 == y.0))
|
||||
}
|
||||
/// Whether the token at `i` is a unit name, not called as a function.
|
||||
fn unit_at(&self, i: usize) -> bool {
|
||||
matches!(self.toks.get(i), Some(Tok::Id(id)) if unit(id).is_some()) && !matches!(self.toks.get(i + 1), Some(Tok::Op('(')))
|
||||
}
|
||||
fn atom(&mut self) -> Result<Value, DocError> {
|
||||
match self.next() {
|
||||
Some(Tok::Num(v)) => Ok(Value::num(v)),
|
||||
@@ -931,11 +977,11 @@ pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>) -> R
|
||||
|
||||
/// Evaluate an expression reading bare numbers next to lengths / angles in `defaults`.
|
||||
pub fn eval_with_defaults(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>, defaults: Defaults) -> Result<Value, DocError> {
|
||||
let toks = lex(s)?;
|
||||
let (toks, spans): (Vec<Tok>, Vec<(usize, usize)>) = lex_spans(s)?.into_iter().map(|(t, a, b)| (t, (a, b))).unzip();
|
||||
if toks.is_empty() {
|
||||
return Err(DocError::Expr("empty expression".into()));
|
||||
}
|
||||
let mut p = Parser { toks, pos: 0, lookup, depth: 0, defaults };
|
||||
let mut p = Parser { toks, spans, pos: 0, lookup, depth: 0, defaults };
|
||||
let v = p.expr()?;
|
||||
if p.pos < p.toks.len() {
|
||||
return Err(DocError::Expr(format!("unexpected input in `{s}`")));
|
||||
@@ -1192,6 +1238,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_and_its_unit_are_one_quantity() {
|
||||
// `10 mm / 2 mm` divides by 2 mm; it is not `(10 mm / 2) mm`, a value in mm².
|
||||
assert_eq!(ev("10 mm / 2 mm", Kind::Unitless).unwrap(), 5.0);
|
||||
assert_eq!(ev("10mm/2mm", Kind::Unitless).unwrap(), 5.0);
|
||||
assert_eq!(ev("width / 4 mm", Kind::Unitless).unwrap(), 10.0);
|
||||
assert_eq!(ev("6 mm / 2 mm * 3", Kind::Unitless).unwrap(), 9.0);
|
||||
assert!((ev("90 deg / 1 rad", Kind::Unitless).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
assert!((ev("(ang - 30 deg) / 1 rad * width", Kind::Length).unwrap() - 40.0 * 60f64.to_radians()).abs() < 1e-12);
|
||||
assert_eq!(ev("1 in / 1 mm", Kind::Unitless).unwrap(), 25.4);
|
||||
assert_eq!(ev("2 1/2 in / 1 in", Kind::Unitless).unwrap(), 2.5);
|
||||
assert_eq!(ev("1\" / 1 mm", Kind::Unitless).unwrap(), 25.4);
|
||||
assert_eq!(ev("-10 mm / 2 mm", Kind::Unitless).unwrap(), -5.0);
|
||||
// A fraction of numbers right before a unit stays one value: half an inch.
|
||||
assert_eq!(ev("1/2 in", Kind::Length).unwrap(), 12.7);
|
||||
assert_eq!(ev("width + 3/4\"", Kind::Length).unwrap(), 40.0 + 0.75 * 25.4);
|
||||
assert_eq!(ev("1 in / 1/2 in", Kind::Unitless).unwrap(), 2.0);
|
||||
assert_eq!(ev("10 / 2 mm", Kind::Length).unwrap(), 5.0);
|
||||
// After a division, a spaced-out `/` divides again: (width / 10) / 2 mm.
|
||||
assert_eq!(ev("width / 10 / 2 mm", Kind::Unitless).unwrap(), 2.0);
|
||||
assert_eq!(ev("width / -10 / 2 mm", Kind::Unitless).unwrap(), -2.0);
|
||||
assert_eq!(ev("width / 10/2 mm", Kind::Unitless).unwrap(), 8.0);
|
||||
// The unit keeps its own power, and an exponent stays a plain number.
|
||||
assert_eq!(ev("sqrt(9 mm^2)", Kind::Length).unwrap(), 3.0);
|
||||
assert_eq!(ev("8 mm^3 / 2 mm^2", Kind::Length).unwrap(), 4.0);
|
||||
assert_eq!(ev("2^3 mm", Kind::Length).unwrap(), 8.0);
|
||||
// A number next to a name that is not a unit is a product, as before.
|
||||
assert_eq!(ev("2 width", Kind::Length).unwrap(), 80.0);
|
||||
// Unchanged: a unit alone, and a unit after a parenthesis.
|
||||
assert_eq!(ev("width / mm", Kind::Unitless).unwrap(), 40.0);
|
||||
assert_eq!(ev("(10 / 2) mm", Kind::Length).unwrap(), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_units() {
|
||||
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
|
||||
|
||||
@@ -1144,6 +1144,7 @@ fn rigid(m: &crate::Mat) -> bool {
|
||||
|
||||
/// Feature input names and component occurrences, which format 1 files may predate.
|
||||
fn normalise(d: &mut Document) -> Result<()> {
|
||||
d.normalise_component_color_indices()?;
|
||||
d.name_all_feature_inputs();
|
||||
let missing: Vec<(u64, u64)> =
|
||||
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Components save an optional `identification_color_index` and the document saves
|
||||
//! `next_component_color_index`. Automatic colours follow creation order and wrap through the
|
||||
//! palette; deletions do not rewind the sequence. Older records receive indices in stored order
|
||||
//! during normalization (not the order they were originally created), preserving explicit RGB overrides.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! An edge joint origin (`Snap::Edge`) saves an optional `axis`: `{"along": [x, y, z]}` (z runs
|
||||
//! along the edge, the way the vector points) or `{"fixed": [x, y, z]}` (z is that direction), for
|
||||
//! an edge or a corner picked in the Joint dialog, which used to be saved as a point. Older
|
||||
//! designs have no `axis` and resolve as before (z out of the face). The last release refuses a
|
||||
//! design with this change by its newer format number.
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Sketch patterns retain count, spacing and angle expressions alongside evaluated settings.
|
||||
//! Missing expressions preserve the numeric settings of older designs without rewriting them.
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A Revolve, Surface Revolve, Circular Pattern or Plane at Angle can turn about an edge or round
|
||||
//! face of the model, kept as a reference (`AxisRef::Model`, a pattern's `axis_ref`, an angle
|
||||
//! plane's `line`: a circular edge's axis through its centre, a cylinder's, cone's or torus's
|
||||
//! axis, or a straight edge), found again by its persistent name on each evaluation so the
|
||||
//! feature follows the edge or face when the model changes. Older designs need no
|
||||
//! transformation: their axes stay the fixed lines or named axes they were saved with. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Design containers may save mesh blobs up to 512 MiB uncompressed, using Deflate when
|
||||
//! a blob exceeds the 256 MiB file limit. The complete saved file still fits that limit.
|
||||
//! Older designs keep their mesh coordinates, indices and compression without migration.
|
||||
//! New saves carry the next format number because released readers only accept mesh
|
||||
//! blobs up to 256 MiB; they must be updated to open these larger payloads.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Drawn 3D sketch arcs: a wire drawn as an exact circular arc now also saves its analytic
|
||||
//! form (`arc`: centre, unit normal, radius, start and end) beside its three defining fit
|
||||
//! points and its samples. Older designs contain no such arcs and open unchanged; a design
|
||||
//! saved with this change carries the newer format number, which the last release refuses.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Hole and Thread features may store `handedness: "left"`. An absent value still means
|
||||
//! right-handed, so older designs need no rewriting. New-format gating prevents an older
|
||||
//! reader from silently dropping the direction and rebuilding a left thread as right.
|
||||
@@ -49,32 +49,35 @@ fn validate_whole_reference(value: &Value) -> Result<()> {
|
||||
if total > 20_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut previous = None;
|
||||
// Bound each raw ID before any owned allocation. Require canonical order.
|
||||
// Bound each raw ID before any owned allocation. A loop is a set of curves: its
|
||||
// written order (any start, either direction) carries no meaning, so it is made
|
||||
// canonical here instead of refusing the design (#6499).
|
||||
let mut ids = Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let current = id(value)?;
|
||||
if previous.is_some_and(|previous| previous >= current) {
|
||||
return Err(invalid());
|
||||
}
|
||||
previous = Some(current);
|
||||
ids.push(id(value)?.to_owned());
|
||||
}
|
||||
values.iter().map(|value| Ok(id(value)?.to_owned())).collect()
|
||||
ids.sort();
|
||||
if ids.windows(2).any(|pair| pair[0] == pair[1]) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(ids)
|
||||
};
|
||||
let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?;
|
||||
let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if holes.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut sorted_holes = Vec::with_capacity(holes.len());
|
||||
let mut all: BTreeSet<_> = outer.iter().cloned().collect();
|
||||
let mut canonical_holes = Vec::with_capacity(holes.len());
|
||||
for hole in holes {
|
||||
let hole = boundary(hole)?;
|
||||
if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) {
|
||||
if hole.iter().any(|curve| !all.insert(curve.clone())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
sorted_holes.push(hole);
|
||||
canonical_holes.push(hole);
|
||||
}
|
||||
if !identities.insert((outer, sorted_holes)) {
|
||||
canonical_holes.sort();
|
||||
if canonical_holes.windows(2).any(|pair| pair[0] == pair[1]) || !identities.insert((outer, canonical_holes)) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
@@ -262,19 +265,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() {
|
||||
fn whole_boundaries_raw_identity_is_bounded_unique_and_complete() {
|
||||
let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]});
|
||||
assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1));
|
||||
let malformed = [
|
||||
json!({"type":"whole_boundaries","profiles":[]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]},{"outer":["s2"],"holes":[["s1"],["s3"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s2"],"holes":[]},{"outer":["s2","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}),
|
||||
];
|
||||
@@ -300,6 +303,33 @@ mod tests {
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_boundary_loop_is_accepted_in_any_written_order() {
|
||||
// Any start and either direction of one closed loop name the same region (#6499).
|
||||
for curves in
|
||||
[json!(["s1", "s2", "s3", "s4"]), json!(["s2", "s3", "s4", "s1"]), json!(["s4", "s3", "s2", "s1"]), json!(["s3", "s4", "s1", "s2"])]
|
||||
{
|
||||
let value = json!({"type":"whole_boundaries","profiles":[{"outer":curves,"holes":[]}]});
|
||||
let Some(ProfileSel::WholeBoundaries { profiles }) = whole_profile_selection_from_value(&value).ok() else {
|
||||
panic!("reversed loop {value} refused");
|
||||
};
|
||||
assert_eq!(profiles.first().map(|profile| profile.outer.clone()), Some(["s1", "s2", "s3", "s4"].map(str::to_string).to_vec()));
|
||||
assert!(profiles.first().is_some_and(|profile| profile.holes.is_empty()));
|
||||
assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_ok());
|
||||
}
|
||||
// Holes are a set of loops too, and the identity is canonical, so the same loop written
|
||||
// in another order is still the same profile.
|
||||
let holey = json!({"type":"whole_boundaries","profiles":[{"outer":["o2","o1"],"holes":[["h2","h3"],["h1"]]}]});
|
||||
let Some(ProfileSel::WholeBoundaries { profiles }) = whole_profile_selection_from_value(&holey).ok() else {
|
||||
panic!("hole loop order refused");
|
||||
};
|
||||
let profile = profiles.first().cloned().unwrap();
|
||||
assert_eq!(profile.outer, ["o1".to_string(), "o2".to_string()]);
|
||||
assert_eq!(profile.holes, vec![vec!["h1".to_string()], vec!["h2".to_string(), "h3".to_string()]]);
|
||||
let saved = serde_json::to_value(ProfileSel::WholeBoundaries { profiles }).unwrap_or_default();
|
||||
assert_eq!(saved, json!({"type":"whole_boundaries","profiles":[{"outer":["o1","o2"],"holes":[["h1"],["h2","h3"]]}]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() {
|
||||
let good = fixture();
|
||||
|
||||
+39
-10
@@ -75,6 +75,10 @@ pub enum Snap {
|
||||
face: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corner: Option<String>,
|
||||
/// The frame's z when it isn't the face's normal (an edge or a corner picked as it is,
|
||||
/// not on a face): the point still follows the edge, found by name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
axis: Option<EdgeAxis>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z) and x toward `x` laid square to z (default: one chosen
|
||||
/// from z alone).
|
||||
@@ -87,6 +91,25 @@ pub enum Snap {
|
||||
},
|
||||
}
|
||||
|
||||
/// The z of an edge origin that isn't its face's normal (component coordinates); its x follows
|
||||
/// from z alone, as a point origin's does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EdgeAxis {
|
||||
/// Along a straight edge, the way this points: a hinge on the edge turns about it as it moves.
|
||||
Along(Vec3),
|
||||
/// This direction.
|
||||
Fixed(Vec3),
|
||||
}
|
||||
|
||||
impl EdgeAxis {
|
||||
pub fn dir(self) -> Vec3 {
|
||||
match self {
|
||||
EdgeAxis::Along(d) | EdgeAxis::Fixed(d) => d,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What sets a joint origin's x axis (its turn about z): a direction, or a face found by its
|
||||
/// persistent name, so a round origin keeps its turn when the design changes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -179,7 +202,7 @@ impl Snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
|
||||
Snap::Point { point, z, x } => point.is_finite() && z.is_none_or(|z| z.is_finite()) && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::Edge { pick, .. } => pick.is_finite(),
|
||||
Snap::Edge { pick, axis, .. } => pick.is_finite() && axis.is_none_or(|a| a.dir().is_finite()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1370,9 +1393,11 @@ fn closure_gaps(workspace: &mut PlacementWorkspace, closing: &[u64], vals: &Valu
|
||||
}
|
||||
|
||||
/// How far a closing joint may be off before it is a conflict: its frames apart by more than
|
||||
/// this many millimetres or turned by more than this many radians.
|
||||
const CONFLICT_MM: f64 = 1e-6;
|
||||
const CONFLICT_RAD: f64 = 1e-8;
|
||||
/// this many millimetres or turned by more than this many radians (a micrometre over a metre).
|
||||
/// Holes picked on parts are rarely exactly in line: axes a few hundredths of a microradian
|
||||
/// apart (#6211) are not a conflict, and the loop through them still moves.
|
||||
const CONFLICT_MM: f64 = 1e-4;
|
||||
const CONFLICT_RAD: f64 = 1e-6;
|
||||
|
||||
/// The largest gap (`closure_gaps`, for loops of size `scale`) that still passes the conflict
|
||||
/// check, with room to spare.
|
||||
@@ -1382,8 +1407,9 @@ fn loose_gap(scale: f64) -> f64 {
|
||||
|
||||
/// Close loops whose gaps `f` gives from `x`, and the tolerance to keep them closed to from here
|
||||
/// on: `tol`, or, where they come no closer than that but within `loose`, a little more than they
|
||||
/// do come. Faces modelled a few nanometres apart along parallel axes leave a gap no value of a
|
||||
/// planar loop takes out; the loop still moves, as closed as it is. `None` when they can't close.
|
||||
/// do come. Faces modelled a few nanometres apart along parallel axes, or axes a hair out of
|
||||
/// parallel, leave a gap no value of a planar loop takes out (the second one that grows as the
|
||||
/// loop turns); the loop still moves, as closed as it comes. `None` when they can't close.
|
||||
fn close_within(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64, loose: f64) -> Option<f64> {
|
||||
if least_squares(f, x, bounds, tol) {
|
||||
return Some(tol);
|
||||
@@ -1691,7 +1717,8 @@ fn close_group(
|
||||
// that close is left as it is.
|
||||
let mut x = x0;
|
||||
let start = largest(&gaps(&x, 0.0)?);
|
||||
let tol = match close_within(&mut |x| gaps(x, 0.0), &mut x, &bounds, tol, loose_gap(scale))? {
|
||||
let loose = loose_gap(scale);
|
||||
let tol = match close_within(&mut |x| gaps(x, 0.0), &mut x, &bounds, tol, loose)? {
|
||||
t if t > tol && moves.is_empty() && start <= t => return Some(BTreeMap::new()),
|
||||
t => t,
|
||||
};
|
||||
@@ -1703,7 +1730,9 @@ fn close_group(
|
||||
}
|
||||
let t1 = (t + dt).min(1.0);
|
||||
let mut trial = x.clone();
|
||||
if least_squares(&mut |x| gaps(x, t1), &mut trial, &bounds, tol) {
|
||||
// Each step as closed as the start, or within the conflict check where the loop's gap
|
||||
// grows as it moves (axes a hair out of parallel, #6211).
|
||||
if close_within(&mut |x| gaps(x, t1), &mut trial, &bounds, tol, loose).is_some() {
|
||||
// A step that moved some loop value too far (through a link and a limit, or close to
|
||||
// a dead point) is taken again in halves.
|
||||
let next = effective(&trial, t1);
|
||||
@@ -1718,7 +1747,7 @@ fn close_group(
|
||||
dt *= 0.5;
|
||||
}
|
||||
}
|
||||
if largest(&gaps(&x, 1.0)?) > tol {
|
||||
if largest(&gaps(&x, 1.0)?) > tol.max(loose) {
|
||||
return None;
|
||||
}
|
||||
// The values that changed from what is stored.
|
||||
@@ -2079,7 +2108,7 @@ pub mod cam;
|
||||
|
||||
#[path = "joints_edge.rs"]
|
||||
mod edge;
|
||||
pub use edge::edge_snap;
|
||||
pub use edge::{edge_axis, edge_snap};
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{Snap, component_bodies, frame};
|
||||
use super::{EdgeAxis, Snap, component_bodies, frame};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, mat_inverse, mat_mul};
|
||||
|
||||
/// A body's placement in its component and its inverse: a body moved in from another component
|
||||
@@ -108,7 +108,7 @@ fn meeting(e: &[Vec3], f: &[Vec3], tol: f64) -> Option<Vec3> {
|
||||
|
||||
/// The frame of an edge snap in its component's coordinates.
|
||||
pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap) -> Result<Mat> {
|
||||
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let Snap::Edge { pick, edge, face, corner, axis } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| {
|
||||
@@ -143,6 +143,27 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
frame(at, z, Some(x)).ok_or_else(bad)
|
||||
}
|
||||
}?;
|
||||
// A z of its own: x from z alone, at the point the edge gives.
|
||||
let local = match axis {
|
||||
None => local,
|
||||
Some(a) => {
|
||||
let at = Vec3::new(local[3][0], local[3][1], local[3][2]);
|
||||
let (_, inv) = placement(doc, b);
|
||||
let want = crate::apply_vector(&inv, a.dir());
|
||||
let z = match a {
|
||||
EdgeAxis::Fixed(_) => want,
|
||||
// The edge's run, the way `want` points.
|
||||
EdgeAxis::Along(_) => {
|
||||
let run = match (e.first(), e.last()) {
|
||||
(Some(p), Some(q)) => (*q - *p).normalized().ok_or_else(bad)?,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
if run.dot(want) < 0.0 { -run } else { run }
|
||||
}
|
||||
};
|
||||
frame(at, z, None).ok_or_else(bad)?
|
||||
}
|
||||
};
|
||||
Ok(mat_mul(&placed, &local))
|
||||
}
|
||||
|
||||
@@ -207,5 +228,16 @@ pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Opt
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner })
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner, axis: None })
|
||||
}
|
||||
|
||||
/// Edge snap `snap` of component `comp` given z `z` (component coordinates) of its own: along the
|
||||
/// edge when the edge runs that way (so a hinge on it keeps turning about it), else fixed.
|
||||
pub fn edge_axis(doc: &Document, st: &ModelState, comp: u64, snap: Snap, z: Vec3) -> Snap {
|
||||
let Snap::Edge { pick, edge, face, corner, .. } = snap else { return snap };
|
||||
let with = |axis| Snap::Edge { pick, edge: edge.clone(), face: face.clone(), corner: corner.clone(), axis: Some(axis) };
|
||||
let along = with(EdgeAxis::Along(z));
|
||||
let runs =
|
||||
edge_frame(doc, st, comp, &along).is_ok_and(|m| z.normalized().is_some_and(|z| Vec3::new(m[2][0], m[2][1], m[2][2]).dot(z) > 1.0 - 1e-9));
|
||||
if runs { along } else { with(EdgeAxis::Fixed(z)) }
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use solidcraft_sketch::linalg;
|
||||
|
||||
use super::{
|
||||
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, jacobian, joint_values,
|
||||
largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
|
||||
largest, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
|
||||
};
|
||||
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
|
||||
|
||||
@@ -325,7 +325,7 @@ fn stride(asm: &Assembly, vals: &Values, vars: &[(u64, usize)], paced: &[((u64,
|
||||
/// Levenberg-Marquardt that stays on the loops: move `x` (within `bounds`, at most `cap` per value
|
||||
/// and a step of every value `stride` measures, links included, a step) to bring the first `mt` values of `f` (the target's misses) toward zero, the rest (the
|
||||
/// loops' weighted gaps) at zero. Each step is solved over all the rows, values on a limit the
|
||||
/// step pushes past held, then put back on the loops (`least_squares` on their gaps) and taken
|
||||
/// step pushes past held, then put back on the loops (`close_within` on their gaps) and taken
|
||||
/// when it gets closer. It ends at the target, or as close as the limits and loops let it get.
|
||||
/// Returns `f` there, or `None` when the loops don't close at the start (within `loose`).
|
||||
fn approach(
|
||||
@@ -341,14 +341,14 @@ fn approach(
|
||||
let n = x.len();
|
||||
let miss = |r: &[f64]| r.iter().take(mt).map(|v| v * v).sum::<f64>();
|
||||
// The loops close first, to `tol` or as near as they come within `loose` (`close_within`),
|
||||
// and stay that closed.
|
||||
// and stay that closed, or within `loose` where their gap grows as they move (#6211).
|
||||
let loops = f(x)?.len() > mt;
|
||||
let mut loop_tol = tol * LOOP_WEIGHT;
|
||||
if loops {
|
||||
loop_tol = close_within(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, loop_tol, loose * LOOP_WEIGHT)?;
|
||||
}
|
||||
let close = |f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>| {
|
||||
least_squares(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, loop_tol)
|
||||
close_within(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, loop_tol, loose * LOOP_WEIGHT).is_some()
|
||||
};
|
||||
let mut r = f(x)?;
|
||||
let (mut lambda, mut kicked) = (1e-3, false);
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod component_color;
|
||||
mod component_removal;
|
||||
pub use component_removal::RemovedTree;
|
||||
pub mod config;
|
||||
@@ -75,9 +76,10 @@ pub use document::*;
|
||||
pub use eval::KeptResults;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, curve_pick_error,
|
||||
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height,
|
||||
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
|
||||
state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
|
||||
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, map_wire, metric_thread_sizes,
|
||||
object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction,
|
||||
sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state,
|
||||
world_state_reusing,
|
||||
};
|
||||
pub use expr::Expr;
|
||||
pub use names::{BodyName, FeatureName, SketchId};
|
||||
|
||||
+345
-58
@@ -1,14 +1,17 @@
|
||||
//! Design properties (#1015): the design's optional top-level `metadata` object.
|
||||
//! Design and part properties (#1015): the optional `metadata` object of the design and of each
|
||||
//! component.
|
||||
//!
|
||||
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
|
||||
//! revision, author, organization, description, licence) and keeps every other key exactly as
|
||||
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
|
||||
//! key each (`com.example.tool`), holding an object.
|
||||
//! revision, author, organization, description, licence), listed once in [`KEYS`] with where
|
||||
//! each may be set (the design, a component or both), how its value is checked and its label.
|
||||
//! It keeps every other key exactly as it was through open, edit and save. Other tools keep
|
||||
//! their own data under one reverse-DNS key each (`com.example.tool`), holding an object.
|
||||
//!
|
||||
//! Reading never validates: a well-known key holding something other than text is kept as it
|
||||
//! is and reads as absent, so a file another tool wrote always opens. Writing through
|
||||
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
|
||||
//! its size, so nothing SolidCraft writes is malformed.
|
||||
//! [`Metadata::set`] (the `document.metadata.set` and `component.metadata.set` commands) checks
|
||||
//! the key against where it is set, the value's kind and its size, so nothing SolidCraft writes
|
||||
//! is malformed.
|
||||
//!
|
||||
//! The map is stored as is: typed fields would refuse a design another tool had written
|
||||
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
|
||||
@@ -27,9 +30,67 @@ pub const ORGANIZATION: &str = "organization";
|
||||
pub const DESCRIPTION: &str = "description";
|
||||
pub const LICENSE: &str = "license";
|
||||
|
||||
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
|
||||
/// unprefixed key is reserved for SolidCraft too.
|
||||
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
|
||||
/// Where a key may be set: on the design, on a component, or on both.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
Design,
|
||||
Component,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Whether a key of this scope may be set in `owner`'s metadata.
|
||||
pub fn covers(self, owner: Owner) -> bool {
|
||||
matches!((self, owner), (Scope::Both, _) | (Scope::Design, Owner::Design) | (Scope::Component, Owner::Component))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whose metadata a change is to: the design's (`document.metadata.set`) or a component's
|
||||
/// (`component.metadata.set`). [`Metadata::set`] is told which.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Owner {
|
||||
Design,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
/// What one of its properties, and several, are called in a refusal.
|
||||
fn names(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Owner::Design => ("design property", "design properties"),
|
||||
Owner::Component => ("part property", "part properties"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of SolidCraft's own keys.
|
||||
pub struct Key {
|
||||
pub name: &'static str,
|
||||
/// Where it may be set.
|
||||
pub scope: Scope,
|
||||
/// The message id of its label (the Design Properties dialog, the Bill of Materials window).
|
||||
pub label: &'static str,
|
||||
/// The value as it is saved (`None`: the key is removed), or why it can't be; given the key
|
||||
/// to name it in the refusal.
|
||||
check: fn(&str, Value) -> Result<Option<Value>>,
|
||||
}
|
||||
|
||||
/// SolidCraft's own keys, in the order the Design Properties dialog shows them; a new key is one
|
||||
/// row here. Every other unprefixed key is reserved for SolidCraft too.
|
||||
pub static KEYS: &[Key] = &[
|
||||
Key { name: PROJECT, scope: Scope::Both, label: "ui.design_properties.project", check: text },
|
||||
Key { name: PART_NUMBER, scope: Scope::Both, label: "ui.design_properties.part_number", check: text },
|
||||
Key { name: REVISION, scope: Scope::Both, label: "ui.design_properties.revision", check: text },
|
||||
Key { name: AUTHOR, scope: Scope::Both, label: "ui.design_properties.author", check: text },
|
||||
Key { name: ORGANIZATION, scope: Scope::Both, label: "ui.design_properties.organization", check: text },
|
||||
Key { name: DESCRIPTION, scope: Scope::Both, label: "ui.design_properties.description", check: text },
|
||||
Key { name: LICENSE, scope: Scope::Both, label: "ui.design_properties.license", check: text },
|
||||
];
|
||||
|
||||
/// The keys a bill of materials lists for each part, in the order of its columns:
|
||||
/// `component.metadata` returns each, `component.metadata.set` takes each as a parameter of its
|
||||
/// own, and the Bill of Materials window edits them.
|
||||
pub static BOM_KEYS: &[&str] = &[PART_NUMBER, DESCRIPTION, REVISION];
|
||||
|
||||
/// Longest key, in bytes.
|
||||
pub const MAX_KEY_BYTES: usize = 128;
|
||||
@@ -43,14 +104,29 @@ pub const MAX_VALUE_DEPTH: usize = 32;
|
||||
pub const MAX_KEYS: usize = 256;
|
||||
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// The design's `metadata` object.
|
||||
/// A design's or a component's `metadata` object.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Metadata(pub Map<String, Value>);
|
||||
|
||||
/// A key SolidCraft reads as a design property.
|
||||
pub fn is_well_known(key: &str) -> bool {
|
||||
WELL_KNOWN.contains(&key)
|
||||
/// SolidCraft's keys that may be set in `owner`'s metadata, in [`KEYS`]' order.
|
||||
pub fn keys(owner: Owner) -> impl Iterator<Item = &'static Key> {
|
||||
KEYS.iter().filter(move |k| k.scope.covers(owner))
|
||||
}
|
||||
|
||||
/// The names of [`keys`]`(owner)`, as refusals list them.
|
||||
pub fn key_list(owner: Owner) -> String {
|
||||
keys(owner).map(|k| k.name).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// One of SolidCraft's keys, by name.
|
||||
pub fn key(name: &str) -> Option<&'static Key> {
|
||||
KEYS.iter().find(|k| k.name == name)
|
||||
}
|
||||
|
||||
/// A key SolidCraft reads as a design or part property.
|
||||
pub fn is_well_known(name: &str) -> bool {
|
||||
key(name).is_some()
|
||||
}
|
||||
|
||||
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
|
||||
@@ -61,6 +137,31 @@ pub fn is_namespaced(key: &str) -> bool {
|
||||
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
|
||||
}
|
||||
|
||||
/// Text SolidCraft keeps as a property: at most [`MAX_TEXT_BYTES`], and no control characters
|
||||
/// but line breaks and tabs. `what` names it in the refusal.
|
||||
pub fn check_text(what: &str, text: &str) -> Result<()> {
|
||||
if text.len() > MAX_TEXT_BYTES {
|
||||
return Err(invalid(format!("{what} is longer than {MAX_TEXT_BYTES} bytes")));
|
||||
}
|
||||
if text.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
|
||||
return Err(invalid(format!("{what} has a control character")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A text key's check: text, which empty text (like `null`) removes.
|
||||
fn text(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.is_empty() => Ok(None),
|
||||
Value::String(s) => {
|
||||
check_text(&format!("`{key}`"), &s)?;
|
||||
Ok(Some(Value::String(s)))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` takes text"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn depth(v: &Value) -> usize {
|
||||
// Iterative, so a hostile value can't overflow the stack here.
|
||||
let mut deepest = 0;
|
||||
@@ -129,10 +230,10 @@ impl Metadata {
|
||||
self.0.iter().filter(|(k, _)| !is_well_known(k))
|
||||
}
|
||||
|
||||
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
|
||||
/// removes it. Returns whether the metadata changed.
|
||||
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
|
||||
let value = check(key, value)?;
|
||||
/// Set `key` to `value` in `owner`'s metadata, after checking both; `null` (or empty text for
|
||||
/// a text key) removes it. Returns whether the metadata changed.
|
||||
pub fn set(&mut self, owner: Owner, key: &str, value: Value) -> Result<bool> {
|
||||
let value = check(owner, key, value)?;
|
||||
let Some(value) = value else { return Ok(self.remove(key)) };
|
||||
if self.0.get(key) == Some(&value) {
|
||||
return Ok(false);
|
||||
@@ -146,7 +247,7 @@ impl Metadata {
|
||||
Some(old) => self.0.insert(key.to_string(), old),
|
||||
None => self.0.remove(key),
|
||||
};
|
||||
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
|
||||
return Err(invalid(format!("{} are limited to {MAX_KEYS} keys and {} KiB", owner.names().1, MAX_TOTAL_BYTES / 1024)));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -157,23 +258,10 @@ impl Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
|
||||
fn check(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if is_well_known(key) {
|
||||
return match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.is_empty() => Ok(None),
|
||||
Value::String(s) => {
|
||||
if s.len() > MAX_TEXT_BYTES {
|
||||
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
|
||||
return Err(invalid(format!("`{key}` has a control character")));
|
||||
}
|
||||
Ok(Some(Value::String(s)))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` takes text"))),
|
||||
};
|
||||
/// `value` as `key` may hold it in `owner`'s metadata (`None`: remove the key), or why it can't.
|
||||
fn check(owner: Owner, key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if let Some(k) = self::key(key).filter(|k| k.scope.covers(owner)) {
|
||||
return (k.check)(key, value);
|
||||
}
|
||||
if is_namespaced(key) {
|
||||
return match value {
|
||||
@@ -193,13 +281,15 @@ fn check(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
}
|
||||
let shown: String = key.chars().take(64).collect();
|
||||
Err(invalid(format!(
|
||||
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
|
||||
WELL_KNOWN.join(", ")
|
||||
"`{shown}` is not a {}: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
|
||||
owner.names().0,
|
||||
key_list(owner)
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Owner::{Component, Design};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -216,30 +306,30 @@ mod tests {
|
||||
#[test]
|
||||
fn set_checks_keys_kinds_and_sizes() {
|
||||
let mut m = Metadata::default();
|
||||
assert!(m.set("author", json!("Jane")).unwrap());
|
||||
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
|
||||
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
|
||||
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
|
||||
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
|
||||
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
|
||||
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
|
||||
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
|
||||
assert!(m.set("", json!({})).is_err());
|
||||
assert!(m.set(Design, "author", json!("Jane")).unwrap());
|
||||
assert!(!m.set(Design, "author", json!("Jane")).unwrap(), "the same value is no change");
|
||||
assert!(m.set(Design, "com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
|
||||
assert!(m.set(Design, "author", json!(42)).is_err(), "well-known keys take text");
|
||||
assert!(m.set(Design, "author", json!("a\u{7}b")).is_err(), "no control characters");
|
||||
assert!(m.set(Design, "description", json!("two\nlines\tand a tab")).unwrap());
|
||||
assert!(m.set(Design, "com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
|
||||
assert!(m.set(Design, "colour", json!("red")).is_err(), "unprefixed keys are reserved");
|
||||
assert!(m.set(Design, "", json!({})).is_err());
|
||||
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
|
||||
assert!(m.set(bad, json!({})).is_err(), "{bad}");
|
||||
assert!(m.set(Design, bad, json!({})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
|
||||
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
|
||||
assert!(m.set(Design, "author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
|
||||
assert!(m.set(Design, "org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
|
||||
let mut deep = json!({});
|
||||
for _ in 0..MAX_VALUE_DEPTH {
|
||||
deep = json!({ "a": deep });
|
||||
}
|
||||
assert!(m.set("org.deep", deep).is_err());
|
||||
assert!(m.set(Design, "org.deep", deep).is_err());
|
||||
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
|
||||
// Null and empty text remove.
|
||||
assert!(m.set("author", json!("")).unwrap());
|
||||
assert!(m.set("com.acme.tool", Value::Null).unwrap());
|
||||
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
|
||||
assert!(m.set(Design, "author", json!("")).unwrap());
|
||||
assert!(m.set(Design, "com.acme.tool", Value::Null).unwrap());
|
||||
assert!(!m.set(Design, "com.acme.tool", Value::Null).unwrap());
|
||||
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
|
||||
}
|
||||
|
||||
@@ -247,17 +337,214 @@ mod tests {
|
||||
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
|
||||
let mut m = Metadata::default();
|
||||
for i in 0..MAX_KEYS {
|
||||
m.set(&format!("org.k{i}"), json!({})).unwrap();
|
||||
m.set(Design, &format!("org.k{i}"), json!({})).unwrap();
|
||||
}
|
||||
assert!(m.set("org.one_more", json!({})).is_err());
|
||||
assert!(m.set(Design, "org.one_more", json!({})).is_err());
|
||||
assert_eq!(m.0.len(), MAX_KEYS);
|
||||
// Each owner's refusal names its own properties.
|
||||
let refusal = |owner| m.clone().set(owner, "org.one_more", json!({})).unwrap_err().to_string();
|
||||
assert_eq!(refusal(Design), "design properties are limited to 256 keys and 256 KiB");
|
||||
assert_eq!(refusal(Component), "part properties are limited to 256 keys and 256 KiB");
|
||||
// A file written past the limits by another tool still opens and can be cut down.
|
||||
let mut big = Metadata::default();
|
||||
for i in 0..MAX_KEYS + 10 {
|
||||
big.0.insert(format!("org.k{i}"), json!({}));
|
||||
}
|
||||
assert!(big.set("org.k0", Value::Null).unwrap());
|
||||
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
|
||||
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
|
||||
assert!(big.set(Design, "org.k0", Value::Null).unwrap());
|
||||
assert!(big.set(Design, "author", json!("Jane")).is_err(), "still over: no new keys");
|
||||
assert!(big.set(Design, "org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_key_is_one_row_in_the_order_the_dialog_shows() {
|
||||
let names: Vec<&str> = KEYS.iter().map(|k| k.name).collect();
|
||||
assert_eq!(names, [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE]);
|
||||
for (i, k) in KEYS.iter().enumerate() {
|
||||
assert!(!names[..i].contains(&k.name), "`{}` has two rows", k.name);
|
||||
assert!(!k.name.is_empty() && !is_namespaced(k.name), "`{}` is unprefixed", k.name);
|
||||
assert!(k.label.starts_with("ui.") && !KEYS[..i].iter().any(|o| o.label == k.label), "`{}`'s label", k.name);
|
||||
assert!(is_well_known(k.name) && key(k.name).is_some_and(|f| std::ptr::eq(f, k)));
|
||||
}
|
||||
for name in ["", "colour", "Author", "author ", "com.acme.tool"] {
|
||||
assert!(!is_well_known(name), "{name:?}");
|
||||
}
|
||||
// Every key SolidCraft took on the design and on a component before the table still is.
|
||||
assert_eq!(key_list(Design), "project, part_number, revision, author, organization, description, license");
|
||||
assert_eq!(key_list(Component), key_list(Design));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bill_of_materials_keys_are_rows_a_component_takes_in_column_order() {
|
||||
assert_eq!(BOM_KEYS, [PART_NUMBER, DESCRIPTION, REVISION], "the bill of materials' columns");
|
||||
for name in BOM_KEYS {
|
||||
assert!(key(name).is_some_and(|k| k.scope.covers(Component)), "`{name}` is a row of KEYS a component takes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_is_taken_only_where_its_scope_covers_and_a_refusal_names_the_owner() {
|
||||
let covered: Vec<bool> =
|
||||
[Scope::Design, Scope::Component, Scope::Both].into_iter().flat_map(|s| [s.covers(Design), s.covers(Component)]).collect();
|
||||
assert_eq!(covered, [true, false, false, true, true, true], "design, component and both, on the design and on a component");
|
||||
for owner in [Design, Component] {
|
||||
for k in KEYS {
|
||||
assert_eq!(Metadata::default().set(owner, k.name, json!("x")).is_ok(), k.scope.covers(owner), "`{}` on {owner:?}", k.name);
|
||||
}
|
||||
assert!(Metadata::default().set(owner, "com.acme.tool", json!({"id": 1})).unwrap(), "another tool's key, on {owner:?}");
|
||||
}
|
||||
let refusal = |owner, key: &str| Metadata::default().set(owner, key, json!("x")).unwrap_err().to_string();
|
||||
assert_eq!(
|
||||
refusal(Design, "colour"),
|
||||
"`colour` is not a design property: SolidCraft's are project, part_number, revision, author, organization, \
|
||||
description, license; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
|
||||
"the design's wording is as it was"
|
||||
);
|
||||
assert_eq!(
|
||||
refusal(Component, "colour"),
|
||||
"`colour` is not a part property: SolidCraft's are project, part_number, revision, author, organization, \
|
||||
description, license; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
);
|
||||
for owner in [Design, Component] {
|
||||
let word = owner.names().0;
|
||||
assert!(refusal(owner, "").starts_with(&format!("`` is not a {word}:")), "the empty key on {owner:?}");
|
||||
assert!(refusal(owner, "Part_Number").starts_with("`Part_Number` is not"), "keys are matched exactly");
|
||||
let long = "k".repeat(MAX_KEY_BYTES * 2);
|
||||
assert!(refusal(owner, &long).starts_with(&format!("`{}` is not a {word}", "k".repeat(64))), "shown cut to 64 characters");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_key_takes_up_to_its_limit_with_line_breaks_and_tabs_only() {
|
||||
for owner in [Design, Component] {
|
||||
let set = |value: Value| Metadata::default().set(owner, AUTHOR, value);
|
||||
assert!(set(json!("x".repeat(MAX_TEXT_BYTES))).unwrap(), "exactly the limit");
|
||||
assert!(set(json!("é".repeat(MAX_TEXT_BYTES / 2))).unwrap(), "bytes, not characters: 4096");
|
||||
let over = set(json!(format!("{}é", "x".repeat(MAX_TEXT_BYTES - 1)))).unwrap_err().to_string();
|
||||
assert_eq!(over, "`author` is longer than 4096 bytes", "one character, two bytes past the limit");
|
||||
assert!(set(json!("a\nb\r\nc\td")).unwrap(), "line breaks and tabs");
|
||||
assert!(set(json!(" ")).unwrap(), "a space is text, kept as it is");
|
||||
for c in ['\u{0}', '\u{7}', '\u{1b}', '\u{7f}', '\u{85}'] {
|
||||
let refused = set(json!(format!("a{c}b"))).unwrap_err().to_string();
|
||||
assert_eq!(refused, "`author` has a control character", "{:?}", c);
|
||||
}
|
||||
for kind in [json!(42), json!(true), json!(["x"]), json!({"text": "x"})] {
|
||||
assert_eq!(set(kind.clone()).unwrap_err().to_string(), "`author` takes text", "{kind}");
|
||||
}
|
||||
// Null and empty text remove the key, and removing a missing one is no change.
|
||||
let mut m = Metadata::default();
|
||||
assert!(m.set(owner, AUTHOR, json!("Jane")).unwrap());
|
||||
assert!(m.set(owner, AUTHOR, json!("")).unwrap() && m.is_empty());
|
||||
assert!(!m.set(owner, AUTHOR, Value::Null).unwrap());
|
||||
}
|
||||
assert_eq!(
|
||||
check_text("property `finish`", &"x".repeat(MAX_TEXT_BYTES + 1)).unwrap_err().to_string(),
|
||||
"property `finish` is longer than 4096 bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every hostile key with every hostile value, on the design and on a component: no panic,
|
||||
/// and a refusal leaves the metadata exactly as it was.
|
||||
#[test]
|
||||
fn hostile_keys_and_values_are_refused_without_a_change() {
|
||||
let long_key = "k".repeat(MAX_KEY_BYTES + 1);
|
||||
let long_tool = format!("com.{}", "k".repeat(MAX_KEY_BYTES));
|
||||
let keys = [
|
||||
"",
|
||||
" ",
|
||||
".",
|
||||
"a.",
|
||||
".a",
|
||||
"a..b",
|
||||
"author",
|
||||
"AUTHOR",
|
||||
" author",
|
||||
"part_number\n",
|
||||
"é",
|
||||
"\u{0}",
|
||||
"com.acme.tool",
|
||||
long_key.as_str(),
|
||||
long_tool.as_str(),
|
||||
];
|
||||
let mut deep = json!({});
|
||||
for _ in 0..MAX_VALUE_DEPTH {
|
||||
deep = json!({ "a": deep });
|
||||
}
|
||||
let values = [
|
||||
Value::Null,
|
||||
json!(""),
|
||||
json!(" "),
|
||||
json!(0),
|
||||
json!(-1.5e308),
|
||||
json!(true),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!("a\u{0}b"),
|
||||
json!("x".repeat(MAX_TEXT_BYTES)),
|
||||
json!("x".repeat(MAX_TEXT_BYTES + 1)),
|
||||
json!({"a": "x".repeat(MAX_VALUE_BYTES)}),
|
||||
deep,
|
||||
];
|
||||
for owner in [Design, Component] {
|
||||
for key in keys {
|
||||
for value in &values {
|
||||
let mut m: Metadata = serde_json::from_value(json!({"author": "Jane", "com.acme.tool": {"id": 7}})).unwrap();
|
||||
let before = m.clone();
|
||||
if m.set(owner, key, value.clone()).is_err() {
|
||||
assert_eq!(m, before, "{owner:?} {key:?} = {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Fields that change no geometry" covers each scope: the design's properties, a
|
||||
/// component's, and its free properties. Each alone, and its undo, is a part-property
|
||||
/// change (nothing to evaluate); anything else beside it is not.
|
||||
#[test]
|
||||
fn only_part_properties_changed_in_each_scope() {
|
||||
let mut before = crate::Document::default();
|
||||
for (id, name) in [(1, "Plate"), (2, "Pin")] {
|
||||
before.components.push(crate::Component { id, name: name.into(), ..Default::default() });
|
||||
}
|
||||
let mut design = before.clone();
|
||||
design.metadata.set(Design, AUTHOR, json!("Jane")).unwrap();
|
||||
let mut part = before.clone();
|
||||
part.components[1].metadata.set(Component, PART_NUMBER, json!("PN-040")).unwrap();
|
||||
let mut free = before.clone();
|
||||
free.components[0].properties.insert("finish".into(), "zinc".into());
|
||||
let mut all = design.clone();
|
||||
all.components.clone_from(&part.components);
|
||||
all.components[0].properties.clone_from(&free.components[0].properties);
|
||||
for (what, after) in [("design", &design), ("component", &part), ("free property", &free), ("all three", &all)] {
|
||||
assert!(after.differs_only_in_part_properties(&before), "{what}");
|
||||
assert!(before.differs_only_in_part_properties(after), "{what}, undone");
|
||||
}
|
||||
assert!(!before.differs_only_in_part_properties(&before.clone()), "no change is not a part-property change");
|
||||
|
||||
let mut units = design.clone();
|
||||
units.units = "in".into();
|
||||
let mut renamed = part.clone();
|
||||
renamed.components[1].name = "Dowel".into();
|
||||
let mut added = free.clone();
|
||||
added.components.push(crate::Component { id: 3, name: "Nut".into(), ..Default::default() });
|
||||
let mut removed = part.clone();
|
||||
removed.components.remove(0);
|
||||
// A part property changed on components that swapped places: the components changed too.
|
||||
let mut swapped = before.clone();
|
||||
swapped.components.reverse();
|
||||
swapped.components[0].metadata.set(Component, PART_NUMBER, json!("PN-040")).unwrap();
|
||||
let mut next_id = design.clone();
|
||||
next_id.next_id += 1;
|
||||
for (what, after) in [
|
||||
("units", &units),
|
||||
("a component's name", &renamed),
|
||||
("a new component", &added),
|
||||
("a removed component", &removed),
|
||||
("swapped components", &swapped),
|
||||
("next id", &next_id),
|
||||
] {
|
||||
assert!(!after.differs_only_in_part_properties(&before), "{what}");
|
||||
assert!(!before.differs_only_in_part_properties(after), "{what}, undone");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +618,7 @@ impl Model {
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
let mut model = Model { results, param_errors, step_digests, frame_bodies, ..Model::default() };
|
||||
let mut model = Model { results, param_errors, step_digests, context: ctx, frame_bodies, ..Model::default() };
|
||||
let unkept = model.results.iter().filter(|r| r.unkept).count();
|
||||
if unkept > 0 {
|
||||
// The features whose state the file did not keep, each evaluated again from the state
|
||||
|
||||
@@ -696,7 +696,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
}
|
||||
FeatureKind::Revolve { sketch, axis, .. } => {
|
||||
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
|
||||
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
|
||||
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
|
||||
return format!("F{fid}:face:{fi}");
|
||||
};
|
||||
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
|
||||
|
||||
@@ -78,6 +78,7 @@ impl FeatureKind {
|
||||
inputs.extend(placement);
|
||||
if let FeatureKind::Sketch { sketch, .. } = self {
|
||||
inputs.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()));
|
||||
inputs.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values().map(String::as_str)));
|
||||
}
|
||||
inputs
|
||||
}
|
||||
@@ -233,6 +234,22 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// A measured length (mm) written as a value in the design's units, with its unit: `1 in`
|
||||
/// for 25.4 mm in an inch design. Kept to the micrometre, as a millimetre value would be, so
|
||||
/// a dimension that restates the geometry moves nothing. Returns (unit, expression).
|
||||
pub fn design_length(&self, mm: f64) -> (String, String) {
|
||||
let u = self.units.trim();
|
||||
let per_unit = match expr::unit_info(u) {
|
||||
Some((Kind::Length, s)) if u != "mm" && s.is_finite() && s > 0.0 => s,
|
||||
_ => return ("mm".into(), format!("{} mm", (mm * 1e6).round() / 1e6)),
|
||||
};
|
||||
let decimals = (-(1e-6 / per_unit).log10()).ceil().clamp(0.0, 12.0) as usize;
|
||||
let text = format!("{:.*}", decimals, mm / per_unit);
|
||||
let text = if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { &text };
|
||||
let text = if text == "-0" { "0" } else { text };
|
||||
(u.into(), format!("{text} {u}"))
|
||||
}
|
||||
|
||||
/// Every length input of every feature as the geometry reads it (mm; a unit-less result is
|
||||
/// in the design's units), for checking that an edit moves nothing.
|
||||
pub fn length_inputs(&self) -> Vec<Option<f64>> {
|
||||
@@ -738,7 +755,9 @@ impl Document {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: format!("{} Quantity", pattern.id) });
|
||||
}
|
||||
}
|
||||
for (what, e) in sketch.text_exprs() {
|
||||
for (what, e) in
|
||||
sketch.text_exprs().into_iter().chain(sketch.patterns.iter().flat_map(|p| p.expressions.iter().map(|(k, e)| (k.as_str(), e))))
|
||||
{
|
||||
if expr::references(e).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
|
||||
}
|
||||
@@ -857,7 +876,7 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.patterns.iter_mut().filter_map(|p| p.count_expression.as_mut()) {
|
||||
for e in sketch.patterns.iter_mut().flat_map(|p| p.count_expression.iter_mut().chain(p.expressions.values_mut())) {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
@@ -964,6 +983,7 @@ impl Document {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
|
||||
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
|
||||
@@ -1007,6 +1027,7 @@ impl Document {
|
||||
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
|
||||
hit |= sketch.patterns.iter().flat_map(|p| p.expressions.values()).any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
hit |= sketch.text_word_exprs().iter().any(|e| crate::text_params::references(e).iter().any(|r| affected.contains(r)));
|
||||
}
|
||||
@@ -1083,3 +1104,43 @@ pub(crate) fn numbers_after<'a>(base: &str, names: impl Iterator<Item = &'a str>
|
||||
.filter_map(|digits| digits.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod design_length_tests {
|
||||
use crate::Document;
|
||||
|
||||
fn written(units: &str, mm: f64) -> (String, String) {
|
||||
let mut doc = Document::new("Units");
|
||||
doc.units = units.into();
|
||||
doc.design_length(mm)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_measured_length_is_written_in_the_design_units() {
|
||||
let w = |u: &str, e: &str| (u.to_string(), e.to_string());
|
||||
assert_eq!(written("in", 25.4), w("in", "1 in"));
|
||||
assert_eq!(written("in", 12.7), w("in", "0.5 in"));
|
||||
assert_eq!(written("in", 10.0), w("in", "0.39370079 in"));
|
||||
assert_eq!(written("cm", 12.34), w("cm", "1.234 cm"));
|
||||
assert_eq!(written("mm", 12.3456789), w("mm", "12.345679 mm"));
|
||||
assert_eq!(written("in", -25.4), w("in", "-1 in"));
|
||||
assert_eq!(written("in", -1e-12), w("in", "0 in"));
|
||||
// Unknown or non-length units keep millimetres.
|
||||
assert_eq!(written("parsec", 5.0), w("mm", "5 mm"));
|
||||
assert_eq!(written("deg", 5.0), w("mm", "5 mm"));
|
||||
assert_eq!(written("", 5.0), w("mm", "5 mm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_value_reads_back_within_a_micrometre() {
|
||||
for units in ["mm", "cm", "m", "in", "ft"] {
|
||||
let mut doc = Document::new("Units");
|
||||
doc.units = units.into();
|
||||
for mm in [0.001, 1.0, 12.345_678_9, 25.4, 999.999, 12_345.678] {
|
||||
let (_, e) = doc.design_length(mm);
|
||||
let back = doc.eval(&e, crate::expr::Kind::Length).unwrap();
|
||||
assert!((back - mm).abs() <= 1e-6, "{units} {mm}: {e} reads {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@ pub fn capture_profile_selection(ss: &SolvedSketch, indices: &[usize]) -> Result
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::{Plane, Vec2};
|
||||
use solidcraft_sketch::{Sketch, find_profiles, solve};
|
||||
|
||||
@@ -518,6 +519,25 @@ mod tests {
|
||||
assert!(profile_selection_indices(&ss, &ProfileSel::Indices { indices: vec![1] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_source_selection_loads_in_any_curve_order() {
|
||||
let ss = solved_square(&[]);
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let canonical = ProfileSel::WholeBoundaries {
|
||||
profiles: vec![WholeProfileBoundary { outer: ["a", "b", "c", "d"].map(str::to_string).to_vec(), holes: Vec::new() }],
|
||||
};
|
||||
assert_eq!(profile_selection_indices(&ss, &canonical).unwrap(), vec![0]);
|
||||
// The same loop written from another corner, or the other way round, names the same
|
||||
// region: a design saved that way opens instead of being refused as damaged (#6499).
|
||||
for order in [["b", "c", "d", "a"], ["d", "c", "b", "a"], ["c", "d", "a", "b"]] {
|
||||
let written = json!({"type":"whole_boundaries","profiles":[{"outer":order,"holes":[]}]});
|
||||
let loaded: ProfileSel = serde_json::from_value(written).unwrap();
|
||||
assert_eq!(loaded, canonical, "the loaded identity is canonical");
|
||||
assert_eq!(profile_selection_indices(&ss, &loaded).unwrap(), vec![0]);
|
||||
assert_eq!(crate::select_profiles(&ss, &loaded).unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn solved_square(extra: &[(&str, [f64; 4])]) -> SolvedSketch {
|
||||
let mut sketch = Sketch::new();
|
||||
let corners = [Vec2::new(0., 0.), Vec2::new(10., 0.), Vec2::new(10., 10.), Vec2::new(0., 10.)];
|
||||
|
||||
@@ -1046,12 +1046,29 @@ fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
|
||||
|
||||
/// Section of a mesh (or one of its faces) with the sketch plane.
|
||||
pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeom> {
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
let (segs, exact) = section_segments(plane, m, face);
|
||||
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
|
||||
let loose = size * 2e-3;
|
||||
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
|
||||
}
|
||||
|
||||
/// The segments where the plane crosses the mesh's triangles (in plane coordinates, each with
|
||||
/// its B-rep face), and the crossings that lie exactly on the surface (on a ruling).
|
||||
fn section_segments(plane: &Plane, m: &Mesh, face: Option<u32>) -> (Vec<(Vec2, Vec2, u32)>, Vec<Vec2>) {
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
let q = size * 1e-7;
|
||||
let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
|
||||
// Surface normal at each mesh vertex where the surface is smooth (None at creases), over
|
||||
// all faces and per face.
|
||||
type Key = (i64, i64, i64);
|
||||
// A vertex this close to the plane is on it, and lies on the same side for every edge it
|
||||
// ends (so a plane through a vertex, or on a face, cuts every edge there alike): below when
|
||||
// the mesh lies on or above the plane (the cut at its bottom face), else above (at its top
|
||||
// face, through an apex). An edge with an end on the plane crosses it at that end.
|
||||
let snap = size * 1e-9;
|
||||
let zero_above = m.positions.iter().any(|p| plane.height(*p) < -snap);
|
||||
let above = |h: f64| if h.abs() <= snap { zero_above } else { h > 0.0 };
|
||||
let mut normals: BTreeMap<Key, Option<Vec3>> = BTreeMap::new();
|
||||
let mut face_normals: BTreeMap<(u32, Key), Option<Vec3>> = BTreeMap::new();
|
||||
// Faces on either side of each mesh edge.
|
||||
@@ -1084,14 +1101,25 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
let crossing = |a: Vec3, b: Vec3| -> Option<Vec3> {
|
||||
let (a, b) = if key(a) <= key(b) { (a, b) } else { (b, a) };
|
||||
let (ha, hb) = (plane.height(a), plane.height(b));
|
||||
if (ha > 0.0) == (hb > 0.0) {
|
||||
if above(ha) == above(hb) {
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = [(a, ha), (b, hb)].into_iter().find(|(_, h)| h.abs() <= snap).map(|(p, _)| p) {
|
||||
exact.borrow_mut().push(plane.to_local(p));
|
||||
return Some(p);
|
||||
}
|
||||
let d = b - a;
|
||||
// An edge inside one face takes that face's normals (its ends may lie on the face's
|
||||
// boundary, a crease for the whole mesh); an edge between faces, the merged ones, so
|
||||
// both faces agree on the crossing.
|
||||
let inside = edge_faces.get(&(key(a), key(b))).and_then(|fs| fs.first().filter(|f| fs.len() == 2 && fs.iter().all(|x| x == *f)).copied());
|
||||
let faces = edge_faces.get(&(key(a), key(b)));
|
||||
let inside = faces.and_then(|fs| fs.first().filter(|f| fs.len() == 2 && fs.iter().all(|x| x == *f)).copied());
|
||||
// An edge where a curved face meets another (a cylinder's rim on its cap) lies on the
|
||||
// curved face: its normals there put the crossing on the curve, not on the chord.
|
||||
let face_normal = |f: u32, p: Vec3| face_normals.get(&(f, key(p))).copied().flatten();
|
||||
let curved =
|
||||
|| faces?.iter().copied().find(|f| matches!((face_normal(*f, a), face_normal(*f, b)), (Some(x), Some(y)) if x.dot(y) < 1.0 - 1e-12));
|
||||
let inside = inside.or_else(curved);
|
||||
let normal = |p: Vec3| match inside {
|
||||
Some(f) => face_normals.get(&(f, key(p))).copied().flatten(),
|
||||
None => normals.get(&key(p)).copied().flatten(),
|
||||
@@ -1136,7 +1164,7 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
}
|
||||
Some(h(0.5 * (lo + hi)))
|
||||
};
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
let mut segs: Vec<(Vec2, Vec2, u32)> = Vec::new();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if face.is_some_and(|x| x != *f) {
|
||||
continue;
|
||||
@@ -1152,13 +1180,11 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
if let [a, b] = pts[..] {
|
||||
let (a, b) = (plane.to_local(a), plane.to_local(b));
|
||||
if a.dist(b) > size * 1e-9 {
|
||||
segs.push((a, b));
|
||||
segs.push((a, b, *f));
|
||||
}
|
||||
}
|
||||
}
|
||||
let exact = exact.into_inner();
|
||||
let loose = size * 2e-3;
|
||||
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
|
||||
(segs, exact.into_inner())
|
||||
}
|
||||
|
||||
/// Re-fit a circle or arc through the exact section points near it, when there are enough.
|
||||
|
||||
@@ -103,6 +103,40 @@ fn set_back_reports_restored_features() {
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
/// Setting a parameter back restores every feature on a design whose states are large next to
|
||||
/// the cache's budget: each cached state shares the bodies it did not change with the states
|
||||
/// before it, so it is charged only for what it made, not for the whole model again.
|
||||
#[test]
|
||||
fn set_back_restores_everything_when_states_share_bodies() {
|
||||
let mut doc = Document::new("blocks");
|
||||
doc.set_param("h", "10 mm", Some("mm"), None).unwrap();
|
||||
for i in 0..30 {
|
||||
let height = if i == 0 { "h" } else { "10" };
|
||||
doc.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(20.0 * i as f64, 0.0, 0.0)),
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: height.into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
// Room for a few whole models: enough for both timelines' new bodies, not for every state
|
||||
// charged as a whole model.
|
||||
m.cache.lock().unwrap().budget = 3 * m.state().retained_bytes();
|
||||
doc.set_param("h", "12 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 30);
|
||||
doc.set_param("h", "10 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 30));
|
||||
}
|
||||
|
||||
/// #1989: what an evaluation ahead on a copy of the model computed is kept for the run that
|
||||
/// follows on the model itself, which finds it there even once the evaluation cache has dropped
|
||||
/// it (the cache keeps only a few states of a design with large bodies). Copies keep nothing.
|
||||
@@ -125,6 +159,97 @@ fn results_kept_ahead_outlive_the_cache() {
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Rolling the marker back must not discard unchanged results just because the bounded cache
|
||||
/// evicted them. Opening an earlier feature and returning to the end otherwise rebuilds it all.
|
||||
#[test]
|
||||
fn rolling_the_marker_back_keeps_results_after_cache_eviction() {
|
||||
for reopen in [false, true] {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
if reopen {
|
||||
m = Model::adopt(&doc, &m.saved(&doc).unwrap()).unwrap();
|
||||
}
|
||||
let finished = m.state();
|
||||
let computed = m.recomputed;
|
||||
// Simulate eviction by previews or the cache's byte limit, without a large/slow fixture.
|
||||
*m.cache.lock().unwrap() = Default::default();
|
||||
for marker in [Some(1), Some(0), Some(1), None] {
|
||||
doc.marker = marker;
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.recomputed, computed, "reopen {reopen}, marker {marker:?}: unchanged features must be reused");
|
||||
assert_eq!(m.state().bodies.len(), usize::from(marker.is_none()));
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&m.state(), &finished), "return to the original evaluated state");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_a_large_unchanged_timeline_forward_reuses_its_geometry() {
|
||||
let mut doc = Document::new("marker cache budget");
|
||||
for i in 0..80 {
|
||||
doc.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new((i % 10) as f64 * 15.0, (i / 10) as f64 * 15.0, 0.0)),
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()));
|
||||
// Exceed the actual byte budget without manually clearing the cache.
|
||||
assert!(m.results.iter().map(|r| r.state.retained_bytes()).sum::<usize>() > 64 << 20);
|
||||
let finished = m.state();
|
||||
doc.marker = Some(1);
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.state().bodies.len(), 1);
|
||||
doc.marker = None;
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 0, "returning to the end must not rebuild unchanged boxes");
|
||||
assert_eq!(m.state().bodies.len(), 80);
|
||||
assert!(std::sync::Arc::ptr_eq(&m.state(), &finished));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolled_results_never_mask_edits_or_disabled_reuse() {
|
||||
for change in ["parameter", "feature", "suppressed", "units", "no_cache"] {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
doc.marker = Some(0);
|
||||
m.evaluate(&doc);
|
||||
*m.cache.lock().unwrap() = Default::default();
|
||||
match change {
|
||||
"parameter" => doc.set_param("width", "50 mm", None, None).unwrap(),
|
||||
"feature" => {
|
||||
let FeatureKind::Extrude { extent, .. } = &mut doc.features[1].kind else { panic!("extrude") };
|
||||
extent.distance = "25".into();
|
||||
}
|
||||
"suppressed" => doc.features[1].suppressed = true,
|
||||
"units" => doc.units = "in".into(),
|
||||
"no_cache" => m.no_cache = true,
|
||||
_ => panic!("unknown edit case"),
|
||||
}
|
||||
doc.marker = None;
|
||||
m.evaluate(&doc);
|
||||
let mut fresh = Model::new();
|
||||
fresh.evaluate(&doc);
|
||||
let volume = |model: &Model| model.state().bodies.iter().map(|b| measure(&b.body).unwrap().volume).sum::<f64>();
|
||||
assert_eq!(m.state().bodies.len(), fresh.state().bodies.len(), "{change}");
|
||||
assert!((volume(&m) - volume(&fresh)).abs() < 1e-6, "{change}: retained geometry must match a fresh evaluation");
|
||||
assert_eq!(m.results.iter().map(|r| &r.error).collect::<Vec<_>>(), fresh.results.iter().map(|r| &r.error).collect::<Vec<_>>(), "{change}");
|
||||
if matches!(change, "parameter" | "feature" | "units" | "no_cache") {
|
||||
assert!(m.last_recomputed > 0, "{change}: changed inputs must rebuild");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
|
||||
+31
-2
@@ -394,11 +394,17 @@ impl PlaneRef {
|
||||
}
|
||||
match self {
|
||||
Self::Construction { name } => f(Ref::Feature(name)),
|
||||
Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs(f, depth + 1),
|
||||
Self::Offset { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs(f, depth + 1),
|
||||
Self::ThroughPoint { base, point } => {
|
||||
base.walk_feature_refs(f, depth + 1);
|
||||
point.walk_feature_refs(f, depth + 1);
|
||||
}
|
||||
Self::AtAngle { base, line, .. } => {
|
||||
base.walk_feature_refs(f, depth + 1);
|
||||
if let Some(g) = line {
|
||||
g.walk_feature_refs(f, depth + 1);
|
||||
}
|
||||
}
|
||||
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
|
||||
a.walk_feature_refs(f, depth + 1);
|
||||
b.walk_feature_refs(f, depth + 1);
|
||||
@@ -419,11 +425,17 @@ impl PlaneRef {
|
||||
}
|
||||
match self {
|
||||
Self::Construction { name } => f(Ref::Feature(name)),
|
||||
Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs_mut(f, depth + 1),
|
||||
Self::Offset { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs_mut(f, depth + 1),
|
||||
Self::ThroughPoint { base, point } => {
|
||||
base.walk_feature_refs_mut(f, depth + 1);
|
||||
point.walk_feature_refs_mut(f, depth + 1);
|
||||
}
|
||||
Self::AtAngle { base, line, .. } => {
|
||||
base.walk_feature_refs_mut(f, depth + 1);
|
||||
if let Some(g) = line {
|
||||
g.walk_feature_refs_mut(f, depth + 1);
|
||||
}
|
||||
}
|
||||
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
|
||||
a.walk_feature_refs_mut(f, depth + 1);
|
||||
b.walk_feature_refs_mut(f, depth + 1);
|
||||
@@ -532,6 +544,23 @@ impl Walk for AxisDef {
|
||||
construct_geometry!();
|
||||
}
|
||||
|
||||
/// A feature's own reference to the model (a revolve's or circular pattern's axis on an edge or
|
||||
/// round face): walked like a construction axis's.
|
||||
impl Walk for GeoRef {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
plane_exprs(vec![self], f)
|
||||
}
|
||||
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
|
||||
plane_exprs_mut(vec![self], f)
|
||||
}
|
||||
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
|
||||
f(Geo::Construct(self))
|
||||
}
|
||||
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
|
||||
f(Geo::Construct(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl Walk for PointDef {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
match self {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! A batch's steps checked before the first one runs (#5853).
|
||||
//!
|
||||
//! A batch runner (the control channel's `engine.batch`, the MCP `batch` tool) runs its steps in
|
||||
//! order and stops at the first failure, going back to the design as it was. A misspelled
|
||||
//! command id in a late step was found only at its turn, after every earlier step had run, and
|
||||
//! then all of that work was undone: a 160-step batch with a typo as its last step ran for a
|
||||
//! minute and changed nothing. [`check`] finds, before anything runs, the first step the batch
|
||||
//! would certainly refuse at its turn, so the runner can refuse the batch unrun:
|
||||
//!
|
||||
//! - a step without a command id;
|
||||
//! - a command id no command has (the error names the closest ids, as at the step's turn);
|
||||
//! - with the session's [`ParamCheck::Strict`], parameters that break the command's typed
|
||||
//! contract (the contract check is the one the step would get; it reads only the parameters).
|
||||
//!
|
||||
//! Anything that depends on the design (a face that isn't there yet, an expression naming a
|
||||
//! parameter an earlier step makes) is left to the step's turn, as are parameters that strict
|
||||
//! checking would not refuse.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::schema::ParamCheck;
|
||||
use crate::{EngineError, find_command};
|
||||
|
||||
/// The first step a batch would refuse at its turn.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Refusal {
|
||||
pub index: usize,
|
||||
pub command: Option<String>,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// The batch reply for a batch refused before its first step: as a failed step's reply,
|
||||
/// with nothing completed, nothing undone and every step not run.
|
||||
pub fn reply(&self, steps: usize) -> Value {
|
||||
json!({
|
||||
"completed": 0,
|
||||
"results": [],
|
||||
"failed": {"index": self.index, "command": self.command, "error": self.error},
|
||||
"undone": 0,
|
||||
"rolled_back": false,
|
||||
"not_run": (0..steps).collect::<Vec<_>>(),
|
||||
"note": "nothing was run: the batch was checked before its first step",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The first of `steps` (`{command | id, params?}`) that would be refused at its turn whatever
|
||||
/// the steps before it do, checked against the command registry and, at `level`
|
||||
/// [`ParamCheck::Strict`], the commands' typed contracts. `host` names ids the runner answers
|
||||
/// itself, outside the registry (the app's `view.pick`).
|
||||
pub fn check(steps: &[Value], level: ParamCheck, host: &[&str]) -> Option<Refusal> {
|
||||
for (index, step) in steps.iter().enumerate() {
|
||||
let Some(id) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else {
|
||||
return Some(Refusal { index, command: None, error: "missing `command`".into() });
|
||||
};
|
||||
if host.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(spec) = find_command(id) else {
|
||||
return Some(Refusal { index, command: Some(id.to_string()), error: EngineError::UnknownCommand(id.to_string()).to_string() });
|
||||
};
|
||||
if level == ParamCheck::Strict
|
||||
&& let Some(contract) = spec.contract
|
||||
&& let Some(first) = contract.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()
|
||||
{
|
||||
return Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn step(command: &str, params: Value) -> Value {
|
||||
json!({"command": command, "params": params})
|
||||
}
|
||||
|
||||
/// 40 circles extruded, as in the issue: 160 good steps.
|
||||
fn circles() -> Vec<Value> {
|
||||
(0..40)
|
||||
.flat_map(|k| {
|
||||
[
|
||||
step("sketch.create", json!({"plane": "XY"})),
|
||||
step("sketch.circle.center", json!({"center": [k * 7, (k % 5) * 3], "radius": 5})),
|
||||
json!({"command": "sketch.finish"}),
|
||||
step("solid.extrude", json!({"distance": 20 + k, "operation": "new"})),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typo_in_the_last_step_is_found_before_the_first_runs() {
|
||||
let mut steps = circles();
|
||||
assert_eq!(check(&steps, ParamCheck::Off, &[]), None, "every step names a command");
|
||||
steps.push(step("solid.extrud", json!({"distance": 5})));
|
||||
let r = check(&steps, ParamCheck::Off, &[]).unwrap();
|
||||
assert_eq!((r.index, r.command.as_deref()), (160, Some("solid.extrud")));
|
||||
assert!(r.error.starts_with("unknown command `solid.extrud`") && r.error.contains("solid.extrude"), "{}", r.error);
|
||||
let reply = r.reply(steps.len());
|
||||
assert_eq!((reply["completed"].clone(), reply["undone"].clone(), reply["rolled_back"].clone()), (json!(0), json!(0), json!(false)));
|
||||
assert_eq!(reply["not_run"].as_array().map(Vec::len), Some(161), "nothing ran");
|
||||
assert_eq!(reply["failed"]["index"], 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_without_a_command_and_the_first_bad_step_are_named() {
|
||||
let steps = vec![step("solid.box", json!({"length": 1, "width": 1, "height": 1})), json!({"params": {}}), step("nope.nope", json!({}))];
|
||||
assert_eq!(check(&steps, ParamCheck::Off, &[]), Some(Refusal { index: 1, command: None, error: "missing `command`".into() }));
|
||||
// `id` is read as `command` is, and an id the runner answers itself passes.
|
||||
let steps = vec![json!({"id": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}), step("view.pick", json!({"x": 1, "y": 1}))];
|
||||
assert_eq!(check(&steps, ParamCheck::Off, &["view.pick"]), None);
|
||||
assert_eq!(check(&steps, ParamCheck::Off, &[]).map(|r| r.index), Some(1), "outside the registry without the runner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_checking_refuses_a_broken_contract_before_the_first_step() {
|
||||
let steps =
|
||||
vec![step("solid.box", json!({"length": 1, "width": 1, "height": 1})), step("solid.box", json!({"lenght": 1, "width": 1, "height": 1}))];
|
||||
// Off and warn run such a step (warn reports it): nothing to refuse up front.
|
||||
assert_eq!(check(&steps, ParamCheck::Off, &[]), None);
|
||||
assert_eq!(check(&steps, ParamCheck::Warn, &[]), None);
|
||||
let r = check(&steps, ParamCheck::Strict, &[]).unwrap();
|
||||
assert_eq!(r.index, 1);
|
||||
assert!(r.error.contains("solid.box") && r.error.contains("lenght"), "{}", r.error);
|
||||
// The same call alone is refused with the same words at its turn.
|
||||
let mut s = crate::Session { param_check: ParamCheck::Strict, ..crate::Session::default() };
|
||||
let at_turn = s.execute("solid.box", &steps[1]["params"]).unwrap_err().to_string();
|
||||
assert_eq!(r.error, at_turn);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,47 @@ fn revolve_cut_round_a_box_corner_does_not_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5543: a grid of 121 holes cut through a plate, four of its circles drawn a second time on
|
||||
/// top of themselves. Each of those was two profiles of one region and so two coincident tools:
|
||||
/// the cut retried with the tool nudged and took fifty times as long. The profiles drawn twice
|
||||
/// are cut once: no retry, and the plate is the one the 121 circles drawn once make.
|
||||
#[test]
|
||||
fn circles_drawn_twice_are_cut_once() {
|
||||
let cut = |repeats: usize| {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [-35, -35], "p1": [35, 35]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 2, "operation": "new"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": "XY", "offset": 2})).unwrap();
|
||||
let mut centres = Vec::new();
|
||||
for i in -7i32..8 {
|
||||
for j in -8i32..9 {
|
||||
let (x, y) = (f64::from(j) * 3.45 + if i % 2 != 0 { 1.725 } else { 0.0 }, f64::from(i) * 3.0);
|
||||
if x.hypot(y) <= 20.7 {
|
||||
centres.push([x, y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(centres.len(), 121);
|
||||
for c in centres.iter().chain(centres.iter().take(repeats)) {
|
||||
s.execute("sketch.circle.center", &json!({"center": c, "radius": 1.15})).unwrap();
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let r = s.execute("solid.extrude", &json!({"operation": "cut", "direction": "negative", "distance": 2, "targets": ["Body1"]})).unwrap();
|
||||
assert!(r["warning"].is_null(), "{}", r["warning"]);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Body1").unwrap().body;
|
||||
(body.volume_at(1e-4).unwrap(), body.face_count())
|
||||
};
|
||||
let (once, faces_once) = cut(0);
|
||||
let (twice, faces_twice) = cut(4);
|
||||
let analytic = 70.0 * 70.0 * 2.0 - 121.0 * std::f64::consts::PI * 1.15 * 1.15 * 2.0;
|
||||
assert!((once - analytic).abs() < 0.2, "{once} vs {analytic}");
|
||||
assert!((twice - once).abs() < 1e-9 * once, "{twice} vs {once}");
|
||||
assert_eq!(faces_twice, faces_once);
|
||||
}
|
||||
|
||||
/// #1985: the STEP export of a small enclosure never finished. The export reads its own file
|
||||
/// back, and meshing the screw hole's seam cylinder never ended: the boundary walk took
|
||||
/// projected angles millions of turns apart. The file is written, reads back cleanly, and holds
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Part properties and the bill of materials. `component.metadata` and
|
||||
//! `component.metadata.set` read and change a component definition's part number, description,
|
||||
//! revision and free properties (shared by every occurrence, saved with the component);
|
||||
//! revision and free properties (shared by every occurrence, saved with the component; the keys
|
||||
//! and their checks are `solidcraft_doc::metadata`'s `KEYS`, the rows a component takes);
|
||||
//! `assembly.bom` counts the placed components of the design, or of one component, and lists
|
||||
//! them as rows, optionally written to a CSV or JSON file. With `group_children`, a part with a
|
||||
//! part number keeps the parts placed inside it (a bolt's washers and nut) in its own row.
|
||||
@@ -8,7 +9,7 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::metadata::{DESCRIPTION, MAX_KEYS, MAX_TEXT_BYTES, PART_NUMBER, REVISION, WELL_KNOWN};
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, MAX_KEYS, Owner, check_text, key_list};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
@@ -21,9 +22,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
|
||||
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
|
||||
removes); values?: {key: value} (SolidCraft's keys project, part_number, revision, author, organization, description, \
|
||||
license, or a reverse-DNS key holding an object); properties?: {name: text, …} (free properties; empty or null removes). \
|
||||
All or nothing, one undo step → component, metadata, properties, changed: [keys]",
|
||||
removes); values?: {key: value} (SolidCraft's keys, as document.metadata's properties lists them, or a reverse-DNS key \
|
||||
holding an object); properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step \
|
||||
→ component, metadata, properties, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("assembly.bom", "Bill of Materials", bom).at("SOLID", "INSPECT").icon("bom").noundo().params(
|
||||
"component?: id or name (default the design); levels?: flat (default: every part at any depth, counted through its \
|
||||
@@ -58,15 +59,22 @@ fn component_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.metadata";
|
||||
let id = component_id(s, p, cmd)?;
|
||||
if id == 0 {
|
||||
let m = &s.doc.metadata;
|
||||
return Ok(json!({"component": 0, "name": s.doc.name, "part_number": m.part_number(), "description": m.description(),
|
||||
"revision": m.revision(), "metadata": m, "properties": {}}));
|
||||
// The root is the design: its own properties, and no free ones.
|
||||
let (name, m, properties) = if id == 0 {
|
||||
(&s.doc.name, &s.doc.metadata, json!({}))
|
||||
} else {
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
(&c.name, &c.metadata, json!(c.properties))
|
||||
};
|
||||
let mut out = Map::new();
|
||||
out.insert("component".into(), json!(id));
|
||||
out.insert("name".into(), json!(name));
|
||||
for k in BOM_KEYS {
|
||||
out.insert(k.to_string(), json!(m.text(k)));
|
||||
}
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
let m = &c.metadata;
|
||||
Ok(json!({"component": id, "name": c.name, "part_number": m.part_number(), "description": m.description(),
|
||||
"revision": m.revision(), "metadata": m, "properties": c.properties}))
|
||||
out.insert("metadata".into(), json!(m));
|
||||
out.insert("properties".into(), properties);
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// A free property as it may be saved (`None`: remove it), or why not.
|
||||
@@ -83,12 +91,8 @@ fn property(name: &str, value: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
Value::Bool(b) => b.to_string(),
|
||||
_ => return Err(bad(cmd, format!("property `{shown}` takes text"))),
|
||||
};
|
||||
if text.len() > MAX_TEXT_BYTES {
|
||||
return Err(bad(cmd, format!("property `{shown}` is longer than {MAX_TEXT_BYTES} bytes")));
|
||||
}
|
||||
if text.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
|
||||
return Err(bad(cmd, format!("property `{shown}` has a control character")));
|
||||
}
|
||||
// The same text rule as SolidCraft's keys.
|
||||
check_text(&format!("property `{shown}`"), &text).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
@@ -107,8 +111,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Object(_)) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
|
||||
Some(_) => return Err(bad(cmd, "`values` must be an object: {key: value, …}")),
|
||||
}
|
||||
for k in [PART_NUMBER, DESCRIPTION, REVISION] {
|
||||
if let Some(v) = p.get(k) {
|
||||
for k in BOM_KEYS {
|
||||
if let Some(v) = p.get(*k) {
|
||||
entries.push((k.to_string(), v.clone()));
|
||||
}
|
||||
}
|
||||
@@ -119,11 +123,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_) => return Err(bad(cmd, "`properties` must be an object: {name: text, …}")),
|
||||
};
|
||||
if entries.is_empty() && props.is_empty() {
|
||||
return Err(bad(cmd, format!("give part_number, description, revision, values or properties ({})", WELL_KNOWN.join(", "))));
|
||||
return Err(bad(cmd, format!("give {}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component))));
|
||||
}
|
||||
let mut changed: Vec<String> = Vec::new();
|
||||
for (k, v) in entries {
|
||||
if metadata.set(&k, v).map_err(|e| bad(cmd, e.to_string()))? && !changed.contains(&k) {
|
||||
if metadata.set(Owner::Component, &k, v).map_err(|e| bad(cmd, e.to_string()))? && !changed.contains(&k) {
|
||||
changed.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,61 @@ fn part_properties_survive_save_and_open_and_undo_in_one_step() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_design_carries_its_part_properties_into_the_bom() {
|
||||
let dir = scratch("insert-design");
|
||||
let source = dir.join("plate.solidcraft");
|
||||
let metadata = json!({
|
||||
"part_number": "PL-6624", "description": "Mounting plate", "revision": "B",
|
||||
"author": "Example author", "license": "CC0-1.0", "com.example.catalog": {"stock": 12}
|
||||
});
|
||||
let mut part = Session::default();
|
||||
run(&mut part, "solid.box", json!({"length": 40, "width": 20, "height": 3}));
|
||||
run(&mut part, "document.metadata.set", json!({"values": metadata}));
|
||||
run(&mut part, "file.save", json!({"path": source}));
|
||||
|
||||
for reuse in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.metadata.set", json!({"values": {"part_number": "ASM-1", "description": "Assembly"}}));
|
||||
let own_metadata = s.doc.metadata.clone();
|
||||
let undo = s.undo.len();
|
||||
let inserted = run(&mut s, "component.insert_design", json!({"path": source, "name": "Plate", "reuse": reuse}));
|
||||
let component = inserted["component"].as_u64().unwrap();
|
||||
assert_eq!(run(&mut s, "component.metadata", json!({"component": component}))["metadata"], metadata);
|
||||
assert_eq!(s.doc.metadata, own_metadata, "the assembly keeps its own properties");
|
||||
let bom = run(&mut s, "assembly.bom", json!({}));
|
||||
assert_eq!(bom["rows"][0]["part_number"], "PL-6624");
|
||||
assert_eq!(bom["rows"][0]["description"], "Mounting plate");
|
||||
assert_eq!(bom["rows"][0]["revision"], "B");
|
||||
assert_eq!(bom["rows"][0]["count"], 1);
|
||||
assert_eq!(s.undo.len(), undo + 1, "insertion including properties is one undo step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.components.is_empty());
|
||||
assert!(s.doc.links.is_empty());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(run(&mut s, "component.metadata", json!({"component": component}))["metadata"], metadata);
|
||||
|
||||
for (file, format) in [("assembly.json", "json"), ("assembly.solidcraft", "container")] {
|
||||
let path = dir.join(file);
|
||||
run(&mut s, "file.save_as", json!({"path": path, "format": format}));
|
||||
let mut back = Session::default();
|
||||
run(&mut back, "doc.open", json!({"path": path}));
|
||||
assert_eq!(run(&mut back, "component.metadata", json!({"component": component}))["metadata"], metadata, "{format}");
|
||||
assert_eq!(run(&mut back, "assembly.bom", json!({}))["rows"], bom["rows"], "{format}");
|
||||
}
|
||||
|
||||
// Reusing the link shares its definition, including locally edited part properties.
|
||||
run(&mut s, "component.metadata.set", json!({"component": component, "description": "Assembly-specific description"}));
|
||||
run(&mut s, "component.insert_design", json!({"path": source, "reuse": true, "translate": [50, 0, 0]}));
|
||||
let bom = run(&mut s, "assembly.bom", json!({}));
|
||||
assert_eq!(bom["rows"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(bom["rows"][0]["count"], 2);
|
||||
assert_eq!(bom["rows"][0]["part_number"], "PL-6624");
|
||||
assert_eq!(bom["rows"][0]["description"], "Assembly-specific description");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_quotes_every_field_and_json_writes_the_rows() {
|
||||
let dir = scratch("export");
|
||||
@@ -203,6 +258,73 @@ fn hostile_part_properties_are_refused_without_a_change() {
|
||||
assert_eq!(run(&mut s, "component.metadata", json!({"component": 0}))["part_number"], "ASM-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_components_refusals_speak_of_part_properties() {
|
||||
let mut s = assembly();
|
||||
let undo = s.undo.len();
|
||||
let refusal = |s: &mut Session, p: Value| s.execute("component.metadata.set", &p).unwrap_err().to_string();
|
||||
let bogus = refusal(&mut s, json!({"component": "Pin", "values": {"bogus": "x"}}));
|
||||
assert!(
|
||||
bogus.ends_with(
|
||||
"`bogus` is not a part property: SolidCraft's are project, part_number, revision, author, organization, description, \
|
||||
license; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
),
|
||||
"{bogus}"
|
||||
);
|
||||
assert!(!bogus.contains("design property"), "{bogus}");
|
||||
assert!(refusal(&mut s, json!({"component": "Pin", "values": {"": "x"}})).contains("`` is not a part property"));
|
||||
// Nothing to change: the same list as before the table.
|
||||
assert!(refusal(&mut s, json!({"component": "Pin"})).ends_with(
|
||||
"give part_number, description, revision, values or properties (project, part_number, revision, author, organization, \
|
||||
description, license)"
|
||||
));
|
||||
// Only the bill of materials' keys are parameters of their own; the others go in `values`.
|
||||
assert!(refusal(&mut s, json!({"component": "Pin", "author": "Jane"})).contains("give part_number, description, revision"));
|
||||
let long = refusal(&mut s, json!({"component": "Pin", "properties": {"finish": "x".repeat(5000)}}));
|
||||
assert!(long.ends_with("property `finish` is longer than 4096 bytes"), "{long}");
|
||||
let bell = refusal(&mut s, json!({"component": "Pin", "properties": {"finish": "a\u{7}"}}));
|
||||
assert!(bell.ends_with("property `finish` has a control character"), "{bell}");
|
||||
assert_eq!(s.undo.len(), undo, "nothing changed");
|
||||
// The design keeps its own words.
|
||||
let design = s.execute("document.metadata.set", &json!({"key": "bogus", "value": "x"})).unwrap_err().to_string();
|
||||
assert!(design.contains("`bogus` is not a design property"), "{design}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_key_a_component_took_before_is_still_taken() {
|
||||
let mut s = assembly();
|
||||
let keys = ["project", "part_number", "revision", "author", "organization", "description", "license"];
|
||||
let mut values: serde_json::Map<String, Value> = keys.iter().map(|k| (k.to_string(), json!(format!("{k} text")))).collect();
|
||||
values.insert("com.acme.shop".into(), json!({"id": 7}));
|
||||
let r = run(&mut s, "component.metadata.set", json!({"component": "Plate", "values": values}));
|
||||
assert_eq!(r["changed"].as_array().map(Vec::len), Some(keys.len() + 1), "{r}");
|
||||
let plate = run(&mut s, "component.metadata", json!({"component": "Plate"}));
|
||||
for k in keys {
|
||||
assert_eq!(plate["metadata"][k], format!("{k} text"), "{k}");
|
||||
}
|
||||
for k in ["part_number", "description", "revision"] {
|
||||
assert_eq!(plate[k], format!("{k} text"), "the bill of materials' field {k}: {plate}");
|
||||
}
|
||||
assert!(plate.get("author").is_none(), "the others stay in metadata: {plate}");
|
||||
// The shortcuts and the root read the same fields.
|
||||
run(&mut s, "component.metadata.set", json!({"component": "Pin", "part_number": "", "description": "Pin", "revision": "D"}));
|
||||
let pin = run(&mut s, "component.metadata", json!({"component": "Pin"}));
|
||||
assert_eq!((&pin["part_number"], &pin["description"], &pin["revision"]), (&Value::Null, &json!("Pin"), &json!("D")), "{pin}");
|
||||
let root = run(&mut s, "component.metadata", json!({"component": 0}));
|
||||
assert_eq!((&root["component"], &root["part_number"], &root["properties"]), (&json!(0), &Value::Null, &json!({})), "{root}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_component_refused_past_its_limits_says_part_properties() {
|
||||
let mut s = assembly();
|
||||
// The pin holds a part number and a description: room for this many more keys.
|
||||
let room = solidcraft_doc::metadata::MAX_KEYS - 2;
|
||||
let values: serde_json::Map<String, Value> = (0..room).map(|i| (format!("org.k{i}"), json!({}))).collect();
|
||||
run(&mut s, "component.metadata.set", json!({"component": "Pin", "values": values}));
|
||||
let over = s.execute("component.metadata.set", &json!({"component": "Pin", "values": {"org.one_more": {}}})).unwrap_err().to_string();
|
||||
assert!(over.ends_with("part properties are limited to 256 keys and 256 KiB"), "{over}");
|
||||
}
|
||||
|
||||
/// Two hex bolts, each with a washer and a nut placed inside it, and a third bolt alone.
|
||||
fn bolts() -> Session {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -367,7 +367,10 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let v = &mut s.visibility;
|
||||
match str_(p, "folder") {
|
||||
Some("bodies" | "all") if visible => v.hidden_occurrences.clear(),
|
||||
Some("bodies" | "all") if visible => {
|
||||
v.hidden_bodies.clear();
|
||||
v.hidden_occurrences.clear();
|
||||
}
|
||||
// Showing a whole folder shows the folder too.
|
||||
Some("origin") if visible => v.origin_hidden = Some(false),
|
||||
_ => {}
|
||||
@@ -760,6 +763,7 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if name == old {
|
||||
return Ok(json!({"body": old, "name": name}));
|
||||
}
|
||||
let previous = s.doc.clone();
|
||||
let st = s.model.state();
|
||||
let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?;
|
||||
if st.body(name).is_some() {
|
||||
@@ -819,7 +823,7 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*names = pins;
|
||||
f.body_names.clear();
|
||||
} else {
|
||||
f.body_names.clone_from(made);
|
||||
f.pin_body_names(made);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -828,15 +832,18 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*names = pins;
|
||||
f.body_names.clear();
|
||||
} else {
|
||||
while f.body_names.len() < siblings.len().max(k + 1) {
|
||||
let i = f.body_names.len();
|
||||
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
|
||||
// An Import keeps its names by the file's body index, its skipped bodies' too (#2175).
|
||||
let slots = f.body_name_slots(siblings.len().max(k + 1));
|
||||
if let Some(end) = slots.last().map(|s| s + 1).filter(|end| f.body_names.len() < *end) {
|
||||
f.body_names.resize(end, String::new());
|
||||
}
|
||||
for (slot, current) in f.body_names.iter_mut().zip(&siblings) {
|
||||
slot.clone_from(current);
|
||||
for (slot, current) in slots.iter().zip(&siblings) {
|
||||
if let Some(stored) = f.body_names.get_mut(*slot) {
|
||||
stored.clone_from(current);
|
||||
}
|
||||
}
|
||||
if let Some(slot) = f.body_names.get_mut(k) {
|
||||
*slot = name.to_string();
|
||||
if let Some(stored) = slots.get(k).and_then(|slot| f.body_names.get_mut(*slot)) {
|
||||
*stored = name.to_string();
|
||||
}
|
||||
}
|
||||
// Later features refer to bodies by name.
|
||||
@@ -875,10 +882,10 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, e));
|
||||
}
|
||||
d.rename_body_keys(&old, name);
|
||||
let v = &mut s.visibility;
|
||||
for b in v.hidden_bodies.iter_mut().chain(v.locked_bodies.iter_mut()).filter(|b| **b == old) {
|
||||
for b in s.visibility.locked_bodies.iter_mut().filter(|b| **b == old) {
|
||||
*b = name.to_string();
|
||||
}
|
||||
s.remember_body_rename(&previous, &old, name);
|
||||
Ok(json!({"body": old, "name": name}))
|
||||
}
|
||||
|
||||
@@ -1008,6 +1015,88 @@ mod tests {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_rename_preserves_plastic_rules_and_current_visibility_through_history() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
|
||||
run(&mut s, "plastic.manage_rules", json!({"name": "Housing", "draft": "3 deg"}));
|
||||
run(&mut s, "plastic.assign_rule", json!({"bodies": ["Plate"], "rule": "Housing"}));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Plate"], "visible": false}));
|
||||
run(&mut s, "body.rename", json!({"body": "Plate", "name": "Base"}));
|
||||
assert_eq!(run(&mut s, "plastic.rules", json!({}))["assigned"], json!({"Base": "Housing"}));
|
||||
assert!(!s.body_visible("Base"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "plastic.rules", json!({}))["assigned"], json!({"Plate": "Housing"}));
|
||||
assert!(!s.body_visible("Plate"), "undo must keep the body hidden");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(!s.body_visible("Base"));
|
||||
// Showing a body is its own undo step; the rename keeps the correct hidden name on
|
||||
// either side of that step, and neither operation loses the assigned rule.
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Base"], "visible": true}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["Base"]);
|
||||
assert!(!s.body_visible("Base"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["Plate"]);
|
||||
assert!(!s.body_visible("Plate"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(!s.body_visible("Base"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.body_visible("Base"));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Base"], "visible": false}));
|
||||
let mut saved = (*s.doc).clone();
|
||||
saved.visibility = s.visibility.clone();
|
||||
let reopened = Session::new(serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap());
|
||||
assert_eq!(reopened.doc.plastic.assigned.get("Base").map(String::as_str), Some("Housing"));
|
||||
assert!(!reopened.body_visible("Base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_rename_undo_keeps_hidden_bodies_with_downstream_users_hidden() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Peg"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Peg"], "translate": [0, 0, 5]}));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Peg"], "visible": false}));
|
||||
run(&mut s, "body.rename", json!({"body": "Peg", "name": "Stud"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.body_visible("Peg"));
|
||||
assert_eq!(s.visibility.hidden_bodies, ["Peg"]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(!s.body_visible("Stud"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_all_bodies_clears_stale_hidden_names() {
|
||||
for folder in ["bodies", "all"] {
|
||||
let mut s = Session::default();
|
||||
s.visibility.hidden_bodies.push("Old body name".into());
|
||||
run(&mut s, "browser.visibility", json!({"folder": folder, "visible": true}));
|
||||
assert!(s.visibility.hidden_bodies.is_empty(), "{folder}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_body_rename_keeps_the_latest_visibility_and_undo_mapping() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Peg"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Peg"], "translate": [0, 0, 5]}));
|
||||
let mut computed = s.scratch();
|
||||
run(&mut computed, "body.rename", json!({"body": "Peg", "name": "Stud"}));
|
||||
// The user hides the body while the rename is being evaluated.
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Peg"], "visible": false}));
|
||||
s.accept_computed(computed);
|
||||
assert!(!s.body_visible("Stud"));
|
||||
assert_eq!(s.visibility.hidden_bodies, ["Stud"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.body_visible("Peg"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.body_visible("Peg"), "the concurrent hide remains its own undo step");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(!s.body_visible("Peg"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(!s.body_visible("Stud"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_body_keeps_its_users() {
|
||||
let mut s = Session::default();
|
||||
@@ -1206,8 +1295,9 @@ mod tests {
|
||||
assert!(["Body1", "Body2", "Body3", "Body4", "Body5"].iter().all(|n| before.contains(&n.to_string())), "{before:?}");
|
||||
run(&mut s, "solid.combine", json!({"target": "Body3", "tools": ["Body5"], "operation": "join", "name": "Joined"}));
|
||||
// Renaming a body that already has a name of its own stores nothing elsewhere.
|
||||
let stored = s.doc.find_feature("P2").unwrap().body_names.clone();
|
||||
run(&mut s, "body.rename", json!({"body": "Second", "name": "Row"}));
|
||||
assert!(s.doc.find_feature("P2").unwrap().body_names.is_empty());
|
||||
assert_eq!(s.doc.find_feature("P2").unwrap().body_names, stored);
|
||||
run(&mut s, "body.rename", json!({"body": "Body1", "name": "FirstCopyA"}));
|
||||
run(&mut s, "body.rename", json!({"body": "Body2", "name": "FirstCopyB"}));
|
||||
assert!(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user