mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
Compare commits
132 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 | |||
| 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 |
@@ -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
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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,231 +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. What to work on: [ROADMAP.md](ROADMAP.md) (status and milestones), "Where help is wanted most"
|
||||
in CONTRIBUTING.md, and the open issues labelled `help wanted`, `good first issue` and
|
||||
`needs-triage`. Check what is already open first (below).
|
||||
2. How the code fits together: [docs/architecture.md](docs/architecture.md) (the layer table) and
|
||||
"How the code is laid out" in CONTRIBUTING.md. Kernel work is paused ("Kernel work is paused"
|
||||
in CONTRIBUTING.md).
|
||||
3. What we may depend on: the licence rules under Non-negotiables below, `deny.toml`, and
|
||||
`cargo xtask licences --check`. Only `crates/kernel` may use `truck`.
|
||||
4. Work on your own: when the code, the docs and these rules settle a question, act on it. Ask on
|
||||
the issue or the PR only when the answer changes what users see or what the change covers.
|
||||
## 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.
|
||||
|
||||
Maintainers may keep local notes in `plan/` (gitignored); nothing in this file depends on them.
|
||||
|
||||
## 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`) + input and output
|
||||
schemas (a contract and a reply type in `crates/engine/src/schema/`, registered with
|
||||
`checked!`, `.contract` and `.output`; a command should have them, and a test warns
|
||||
without them (it will become required); `schema/unconverted.txt` only shrinks; how: docs/book/src/dev/schemas.md) + 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. The gate reads the working tree,
|
||||
so do not switch branches while it runs: it compiles the other branch's code and fails on a
|
||||
change that is not in the PR. Run it in the background and wait for its log.
|
||||
- **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
|
||||
`docs/roadmap-detail.md` (committed) tracks status, milestones and estimates; `ROADMAP.md` is the short user-facing roadmap. 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 |
|
||||
@@ -519,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
|
||||
|
||||
|
||||
+8
-4
@@ -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.
|
||||
|
||||
@@ -192,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
|
||||
|
||||
Generated
+21
-17
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4240,11 +4240,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4265,7 +4265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4282,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4302,7 +4302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4311,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4331,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4355,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4375,7 +4375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4384,7 +4384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4398,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4409,14 +4409,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.23.1"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4445,12 +4445,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.23.1"
|
||||
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.1"
|
||||
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.1"
|
||||
version = "0.24.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -772,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,
|
||||
@@ -841,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());
|
||||
@@ -870,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]
|
||||
|
||||
+71
-11
@@ -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);
|
||||
@@ -633,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"));
|
||||
@@ -697,6 +718,12 @@ 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 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 };
|
||||
@@ -775,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",
|
||||
@@ -805,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);
|
||||
}
|
||||
@@ -906,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),
|
||||
@@ -932,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,
|
||||
}
|
||||
}
|
||||
@@ -958,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;
|
||||
@@ -979,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -754,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",
|
||||
@@ -919,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",
|
||||
@@ -1041,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",
|
||||
@@ -1178,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",
|
||||
@@ -1326,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",
|
||||
@@ -1338,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.",
|
||||
@@ -1347,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",
|
||||
@@ -1448,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",
|
||||
@@ -1472,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.",
|
||||
@@ -1498,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",
|
||||
@@ -2185,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",
|
||||
@@ -2387,6 +2446,7 @@
|
||||
"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",
|
||||
@@ -2489,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.",
|
||||
@@ -2535,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",
|
||||
@@ -2797,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",
|
||||
@@ -2883,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",
|
||||
@@ -2932,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",
|
||||
@@ -3185,6 +3272,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+89
-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": "ルールドサーフェス",
|
||||
@@ -754,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": "チェックポイントを復元",
|
||||
@@ -919,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点指定円弧",
|
||||
@@ -1041,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のガンマ空間で色の値を表示",
|
||||
@@ -1178,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": "デザイン",
|
||||
@@ -1326,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": "最後へ",
|
||||
@@ -1338,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 を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
|
||||
@@ -1347,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": "穴なし",
|
||||
@@ -1448,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": "既定",
|
||||
@@ -1472,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": "スケッチのスナップとクリックによる選択。",
|
||||
@@ -1498,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": "ホイールとトラックパッド",
|
||||
@@ -2185,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": "フィーチャーをフォルダへ移動",
|
||||
@@ -2387,6 +2446,7 @@
|
||||
"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": "確認",
|
||||
@@ -2489,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 経由、またはこのコンピューター宛てにのみ送信されます。",
|
||||
@@ -2535,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": "アプリケーションバーにアシスタントボタンを表示",
|
||||
@@ -2797,6 +2876,7 @@
|
||||
"ui.heading_torus": "トーラス",
|
||||
"ui.heading_utilities": "ユーティリティ",
|
||||
"ui.heading_utility": "ユーティリティ",
|
||||
"ui.heading_weldments": "溶接構造",
|
||||
"ui.help": "ヘルプ",
|
||||
"ui.hide": "非表示",
|
||||
"ui.hideAxes": "軸を非表示",
|
||||
@@ -2883,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": "新しいスケッチグループ",
|
||||
@@ -2932,6 +3017,8 @@
|
||||
"ui.pinToToolbar": "ツールバーに固定",
|
||||
"ui.pipe": "パイプ",
|
||||
"ui.placement": "配置",
|
||||
"ui.placement_handles": "配置ハンドル",
|
||||
"ui.placement_handles_tip": "レスト、ボス、スナップ フィットのプレビューに表示されるハンドル: マーカーをドラッグしてモデル上を移動し、リングで回転します",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.plugins": "プラグイン",
|
||||
@@ -3185,6 +3272,7 @@
|
||||
"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 で開く",
|
||||
|
||||
@@ -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": "축 숨기기",
|
||||
|
||||
@@ -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": "隱藏軸",
|
||||
|
||||
+89
-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": "直纹曲面",
|
||||
@@ -754,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": "恢复检查点",
|
||||
@@ -919,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": "三点圆弧",
|
||||
@@ -1041,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 伽马空间显示颜色值",
|
||||
@@ -1178,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": "设计",
|
||||
@@ -1326,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": "到结尾",
|
||||
@@ -1338,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 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
|
||||
@@ -1347,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": "无孔",
|
||||
@@ -1448,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": "默认",
|
||||
@@ -1472,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": "草图如何捕捉,以及单击如何选择。",
|
||||
@@ -1498,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": "滚轮与触控板",
|
||||
@@ -2185,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": "将特征移至文件夹",
|
||||
@@ -2387,6 +2446,7 @@
|
||||
"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": "询问",
|
||||
@@ -2489,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 发送,或发送到本机。",
|
||||
@@ -2535,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": "在工具栏显示按钮",
|
||||
@@ -2797,6 +2876,7 @@
|
||||
"ui.heading_torus": "圆环体",
|
||||
"ui.heading_utilities": "工具",
|
||||
"ui.heading_utility": "实用工具",
|
||||
"ui.heading_weldments": "焊件",
|
||||
"ui.help": "帮助",
|
||||
"ui.hide": "隐藏",
|
||||
"ui.hideAxes": "隐藏轴",
|
||||
@@ -2883,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": "新建草图组",
|
||||
@@ -2932,6 +3017,8 @@
|
||||
"ui.pinToToolbar": "固定到工具栏",
|
||||
"ui.pipe": "管道",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "垫块、凸台或卡扣预览上的手柄:拖动标记在模型上移动,用转环旋转",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
@@ -3185,6 +3272,7 @@
|
||||
"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 中打开",
|
||||
|
||||
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)?;
|
||||
|
||||
+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(),
|
||||
}
|
||||
}
|
||||
|
||||
+145
-21
@@ -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>,
|
||||
},
|
||||
@@ -2121,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")]
|
||||
@@ -2456,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()
|
||||
}
|
||||
@@ -2505,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 {
|
||||
@@ -2834,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);
|
||||
}
|
||||
@@ -2899,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;
|
||||
@@ -2949,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,
|
||||
@@ -3612,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)),
|
||||
|
||||
+363
-84
@@ -348,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`.
|
||||
@@ -422,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,
|
||||
@@ -441,6 +451,7 @@ fn thread_on_impl(
|
||||
length: Option<f64>,
|
||||
tapped: bool,
|
||||
turned: bool,
|
||||
handedness: crate::ThreadHandedness,
|
||||
) -> Result<ThreadInfo> {
|
||||
let (body, cyl) = st
|
||||
.bodies
|
||||
@@ -501,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,
|
||||
@@ -591,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
|
||||
@@ -956,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,
|
||||
}
|
||||
|
||||
@@ -997,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)
|
||||
{
|
||||
@@ -1099,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)
|
||||
}
|
||||
@@ -1116,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());
|
||||
@@ -1127,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)
|
||||
}
|
||||
@@ -1183,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>>)>,
|
||||
@@ -1442,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 {
|
||||
@@ -1966,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());
|
||||
@@ -1978,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));
|
||||
@@ -2052,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();
|
||||
@@ -2063,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).
|
||||
@@ -2075,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}")));
|
||||
@@ -2189,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 {
|
||||
@@ -2325,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);
|
||||
@@ -2439,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
|
||||
@@ -4037,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}`")))?;
|
||||
@@ -4077,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.
|
||||
@@ -5246,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).
|
||||
@@ -5658,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
|
||||
@@ -6060,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)?;
|
||||
@@ -6189,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>,
|
||||
@@ -6375,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 {
|
||||
@@ -6386,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)?;
|
||||
@@ -6657,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 }
|
||||
@@ -6886,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()));
|
||||
@@ -6913,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 {
|
||||
@@ -6936,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(());
|
||||
@@ -7654,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,
|
||||
}
|
||||
@@ -7664,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 { .. }
|
||||
@@ -8149,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1393,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.
|
||||
@@ -1405,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);
|
||||
@@ -1714,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,
|
||||
};
|
||||
@@ -1726,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);
|
||||
@@ -1741,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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -76,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));
|
||||
|
||||
@@ -234,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>> {
|
||||
@@ -1088,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);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -350,6 +350,11 @@ fn a_body_that_changed_shape_is_measured_again() {
|
||||
/// ring under it. With a fillet under the head against the bore's sharp edge, the fillet's ring
|
||||
/// is in the flange (about 0.49 mm³), next to walls that lie on one another: the boolean tried
|
||||
/// for minutes there and gave up; now it is reported as such within seconds.
|
||||
///
|
||||
/// The clock is not read: what is under test is the result, and the boolean that gives up is
|
||||
/// reported as such instead of hanging the test. The wall that lies on the other one is what
|
||||
/// makes the pair unchecked, and that is what the assertions below check (#2999 dropped the clock
|
||||
/// from the sibling tests the same way).
|
||||
#[test]
|
||||
fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
|
||||
let ring = std::f64::consts::PI * (7.0f64.powi(2) - 4.0f64.powi(2)) * 0.2;
|
||||
@@ -365,9 +370,7 @@ fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[104, 0, z]], "radius": 0.3, "body": "Pin"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[107, 0, z + 1.5]], "radius": 0.5, "body": "Pin"}));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let r = run(&mut s, "inspect.clearance", json!({"bodies": ["Pin"], "against": ["Bush"], "min_volume": 0.01, "clearance": 1e-6}));
|
||||
assert!(started.elapsed().as_secs_f64() < 30.0, "sunk {sunk}: {:?}", started.elapsed());
|
||||
if fillet {
|
||||
assert!(r["unchecked"][0]["reason"].as_str().is_some_and(|w| w.contains("pin on one cylinder")), "{r}");
|
||||
continue;
|
||||
|
||||
@@ -566,7 +566,7 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// This design: the component's history becomes an Import feature of the new design's bodies
|
||||
let shown = file.display().to_string();
|
||||
let (_, sf, faces) = session_bodies(&part, &shown)?;
|
||||
let (_, sf, faces, _) = session_bodies(&part, &shown)?;
|
||||
// The link has a body per piece: a body in pieces keeps its name on the first
|
||||
let mut solids: Vec<String> = Vec::new();
|
||||
for b in part.world_state().bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
@@ -592,7 +592,7 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.link_faces = faces;
|
||||
}
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id });
|
||||
s.refresh();
|
||||
rename_joint_faces(s, &state, id, &sc.own);
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it,
|
||||
//! join a new component through an Import feature (exact B-rep, kept in this design so it opens
|
||||
//! without the part file). The link remembers the file, relative to this design's folder once it
|
||||
//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and
|
||||
//! Break Link keeps the bodies and forgets the file. The bodies' faces are named after the names
|
||||
//! without the part file). The link remembers the absolute file in a session and a path relative
|
||||
//! to the design's folder on disk; Update Links reads it again and replaces the Import feature's
|
||||
//! bodies, and Break Link keeps the bodies and forgets the file. The bodies' faces are named after the names
|
||||
//! the design gives them (`Feature::link_faces`), so a joint on a linked face stays on that face
|
||||
//! when the design changes, as it would inside the design, and reports it when the face is gone.
|
||||
|
||||
@@ -27,7 +27,7 @@ mod relink_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params(
|
||||
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
|
||||
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design and a copy of its design properties; \
|
||||
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]; \
|
||||
reuse?: bool (default false): insert a free occurrence, reusing an existing link to the same file when available",
|
||||
),
|
||||
@@ -54,10 +54,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
(its link here, else a new linked component) → each component's history goes and its placements become placements of the linked \
|
||||
component; refused when its bodies are not the same as the link's; returns component, name, replaced and warnings",
|
||||
),
|
||||
CommandSpec::new("component.links", "List Links", list_links)
|
||||
.icon("chain")
|
||||
.noundo()
|
||||
.params("→ linked components: component, name, path, file (resolved), found"),
|
||||
CommandSpec::new("component.links", "List Links", list_links).icon("chain").noundo().params(
|
||||
"→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \
|
||||
or names a device: it is not looked for), and then host (the network location) and reason",
|
||||
),
|
||||
];
|
||||
|
||||
/// The folder relative link paths are kept against: the design's own, once it has a file.
|
||||
@@ -119,14 +119,37 @@ fn resolve(design: Option<&str>, link: &str) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the file a stored link names may not be reached, from its text alone (`None`: it may): a
|
||||
/// device, or a network location not allowed (`vfs::design_path_refusal`). Both the link as
|
||||
/// stored and the file it resolves to are judged, before anything asks the disk or the network.
|
||||
fn link_refusal(design: Option<&str>, link: &str) -> Option<solidcraft_io::vfs::Remote> {
|
||||
let file = resolve(design, link);
|
||||
solidcraft_io::vfs::design_path_refusal(link, design).or_else(|| solidcraft_io::vfs::design_path_refusal(&file.to_string_lossy(), design))
|
||||
}
|
||||
|
||||
/// The error for a link [`link_refusal`] refuses.
|
||||
fn link_refused(design: Option<&str>, link: &str) -> Result<()> {
|
||||
solidcraft_io::vfs::check_design_path(link, design).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let file = resolve(design, link);
|
||||
solidcraft_io::vfs::check_design_path(&file.to_string_lossy(), design).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
|
||||
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
|
||||
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect()
|
||||
}
|
||||
|
||||
/// Resolve a design's component links against its original file, before opening it from a
|
||||
/// different location (for example a recovery file) or keeping it in session history.
|
||||
pub fn resolve_design_links(doc: &mut Document, from: Option<&str>) {
|
||||
for link in doc.links.values_mut() {
|
||||
link.path = absolute(&resolve(from, &link.path)).to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies of a linked design: its name, the Import feature that holds them and the names
|
||||
/// the design gives their faces (`Feature::link_faces`).
|
||||
type Linked = (String, solidcraft_io::StepFeature, Vec<Vec<String>>);
|
||||
/// the design gives their faces (`Feature::link_faces`), with the source's design properties.
|
||||
type Linked = (String, solidcraft_io::StepFeature, Vec<Vec<String>>, solidcraft_doc::Metadata);
|
||||
|
||||
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
|
||||
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<Linked> {
|
||||
@@ -134,9 +157,14 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<Linked> {
|
||||
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
|
||||
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
// A network location only when its server is allowed, a device never: judged from the text
|
||||
// before anything is read (on Windows, reading `\\host\share\x` connects to the host).
|
||||
let bytes = solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| {
|
||||
let e = e.to_string();
|
||||
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
|
||||
})?;
|
||||
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
session_bodies(&Session::new(doc), &shown)
|
||||
session_bodies(&Session::opened_at(doc, None, Some(&shown)), &shown)
|
||||
}
|
||||
|
||||
/// The solid bodies of `part`, the design saved as `shown`, as an Import feature.
|
||||
@@ -159,7 +187,7 @@ fn session_bodies(part: &Session, shown: &str) -> Result<Linked> {
|
||||
// The STEP text keeps no face names: carry the design's own, so references into the linked
|
||||
// bodies follow them as they do inside the design (#3330).
|
||||
let faces = solidcraft_doc::naming::carried_face_names(&world, &feature.import.bodies);
|
||||
Ok((name, feature, faces))
|
||||
Ok((name, feature, faces, part.doc.metadata.clone()))
|
||||
}
|
||||
|
||||
fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
|
||||
@@ -178,6 +206,8 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => solidcraft_geom::Vec3::ZERO,
|
||||
Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?,
|
||||
};
|
||||
// A network or device path is judged from its text, as given, before anything reads it.
|
||||
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let file = absolute(Path::new(given));
|
||||
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
|
||||
return Err(bad(cmd, "a design can't be linked into itself"));
|
||||
@@ -189,7 +219,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::component::copy_occurrence(s, &json!({"component": component, "parent": parent, "translate": translate}));
|
||||
}
|
||||
}
|
||||
let (design_name, sf, faces) = linked_bodies(&file, s.path.as_deref())?;
|
||||
let (design_name, sf, faces, metadata) = linked_bodies(&file, s.path.as_deref())?;
|
||||
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
|
||||
let mut create = json!({"name": name, "activate": false});
|
||||
// Clipboard insertion uses reuse even for the first paste, which must stay movable.
|
||||
@@ -202,6 +232,10 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let made = super::component::new_component(s, &create)?;
|
||||
let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?;
|
||||
let occ = made["occurrence"].as_u64();
|
||||
// Seed only a new definition: reused links keep any locally edited part properties.
|
||||
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == comp) {
|
||||
c.metadata = metadata;
|
||||
}
|
||||
let id = s.doc_mut().add_feature(sf.kind, Some(&name))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.component = comp;
|
||||
@@ -212,7 +246,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.transform[3] = [translate.x, translate.y, translate.z, 1.0];
|
||||
}
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id });
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
@@ -251,8 +285,9 @@ fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
};
|
||||
let old = old.clone();
|
||||
link_refused(s.path.as_deref(), &link.path).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
|
||||
let file = resolve(s.path.as_deref(), &link.path);
|
||||
let (_, sf, faces) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
|
||||
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
|
||||
warnings.extend(sf.warnings.iter().cloned());
|
||||
let same = matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old);
|
||||
let kept = s.doc.feature(link.feature).map(|f| &f.link_faces);
|
||||
@@ -322,6 +357,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) {
|
||||
return Err(bad(cmd, "choose a saved .solidcraft design"));
|
||||
}
|
||||
// A network or device path is judged from its text before the disk is asked anything.
|
||||
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
|
||||
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
|
||||
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
|
||||
@@ -331,7 +368,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Read/evaluate the candidate before changing anything. Session::execute owns the single
|
||||
// undo step and restores both the document and evaluated state on any later failure.
|
||||
let (_, sf, faces) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let geometry_changed = match (s.doc.feature(link.feature).map(|f| &f.kind), &sf.kind) {
|
||||
(Some(FeatureKind::Import { step: old, .. }), FeatureKind::Import { step, .. }) => old != step,
|
||||
_ => true,
|
||||
@@ -354,7 +391,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.kind = sf.kind;
|
||||
f.link_faces = faces;
|
||||
}
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: link.feature });
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: link.feature });
|
||||
s.refresh();
|
||||
if let Some(error) = s.model.result(link.feature).and_then(|r| r.error.clone()) {
|
||||
return Err(bad(cmd, error));
|
||||
@@ -367,7 +404,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (_, name, why) in sol.errors.iter().chain(&sol.conflicts) {
|
||||
warnings.push(format!("joint {name}: {why}"));
|
||||
}
|
||||
Ok(json!({"component": comp, "feature": link.feature, "path": path, "previous_path": link.path, "geometry_changed": geometry_changed,
|
||||
let previous_path = stored(s.path.as_deref(), &resolve(s.path.as_deref(), &link.path));
|
||||
Ok(json!({"component": comp, "feature": link.feature, "path": path, "previous_path": previous_path, "geometry_changed": geometry_changed,
|
||||
"warnings": warnings, "joint_errors": diagnostics(&sol.errors), "joint_conflicts": diagnostics(&sol.conflicts)}))
|
||||
}
|
||||
|
||||
@@ -375,7 +413,7 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.break_link";
|
||||
let comp = component_key(s, p, "component", cmd)?;
|
||||
let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
|
||||
Ok(json!({"component": comp, "path": link.path}))
|
||||
Ok(json!({"component": comp, "path": stored(s.path.as_deref(), &resolve(s.path.as_deref(), &link.path))}))
|
||||
}
|
||||
|
||||
fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -386,8 +424,18 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.map(|(c, l)| {
|
||||
let file = resolve(s.path.as_deref(), &l.path);
|
||||
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
let file = file.to_string_lossy();
|
||||
json!({"component": c, "name": name, "path": l.path, "file": file, "found": solidcraft_io::vfs::is_file(&file)})
|
||||
// A refused path is not looked for at all: on Windows asking whether a file on
|
||||
// `\\host\share` exists already connects to the host.
|
||||
match link_refusal(s.path.as_deref(), &l.path) {
|
||||
None => json!({"component": c, "name": name, "path": path, "file": file,
|
||||
"found": solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref())}),
|
||||
Some(solidcraft_io::vfs::Remote::Network(host)) => json!({"component": c, "name": name, "path": path, "file": file,
|
||||
"found": null, "host": host, "reason": format!("on the network location \\\\{host}, which is not allowed")}),
|
||||
Some(solidcraft_io::vfs::Remote::Device) => json!({"component": c, "name": name, "path": path, "file": file,
|
||||
"found": null, "reason": "names a device, not a file"}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"links": links}))
|
||||
@@ -436,6 +484,95 @@ mod tests {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// #6860: Save As changes the file's folder, not the targets kept in the edit history.
|
||||
#[test]
|
||||
fn save_as_keeps_link_targets_through_undo_redo_and_checkpoints() {
|
||||
for on_copy in [false, true] {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-save-as-{}-{on_copy}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("parts")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("asm/sub/deeper")).unwrap();
|
||||
let part = dir.join("parts/plate.solidcraft");
|
||||
let case = dir.join("asm/case.solidcraft");
|
||||
save_box(&part, 30.0);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.insert_design", json!({"path": part}));
|
||||
run(&mut s, "file.save", json!({"path": case}));
|
||||
// Opening starts from the relative paths actually written to disk.
|
||||
run(&mut s, "doc.open", json!({"path": case}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [60, 0, 0]}));
|
||||
let checkpoint = s.checkpoint();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [80, 0, 0]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let before = s.doc.clone();
|
||||
let undo_before = s.undo.last().unwrap().doc.clone();
|
||||
let missing = dir.join("missing/case.solidcraft");
|
||||
assert!(s.execute("file.save_as", &json!({"path": missing})).is_err());
|
||||
assert_eq!(s.path.as_deref(), case.to_str());
|
||||
for folder in ["asm/sub", "asm/sub/deeper"] {
|
||||
let path = dir.join(folder).join("case.solidcraft");
|
||||
if on_copy {
|
||||
let mut copy = s.scratch();
|
||||
run(&mut copy, "file.save_as", json!({"path": path}));
|
||||
s.adopt_save(©);
|
||||
} else {
|
||||
run(&mut s, "file.save_as", json!({"path": path}));
|
||||
}
|
||||
assert!(!s.is_dirty());
|
||||
for command in ["edit.redo", "edit.undo", "edit.undo", "edit.redo"] {
|
||||
run(&mut s, command, json!({}));
|
||||
let links = run(&mut s, "component.links", json!({}));
|
||||
assert_eq!(links["links"][0]["found"], true, "{on_copy}, {folder}, {command}: {links}");
|
||||
assert_eq!(absolute(Path::new(links["links"][0]["file"].as_str().unwrap())), absolute(&part));
|
||||
}
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &before), "saving does not rewrite the live document");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.undo.last().unwrap().doc, &undo_before), "saving does not copy the edit history");
|
||||
s.restore_checkpoint(checkpoint).unwrap();
|
||||
assert_eq!(run(&mut s, "component.links", json!({}))["links"][0]["found"], true);
|
||||
let path = s.path.clone().unwrap();
|
||||
for format in ["json", "container"] {
|
||||
run(&mut s, "file.save", json!({"format": format}));
|
||||
let disk = solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(disk.links.values().next().unwrap().path, "../../../parts/plate.solidcraft");
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(run(&mut s, "component.links", json!({}))["links"][0]["found"], true);
|
||||
}
|
||||
save_box(&part, 45.0);
|
||||
assert_eq!(run(&mut s, "component.update_links", json!({}))["updated"], json!(["plate"]));
|
||||
assert!((volume(&mut s) - 9125.0).abs() < 1e-6);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_relative_links_keep_their_targets_after_save_as() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-recovery-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("parts")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("asm/sub")).unwrap();
|
||||
let part = dir.join("parts/plate.solidcraft");
|
||||
save_box(&part, 30.0);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.insert_design", json!({"path": part}));
|
||||
run(&mut s, "file.save", json!({"path": dir.join("asm/case.solidcraft")}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [60, 0, 0]}));
|
||||
let recovery = dir.join("recovery");
|
||||
let mut autosaver = crate::recovery::Autosaver::new(&recovery, std::time::Duration::ZERO).unwrap();
|
||||
assert!(autosaver.save(&s).unwrap());
|
||||
drop(autosaver);
|
||||
let entry = crate::recovery::orphans(&recovery).pop().unwrap();
|
||||
// Older autosaves hold paths relative to the design, not the recovery folder.
|
||||
std::fs::write(recovery.join(format!("{}.solidcraft", entry.id)), solidcraft_io::write_design(&s.design())).unwrap();
|
||||
let mut recovered = Session::default();
|
||||
run(&mut recovered, "doc.recover", json!({"dir": recovery, "id": entry.id}));
|
||||
assert_eq!(run(&mut recovered, "component.links", json!({}))["links"][0]["found"], true);
|
||||
run(&mut recovered, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [80, 0, 0]}));
|
||||
run(&mut recovered, "file.save_as", json!({"path": dir.join("asm/sub/case.solidcraft")}));
|
||||
run(&mut recovered, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut recovered, "component.links", json!({}))["links"][0]["found"], true);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id()));
|
||||
@@ -649,4 +786,112 @@ mod tests {
|
||||
assert!(update["warnings"].to_string().contains("joint PinInHole refers to faces of Plate by their place"), "{update}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A design someone sends may name a linked file on another computer (`\\host\share\…`) or a
|
||||
/// device. On Windows, reading it or only asking whether it exists connects to that computer
|
||||
/// and may send it the user's sign-in. Update Links and List Links judge the stored path from
|
||||
/// its text and touch nothing: List Links reports it unknown with a reason, Update Links
|
||||
/// refuses with the server's name, and nothing in the design changes.
|
||||
#[test]
|
||||
fn links_to_network_locations_or_devices_touch_nothing_until_allowed() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-network-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("parts")).unwrap();
|
||||
let part = dir.join("parts").join("plate.solidcraft");
|
||||
save_box(&part, 30.0);
|
||||
let case = dir.join("case.solidcraft");
|
||||
let mut a = Session::default();
|
||||
let comp = run(&mut a, "component.insert_design", json!({"path": part}))["component"].as_u64().unwrap();
|
||||
run(&mut a, "file.save", json!({"path": case}));
|
||||
let feature = a.doc.links[&comp].feature;
|
||||
// On Unix these names are plain files beside the design, so a check that was skipped would
|
||||
// find them: the refusal below comes from the text alone.
|
||||
let crafted = [r"\\evil.example\share\plate.solidcraft", "//evil.example/share/plate.solidcraft", r"\\?\UNC\evil.example\share\p.solidcraft"];
|
||||
if cfg!(unix) {
|
||||
std::fs::copy(&part, dir.join(crafted[0])).unwrap();
|
||||
}
|
||||
let devices = [r"\\.\pipe\plate.solidcraft", r"\\.\COM1", "parts/NUL.solidcraft", "parts/com1.solidcraft", r"parts\CON.part.solidcraft"];
|
||||
std::fs::copy(&part, dir.join("parts").join("NUL.solidcraft")).unwrap();
|
||||
let before = volume(&mut a);
|
||||
for link in crafted.iter().chain(&devices) {
|
||||
a.doc_mut().links.insert(comp, DesignLink { path: link.to_string(), feature });
|
||||
let calls = solidcraft_io::vfs::file_calls();
|
||||
let listed = run(&mut a, "component.links", json!({}));
|
||||
let entry = &listed["links"][0];
|
||||
assert!(entry["found"].is_null() && entry["reason"].is_string(), "{link}: {listed}");
|
||||
let err = a.execute("component.update_links", &json!({})).unwrap_err().to_string();
|
||||
assert_eq!(solidcraft_io::vfs::file_calls(), calls, "{link}: a file call was made");
|
||||
if devices.contains(link) {
|
||||
assert!(entry["host"].is_null() && err.contains("names a device"), "{link}: {listed} {err}");
|
||||
} else {
|
||||
assert_eq!(entry["host"], "evil.example", "{link}: {listed}");
|
||||
assert!(err.contains(r"network location \\evil.example"), "{link}: {err}");
|
||||
}
|
||||
assert_eq!(volume(&mut a), before, "{link}: the design changed");
|
||||
}
|
||||
// Allowed (Allow once, a preference, …), the same link is read as any other.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
a.doc_mut().links.insert(comp, DesignLink { path: crafted[0].to_string(), feature });
|
||||
solidcraft_io::vfs::allowing_hosts(&["EVIL.example".to_string()], || {
|
||||
let listed = run(&mut a, "component.links", json!({}));
|
||||
assert_eq!(listed["links"][0]["found"], true, "{listed}");
|
||||
assert_eq!(run(&mut a, "component.update_links", json!({}))["unchanged"], json!(["plate"]));
|
||||
});
|
||||
assert!(run(&mut a, "component.links", json!({}))["links"][0]["found"].is_null(), "allowed for that action only");
|
||||
}
|
||||
// A local link, relative or absolute, is found and read as before.
|
||||
for local in ["parts/plate.solidcraft".to_string(), part.to_string_lossy().to_string()] {
|
||||
a.doc_mut().links.insert(comp, DesignLink { path: local.clone(), feature });
|
||||
assert_eq!(run(&mut a, "component.links", json!({}))["links"][0]["found"], true, "{local}");
|
||||
assert_eq!(run(&mut a, "component.update_links", json!({}))["unchanged"], json!(["plate"]), "{local}");
|
||||
}
|
||||
// Relink to a network path (programmatic: no prompt) is refused before the disk is asked.
|
||||
let calls = solidcraft_io::vfs::file_calls();
|
||||
let err = a.execute("component.relink", &json!({"component": comp, "path": r"\\evil.example\share\x.solidcraft"})).unwrap_err().to_string();
|
||||
assert!(err.contains("network location"), "{err}");
|
||||
assert_eq!(solidcraft_io::vfs::file_calls(), calls);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Every place that reaches a file through a link path goes through the design-path rule
|
||||
/// (`vfs::check_design_path` / `read_design_path` / `design_path_is_file` / `link_refusal`):
|
||||
/// no plain file call on a link's path in the link commands, and the font file a design's text
|
||||
/// names is checked before it is read.
|
||||
#[test]
|
||||
fn every_link_path_goes_through_the_design_path_rule() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let code = |file: &str| {
|
||||
let text = std::fs::read_to_string(root.join(file)).unwrap();
|
||||
// The code before its test module.
|
||||
text.split("\n#[cfg(test)]\n#[allow(clippy::unwrap_used").next().unwrap_or("").to_string()
|
||||
};
|
||||
for file in ["src/cmd/component_link.rs", "src/cmd/component_extract.rs"] {
|
||||
let text = code(file);
|
||||
for raw in [
|
||||
"vfs::read(",
|
||||
"vfs::read_to_string(",
|
||||
"vfs::is_file(",
|
||||
"vfs::exists(",
|
||||
"vfs::len(",
|
||||
"std::fs::read",
|
||||
"std::fs::metadata",
|
||||
".is_file()",
|
||||
".exists()",
|
||||
".metadata()",
|
||||
] {
|
||||
assert!(!text.contains(raw), "{file}: `{raw}` on a link's path must go through vfs::check_design_path");
|
||||
}
|
||||
// Relink canonicalises the path it is given, after the rule.
|
||||
if let Some(at) = text.find("canonicalize") {
|
||||
let rule = text[..at].rfind("link_refused(s.path.as_deref(), given)").unwrap_or(usize::MAX);
|
||||
assert!(rule < at, "{file}: canonicalize before check_design_path");
|
||||
}
|
||||
}
|
||||
let fonts = std::fs::read_to_string(root.join("../sketch/src/fonts.rs")).unwrap();
|
||||
let (check, read) = (fonts.find("FILE_CHECK.get()").unwrap(), fonts.find("read_font(&path)").unwrap());
|
||||
assert!(check < read, "a font file named by a design is checked before it is read");
|
||||
let io = std::fs::read_to_string(root.join("../io/src/vfs.rs")).unwrap();
|
||||
assert!(io.contains("set_font_file_check(|p| check_design_path("), "the font check is the design-path rule");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::construct::{AxisDef, GeoRef, PointDef};
|
||||
use solidcraft_doc::{FeatureKind, PlaneRef};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, plane_param, sketch_id};
|
||||
@@ -88,6 +89,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// An axis on an edge or round face of the model (`{edge: [x,y,z]}` or `{face: [x,y,z]}`, the
|
||||
/// point on it), with the line it gives now (`solidcraft_doc::construct::model_axis`); `None`
|
||||
/// for any other form of `axis`.
|
||||
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
|
||||
// Also as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it kept.
|
||||
let saved = |v: &Value| matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face"));
|
||||
let Some(v) = v.filter(|v| v.get("edge").is_some() || v.get("face").is_some() || saved(v)) else { return Ok(None) };
|
||||
let g = geo(s, Some(v), cmd, "axis")?;
|
||||
let (o, d) = solidcraft_doc::construct::model_axis(&s.world_state(), &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
|
||||
Ok(Some((g, o, d)))
|
||||
}
|
||||
|
||||
/// A reference from its JSON form.
|
||||
pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
|
||||
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
|
||||
|
||||
@@ -632,7 +632,7 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut();
|
||||
for (id, made) in &keep {
|
||||
if let Some(f) = doc.feature_mut(*id) {
|
||||
f.body_names.clone_from(made);
|
||||
f.pin_body_names(made);
|
||||
}
|
||||
}
|
||||
for id in &ids {
|
||||
@@ -1070,7 +1070,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
super::features::stable_profiles(s, &mut nf.kind, false)?;
|
||||
}
|
||||
// New face points: their faces' names now.
|
||||
if nf.kind.face_points() != f.kind.face_points() {
|
||||
|
||||
@@ -822,6 +822,53 @@ fn suppressing_a_feature_keeps_later_counted_body_names() {
|
||||
assert!(works(&s));
|
||||
}
|
||||
|
||||
/// Counted body names are pinned when the body is made, as if the user had renamed it: deleting,
|
||||
/// reordering or rolling back another feature leaves the other bodies' names alone, and a number
|
||||
/// a deleted feature held is not handed to the next body.
|
||||
#[test]
|
||||
fn counted_body_names_stay_when_the_timeline_changes() {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20), ("C", 40)] {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "name": name}));
|
||||
}
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Body2"], "translate": [0, 30, 0], "name": "Move"}));
|
||||
let bodies = |s: &Session| -> Vec<(String, String)> {
|
||||
let st = s.model.state();
|
||||
st.bodies.iter().map(|b| (b.name.clone(), s.doc.feature(b.feature).map(|f| f.name.clone()).unwrap_or_default())).collect()
|
||||
};
|
||||
let named = |pairs: &[(&str, &str)]| pairs.iter().map(|(b, f)| (b.to_string(), f.to_string())).collect::<Vec<_>>();
|
||||
let works = |s: &Session| s.doc.features.iter().all(|f| s.model.result(f.id).is_some_and(|r| r.error.is_none()));
|
||||
// The names are stored on the features as soon as the bodies exist.
|
||||
assert_eq!(s.doc.find_feature("B").map(|f| f.body_names.clone()), Some(vec!["Body2".to_string()]));
|
||||
assert!(["Body1", "Body2", "Body3"].iter().all(|n| s.doc.reserved_body_names.contains(*n)));
|
||||
|
||||
// Reordering A behind B: every body keeps its number, wherever it comes in the list.
|
||||
run(&mut s, "timeline.reorder", json!({"feature": "A", "position": 2, "active": true}));
|
||||
let mut reordered = bodies(&s);
|
||||
reordered.sort();
|
||||
assert_eq!(reordered, named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")]));
|
||||
assert!(works(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
|
||||
// Rolled back to before B and forward again.
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": "end"}));
|
||||
assert_eq!(bodies(&s), named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")]));
|
||||
|
||||
// Deleting A: B and C keep their names, so the Move still finds Body2.
|
||||
run(&mut s, "timeline.delete", json!({"feature": "A"}));
|
||||
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C")]));
|
||||
assert!(works(&s), "the Move still finds Body2");
|
||||
|
||||
// The number A held is not handed out again.
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0], "name": "D"}));
|
||||
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "D")]));
|
||||
// Undo takes the box away, and the number back with it.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0], "name": "E"}));
|
||||
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "E")]));
|
||||
}
|
||||
|
||||
/// The value at a dotted path of a definition.
|
||||
fn dotted<'a>(def: &'a Value, path: &str) -> &'a Value {
|
||||
path.split('.').fold(def, |v, k| &v[k])
|
||||
|
||||
@@ -25,11 +25,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("E")
|
||||
.contract(&EXTRUDE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir}; angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -58,7 +58,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_circ")
|
||||
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
|
||||
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
|
||||
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_path")
|
||||
@@ -68,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" or \"1/4-20 UNC\" (cosmetic); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" or \"1/4-20 UNC\" (cosmetic); handedness?: right|left (default right, needs thread for left); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", pipe)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -92,7 +92,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); handedness?: right|left (default right); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -108,7 +108,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_angle")
|
||||
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir}; angle: expr; name?"),
|
||||
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle: expr; name?"),
|
||||
CommandSpec::new("solid.split_face", "Split Face", split_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("split_face")
|
||||
@@ -198,14 +198,21 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
|
||||
// Probe in the same coordinate frame as the face picks.
|
||||
let grouped = p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first()).and_then(vec3);
|
||||
let st = if grouped.is_some() && world_faces { s.world_state() } else { s.model.state() };
|
||||
// The body the extrude starts on, when it is in a component inside the active one: it takes
|
||||
// part as the active component's own bodies do (#5679).
|
||||
let mut started_on = None;
|
||||
let (base, n) = if let Some(point) = grouped {
|
||||
let (mesh, index, distance, name) = super::face::face_at_state(&st, point, str_(p, "face_body")).ok().flatten()?;
|
||||
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
|
||||
return None;
|
||||
}
|
||||
let normal = st.body(&name)?.body.planar_face(index as usize)?.normal();
|
||||
started_on = Some(name);
|
||||
(point, normal)
|
||||
} else {
|
||||
if p.get("face").is_none() {
|
||||
started_on = feature_sketch(s, p, "Extrude").ok().and_then(|sketch| face_body_reached(s, sketch).ok().flatten());
|
||||
}
|
||||
extrude_base(s, p)?
|
||||
};
|
||||
// The extrude starts at its From Object or start offset, not at the profile: probe from
|
||||
@@ -232,17 +239,27 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
|
||||
// A component with no body of its own works on those of the components inside it (#3223).
|
||||
let parts = string_list(p, "participants");
|
||||
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
|
||||
let bodies: Vec<_> = if !parts.is_empty() {
|
||||
let mut bodies: Vec<_> = if !parts.is_empty() {
|
||||
st.bodies.iter().filter(|b| parts.contains(&b.name)).collect()
|
||||
} else if implicit_all {
|
||||
st.bodies.iter().collect()
|
||||
} else {
|
||||
active_scope(s, &st)
|
||||
};
|
||||
if parts.is_empty()
|
||||
&& let Some(body) = started_on.as_deref().and_then(|name| st.body(name))
|
||||
&& !bodies.iter().any(|b| b.name == body.name)
|
||||
&& s.doc.component_within(s.doc.model_body_component(body), s.active_component)
|
||||
{
|
||||
bodies.push(body);
|
||||
}
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
}
|
||||
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
|
||||
// A profile's points are in the active component's frame; a body placed differently (in a
|
||||
// moved or turned component) is probed where it is shown. Face picks are in `st`'s frame.
|
||||
let frames: Vec<Option<solidcraft_doc::Mat>> = bodies.iter().map(|b| if grouped.is_some() { None } else { into_body_frame(s, b) }).collect();
|
||||
let inside = |q: Vec3| bodies.iter().zip(&frames).any(|(b, m)| b.mesh().contains(m.as_ref().map_or(q, |m| solidcraft_doc::apply_point(m, q))));
|
||||
// A step into whatever is right in front of the profile, short of the far end.
|
||||
let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM);
|
||||
let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5));
|
||||
@@ -309,7 +326,11 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
/// whose curves would not find that same profile again (two profiles bounded by the same
|
||||
/// curves) stays as given. Curve picks are checked: one that names no profile, or several, is
|
||||
/// refused now rather than failing later.
|
||||
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
///
|
||||
/// `exact` keeps each index or point pick to the region it names. Otherwise a pick on a drawn
|
||||
/// profile that reference geometry cuts into regions takes the whole drawn profile
|
||||
/// ([`drawn_profile_regions`], #5302).
|
||||
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind, exact: bool) -> Result<()> {
|
||||
use solidcraft_doc::sketch::profile_at_point;
|
||||
let st = s.model.state();
|
||||
for (sketch, sel) in kind.profile_sels_mut() {
|
||||
@@ -373,7 +394,8 @@ pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()>
|
||||
}
|
||||
ProfileSel::All => continue,
|
||||
};
|
||||
let captured = solidcraft_doc::capture_profile_selection(ss, &drawn_profile_regions(ss, &indices))?;
|
||||
let picked = if exact { indices } else { drawn_profile_regions(ss, &indices) };
|
||||
let captured = solidcraft_doc::capture_profile_selection(ss, &picked)?;
|
||||
*sel = match captured {
|
||||
ProfileSel::OffsetBoundaries { associations }
|
||||
if associations.iter().any(|id| {
|
||||
@@ -480,6 +502,13 @@ fn drawn_profile_regions(ss: &solidcraft_doc::SolvedSketch, indices: &[usize]) -
|
||||
out
|
||||
}
|
||||
|
||||
/// `exact_regions: true`: each index or point pick is the region it names, never grown to the
|
||||
/// whole drawn profile. The dialogs send it: they highlight exactly the regions picked, and OK
|
||||
/// must build those (#6724).
|
||||
pub(super) fn exact_regions(p: &Value) -> bool {
|
||||
bool_(p, "exact_regions").unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
|
||||
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
|
||||
match v {
|
||||
@@ -588,7 +617,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
|
||||
// sketch's profiles (a face sketch's projected outline rebuilt), never the curves.
|
||||
stable_profiles(s, &mut kind)?;
|
||||
stable_profiles(s, &mut kind, exact_regions(p))?;
|
||||
// Blends remember where their edges sat, to find them again after upstream edits.
|
||||
let (refs, names) = match &kind {
|
||||
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
|
||||
@@ -780,14 +809,20 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => operation(p, cmd)?,
|
||||
};
|
||||
let mut targets = string_list(p, "targets");
|
||||
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
|
||||
let mut p = std::borrow::Cow::Borrowed(p);
|
||||
if operation == Operation::Join && targets.is_empty() && string_list(&p, "participants").is_empty() {
|
||||
// A face's outward extension joins that body, and so does a profile sketched on a body's
|
||||
// face. Assembly neighbours can touch only an edge of the tool: trying to join them
|
||||
// first fails, or absorbs unrelated parts (#226). A profile drawn past the face can lie
|
||||
// on a neighbour's face too: a rib on a bracket's side welded in the motor the bracket
|
||||
// holds, a join that ran for minutes and failed (#1349).
|
||||
// Store the resolved target so recomputes and reopening keep the same scope.
|
||||
targets.extend(face_body.or_else(|| sketch_face_body(s, sketch)));
|
||||
targets.extend(match face_body {
|
||||
Some(body) => Some(body),
|
||||
None => face_body_reached(s, sketch)?,
|
||||
});
|
||||
} else if let Some(parts) = cut_scope(s, &p, operation, sketch, face_body)? {
|
||||
p.to_mut()["participants"] = json!(parts);
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch: SketchId(sketch),
|
||||
@@ -795,14 +830,39 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(f) => f,
|
||||
None if thin.is_some() => ProfileSel::All,
|
||||
None if p.get("profiles").is_none() => default_extrude_profiles(s, sketch),
|
||||
None => profiles(p, cmd)?,
|
||||
None => profiles(&p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
operation,
|
||||
targets: targets.into_iter().map(BodyName).collect(),
|
||||
thin,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
add_feature(s, &p, kind)
|
||||
}
|
||||
|
||||
/// The bodies a cut or intersect from a profile on a body's face takes part in when it names
|
||||
/// none and that body is in a component inside the active one (#5679): the active component's
|
||||
/// own bodies, as by default, and that body. `None` when the default already reaches it: it is
|
||||
/// one of the active component's, or a component with no body of its own cuts every body.
|
||||
fn cut_scope(s: &Session, p: &Value, operation: Operation, sketch: u64, face_body: Option<String>) -> Result<Option<Vec<String>>> {
|
||||
if !matches!(operation, Operation::Cut | Operation::Intersect)
|
||||
|| !string_list(p, "targets").is_empty()
|
||||
|| !string_list(p, "participants").is_empty()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(body) = face_body_reached(s, sketch)?.or(face_body) else { return Ok(None) };
|
||||
let st = s.model.state();
|
||||
if s.cut_takes_every_body(&st) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut parts: Vec<String> = active_scope(s, &st).into_iter().map(|b| b.name.clone()).collect();
|
||||
let within = st.body(&body).is_some_and(|b| s.doc.component_within(s.doc.model_body_component(b), s.active_component));
|
||||
if parts.contains(&body) || !within {
|
||||
return Ok(None);
|
||||
}
|
||||
parts.push(body);
|
||||
Ok(Some(parts))
|
||||
}
|
||||
|
||||
/// The profiles an extrude takes when `profiles` is omitted: even-odd nesting over what was drawn,
|
||||
@@ -846,6 +906,68 @@ fn default_extrude_profiles(s: &Session, sketch: u64) -> ProfileSel {
|
||||
}
|
||||
}
|
||||
|
||||
/// The matrix taking a point of the active component's frame into the frame `b`'s geometry is
|
||||
/// kept in, as the evaluator moves a tool into it; `None` when the two are the same.
|
||||
fn into_body_frame(s: &Session, b: &solidcraft_doc::ModelBody) -> Option<solidcraft_doc::Mat> {
|
||||
let frame = s.doc.component_transform(s.doc.model_body_component(b));
|
||||
let world = s.doc.model_body_offset(b).map_or(frame, |offset| solidcraft_doc::mat_mul(&frame, offset));
|
||||
let active = s.doc.component_transform(s.active_component);
|
||||
if world == active {
|
||||
return None;
|
||||
}
|
||||
solidcraft_doc::mat_inverse(&world).map(|into| solidcraft_doc::mat_mul(&into, &active))
|
||||
}
|
||||
|
||||
/// The body whose face `sketch` is attached to, when a join or cut made in the active component
|
||||
/// can change it: one of the active component's own ([`active_scope`]), or one in a component
|
||||
/// inside it, moved or turned, which is changed in its own component, so every placement of that
|
||||
/// component shows the change (#5679). `Ok(None)` when the sketch is not on a face, or the body is
|
||||
/// outside the active component. Refused where the change can't be made in the body's component:
|
||||
/// its bodies come from a linked design file, or the face was picked on a further placement of a
|
||||
/// component placed more than once (the change is drawn at the first).
|
||||
fn face_body_reached(s: &Session, sketch: u64) -> Result<Option<String>> {
|
||||
let Some(FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. }) = s.doc.feature(sketch).map(|f| &f.kind)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let st = s.model.state();
|
||||
if let Some(own) = active_scope(s, &st).into_iter().find(|b| b.name == anchor.target.body) {
|
||||
return Ok(Some(own.name.clone()));
|
||||
}
|
||||
let Some(body) = st.body(&anchor.target.body) else { return Ok(None) };
|
||||
let component = s.doc.model_body_component(body);
|
||||
if !s.doc.component_within(component, s.active_component) {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = |c: u64| s.doc.components.iter().find(|x| x.id == c).map_or_else(|| format!("component {c}"), |x| x.name.clone());
|
||||
let mut c = component;
|
||||
for _ in 0..1000 {
|
||||
if c == s.active_component || c == 0 {
|
||||
break;
|
||||
}
|
||||
if s.doc.links.contains_key(&c) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"`{}` is in {}, which is linked to a design file: open that design to change it, or break the link first",
|
||||
body.name,
|
||||
name(c)
|
||||
)));
|
||||
}
|
||||
match s.doc.components.iter().find(|x| x.id == c) {
|
||||
Some(x) => c = x.parent,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !anchor.path.is_empty() && anchor.path != s.doc.first_path(component) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"`{}` is in {}, which is placed more than once, and the sketch is on another placement than the first: activate that placement of {} to cut or join it there",
|
||||
body.name,
|
||||
name(component),
|
||||
name(component)
|
||||
)));
|
||||
}
|
||||
Ok(Some(body.name.clone()))
|
||||
}
|
||||
|
||||
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
|
||||
/// it is one the active component works on ([`active_scope`]).
|
||||
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
|
||||
@@ -857,13 +979,20 @@ fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The bodies a Join named none for: the body whose face a profile sketch is on (the first
|
||||
/// sketch that is on one), as for an extrude. Explicit `targets` or `participants` keep theirs.
|
||||
fn face_join_targets(s: &Session, p: &Value, operation: Operation, sketches: &[u64]) -> Vec<BodyName> {
|
||||
/// sketch that is on one), as for an extrude, also in a component inside the active one, moved
|
||||
/// or turned (#5679; refused as [`face_body_reached`] says). Explicit `targets` or
|
||||
/// `participants` keep theirs.
|
||||
fn face_join_targets(s: &Session, p: &Value, operation: Operation, sketches: &[u64]) -> Result<Vec<BodyName>> {
|
||||
let mut targets = string_list(p, "targets");
|
||||
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
|
||||
targets.extend(sketches.iter().find_map(|sk| sketch_face_body(s, *sk)));
|
||||
for sketch in sketches {
|
||||
if let Some(body) = face_body_reached(s, *sketch)? {
|
||||
targets.push(body);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
targets.into_iter().map(BodyName).collect()
|
||||
Ok(targets.into_iter().map(BodyName).collect())
|
||||
}
|
||||
|
||||
/// The bodies a feature of the active component works on when it names none: the component's
|
||||
@@ -1031,25 +1160,36 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
|
||||
};
|
||||
let operation = operation(p, cmd)?;
|
||||
let targets = face_join_targets(s, p, operation, &[sketch])?;
|
||||
// From a profile on a body's face in a component inside the active one, a cut changes that
|
||||
// body too, as an extrude's does (#5679).
|
||||
let mut p = std::borrow::Cow::Borrowed(p);
|
||||
if let Some(parts) = cut_scope(s, &p, operation, sketch, None)? {
|
||||
p.to_mut()["participants"] = json!(parts);
|
||||
}
|
||||
let kind = FeatureKind::Revolve {
|
||||
sketch: SketchId(sketch),
|
||||
profiles,
|
||||
axis,
|
||||
angle,
|
||||
operation,
|
||||
targets: face_join_targets(s, p, operation, &[sketch]),
|
||||
targets,
|
||||
angle2,
|
||||
symmetric,
|
||||
convention,
|
||||
to: to.map(FacePick),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
add_feature(s, &p, kind)
|
||||
}
|
||||
|
||||
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
|
||||
/// world axis, a construction axis by name (followed when it is edited) or {origin, dir}. A line
|
||||
/// of the sketch wins over a construction axis of the same name.
|
||||
/// world axis, a construction axis by name (followed when it is edited), {origin, dir}, or an edge
|
||||
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
|
||||
/// changes. A line of the sketch wins over a construction axis of the same name.
|
||||
pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Result<AxisRef> {
|
||||
if let Some((geo, ..)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
|
||||
return Ok(AxisRef::Model { geo });
|
||||
}
|
||||
if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
|
||||
@@ -1060,7 +1200,9 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
|
||||
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
|
||||
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
|
||||
Some(id) => Ok(AxisRef::SketchLine { curve: id.into() }),
|
||||
None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis or {origin, dir}")),
|
||||
None => {
|
||||
Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,7 +1350,8 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
// of the body the feature names, or of any body: a pick far from every edge would round
|
||||
// whichever edge is nearest (#783). Each body's edges are worked out once.
|
||||
let named = str_(p, "body").filter(|n| st.body(n).is_some());
|
||||
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
|
||||
// Per body: its size, its edges' polylines and which of them are seams.
|
||||
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
|
||||
let mut out = Vec::new();
|
||||
for e in a {
|
||||
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
|
||||
@@ -1229,21 +1372,50 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
match b.body.display_mesh(tol) {
|
||||
Ok(m) => Some((size, m.edges, m.seams)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
|
||||
// them, picked silently (#5173). Edges that carry straight on through the point (the two
|
||||
// halves of a circle) are one edge to whoever picked it, and seams don't count. Directions
|
||||
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
|
||||
// of opposite counts as carrying on (edges at a real corner are far from opposite).
|
||||
let mut nearest = f64::INFINITY;
|
||||
let on_edge = bodies.iter().any(|(size, edges)| {
|
||||
let d = edges
|
||||
.iter()
|
||||
.flat_map(|e| e.points.windows(2))
|
||||
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
d <= size * 0.05 + 1e-3
|
||||
});
|
||||
if !on_edge && !bodies.is_empty() {
|
||||
let mut near = Vec::new();
|
||||
for (size, edges, seams) in bodies.iter() {
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
if d <= size * 0.05 + 1e-3 {
|
||||
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let matches = near.len();
|
||||
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
let mut chains = 0usize;
|
||||
while let Some(dir) = meeting.pop() {
|
||||
chains += 1;
|
||||
if let Some(dir) = dir
|
||||
&& let Some(j) = meeting.iter().position(|o| o.is_some_and(|other| other.dot(dir) < -0.7))
|
||||
{
|
||||
meeting.swap_remove(j);
|
||||
}
|
||||
}
|
||||
if chains > 1 {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
|
||||
));
|
||||
}
|
||||
if matches == 0 && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
}
|
||||
@@ -1252,6 +1424,19 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
|
||||
/// the edge runs on through `pt`.
|
||||
fn leaving(points: &[Vec3], pt: Vec3) -> Option<Vec3> {
|
||||
let (first, last) = (points.first()?, points.last()?);
|
||||
if first.dist(pt) <= 1e-3 {
|
||||
(*points.get(1)? - *first).normalized()
|
||||
} else if last.dist(pt) <= 1e-3 {
|
||||
(*points.get(points.len().checked_sub(2)?)? - *last).normalized()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge reference as given, before it is looked up: see [`edge_points`].
|
||||
pub(crate) enum EdgeRefForm<'a> {
|
||||
Point(Vec3),
|
||||
@@ -1556,17 +1741,41 @@ fn move_body_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(a) = &angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "angle")?;
|
||||
}
|
||||
add_feature(
|
||||
let copy = bool_(p, "copy").unwrap_or(false);
|
||||
let reply = add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Move {
|
||||
bodies: bodies.into_iter().map(BodyName).collect(),
|
||||
bodies: bodies.iter().cloned().map(BodyName).collect(),
|
||||
translate: t,
|
||||
rotate_axis: p.get("axis").and_then(vec3).map(Dir),
|
||||
angle,
|
||||
copy: bool_(p, "copy").unwrap_or(false),
|
||||
copy,
|
||||
},
|
||||
)
|
||||
)?;
|
||||
if copy {
|
||||
copy_keeps_appearance(s, &reply, &bodies, cmd)?;
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// A copy keeps the look and the material of the body it came from (#6423): the copy is the same
|
||||
/// part in another place, so it looks the same.
|
||||
fn copy_keeps_appearance(s: &mut Session, reply: &Value, sources: &[String], cmd: &str) -> Result<()> {
|
||||
let id = reply.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "the copy has no feature id"))?;
|
||||
let copies: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
|
||||
if copies.len() != sources.len() {
|
||||
return Err(bad(cmd, "the copy did not make one body per body it copied"));
|
||||
}
|
||||
for (source, copy) in sources.iter().zip(copies.iter()) {
|
||||
if let Some(look) = s.doc.appearances.bodies.get(source).cloned() {
|
||||
s.doc_mut().appearances.bodies.insert(copy.clone(), look);
|
||||
}
|
||||
if let Some(material) = s.doc.materials.get(source).cloned() {
|
||||
s.doc_mut().materials.insert(copy.clone(), material);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
@@ -1582,9 +1791,19 @@ fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// An axis line from its parameter, with what it follows: an edge or round face of the model
|
||||
/// ({edge|face: [x,y,z]}) or a construction axis by name ([`named_axis_line`]).
|
||||
fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
|
||||
if let Some((geo, o, d)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
|
||||
return Ok((o, d, Some(geo), None));
|
||||
}
|
||||
let (o, d, name) = named_axis_line(s, p, cmd)?;
|
||||
Ok((o, d, None, name))
|
||||
}
|
||||
|
||||
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
|
||||
/// axis comes back where it is now, with its name.
|
||||
fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
|
||||
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
|
||||
match p.get("axis") {
|
||||
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
|
||||
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
|
||||
@@ -1597,7 +1816,7 @@ fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<Fe
|
||||
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
|
||||
Ok((origin, dir, Some(a.as_str().into())))
|
||||
}
|
||||
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis or {origin, dir}")),
|
||||
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
|
||||
},
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = match o.get("origin") {
|
||||
@@ -1691,7 +1910,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.pattern.circular";
|
||||
let (features, bodies) = pattern_sources(s, p, cmd)?;
|
||||
let (origin, axis, axis_feature) = axis_line(s, p, cmd)?;
|
||||
let (origin, axis, axis_ref, axis_feature) = axis_line(s, p, cmd)?;
|
||||
let count = req_expr(cmd, p, "count")?;
|
||||
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
|
||||
@@ -1703,6 +1922,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
angle,
|
||||
alignment: super::alignment_param(p, "alignment", cmd)?,
|
||||
reversed: super::reversed_param(p, "reversed", cmd)?,
|
||||
axis_ref,
|
||||
axis_feature,
|
||||
};
|
||||
finish_pattern(s, p, features, pattern, bodies)
|
||||
@@ -1895,24 +2115,22 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.at_angle";
|
||||
// The plane keeps the axis line as it is now.
|
||||
let (axis_origin, axis_dir, _) = axis_line(s, p, cmd)?;
|
||||
// The plane follows an edge or face it is on; a construction axis is kept as it is now.
|
||||
let (axis_origin, axis_dir, line, _) = axis_line(s, p, cmd)?;
|
||||
let angle = req_expr(cmd, p, "angle")?;
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
let line = line.map(Box::new);
|
||||
// A planar body face: attached exactly, as a sketch on that face is, so the plane follows it.
|
||||
if let Some(face) = p.get("base").filter(|b| b.get("face").is_some()) {
|
||||
let base = super::sketch::face::capture(s, face, cmd)?;
|
||||
super::sketch::face::capture_positions(s, &base)?;
|
||||
let kind = FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } };
|
||||
let plane = solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle, line };
|
||||
// The capture is already in the authoring frame.
|
||||
return add_feature_in(s, p, kind, false);
|
||||
return add_feature_in(s, p, FeatureKind::ConstructionPlane { plane }, false);
|
||||
}
|
||||
let base = plane_param(s, p.get("base"), cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } },
|
||||
)
|
||||
let plane = solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle, line };
|
||||
add_feature(s, p, FeatureKind::ConstructionPlane { plane })
|
||||
}
|
||||
|
||||
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -2121,6 +2339,10 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let handedness = thread_handedness(p, cmd)?;
|
||||
if thread.is_none() && !handedness.is_right() {
|
||||
return Err(bad(cmd, "`handedness` needs `thread` (a tapped hole)"));
|
||||
}
|
||||
if let Some(t) = &thread
|
||||
&& solidcraft_doc::parse_tapped_thread(t).is_none()
|
||||
{
|
||||
@@ -2293,6 +2515,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
points: points.map(|x| x.0),
|
||||
thread,
|
||||
thread_depth,
|
||||
handedness,
|
||||
to: to.map(FacePick),
|
||||
references,
|
||||
},
|
||||
@@ -2434,7 +2657,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
|
||||
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?;
|
||||
let operation = operation(p, cmd)?;
|
||||
let targets = face_join_targets(s, p, operation, &[sketch]);
|
||||
let targets = face_join_targets(s, p, operation, &[sketch])?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
@@ -2572,10 +2795,17 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (rails, rail_fit) = guide_rails(s, p, cmd, "loft", None)?;
|
||||
let operation = operation(p, cmd)?;
|
||||
let sketches: Vec<u64> = sections.iter().map(|sec| sec.sketch.0).collect();
|
||||
let targets = face_join_targets(s, p, operation, &sketches);
|
||||
let targets = face_join_targets(s, p, operation, &sketches)?;
|
||||
add_feature(s, p, FeatureKind::Loft { sections, operation, targets, rails, rail_fit })
|
||||
}
|
||||
|
||||
fn thread_handedness(p: &Value, cmd: &str) -> Result<solidcraft_doc::ThreadHandedness> {
|
||||
match p.get("handedness") {
|
||||
None => Ok(solidcraft_doc::ThreadHandedness::Right),
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`handedness` must be `right` or `left`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.thread";
|
||||
let face = p
|
||||
@@ -2598,7 +2828,8 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(cmd, msg));
|
||||
}
|
||||
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled })?;
|
||||
let handedness = thread_handedness(p, cmd)?;
|
||||
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled, handedness })?;
|
||||
let st = s.model.state();
|
||||
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
|
||||
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
|
||||
@@ -2687,6 +2918,10 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[path = "patterns_tests.rs"]
|
||||
mod patterns_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_axis_tests.rs"]
|
||||
mod model_axis_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "prism_local_tests.rs"]
|
||||
mod prism_local_tests;
|
||||
|
||||
@@ -52,7 +52,22 @@ fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
|
||||
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"), "an existing active-component body keeps its original participant scope");
|
||||
// The picked part takes part beside the active component's own body (#5679): going in cuts
|
||||
// it, and the top level's own body is left alone.
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("cut"), "the picked body takes part beside the active component's own");
|
||||
for operation in ["auto", "cut"] {
|
||||
let mut t = s.scratch();
|
||||
let mut p = params.clone();
|
||||
p["operation"] = json!(operation);
|
||||
run(&mut t, "solid.extrude", p);
|
||||
let volume = |name: &str| solidcraft_kernel::measure(&t.model.state().body(name).unwrap().body).unwrap().volume;
|
||||
assert!(
|
||||
(volume("Stock") - 800.).abs() < 1e-6 && (volume("Other") - 1000.).abs() < 1e-6,
|
||||
"{operation}: {} {}",
|
||||
volume("Stock"),
|
||||
volume("Other")
|
||||
);
|
||||
}
|
||||
params["participants"] = json!(["Stock"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("cut"));
|
||||
params["participants"] = json!(["Other"]);
|
||||
@@ -160,6 +175,183 @@ fn every_inward_extrude_into_a_part_in_another_component_cuts_it() {
|
||||
assert!(wrong.is_empty(), "{} wrong:\n{}", wrong.len(), wrong.join("\n"));
|
||||
}
|
||||
|
||||
/// #5679's part: a 10 mm cube `Stock` in component Part, its placement moved to (30, 5, 2) and
|
||||
/// turned 35 degrees about (1, 2, 0), with the top level active; with `base`, the top level has a
|
||||
/// body of its own, a 20 x 20 x 5 `Base` at (-40, -40, 0). Returns Part's placement.
|
||||
fn turned_part(base: bool) -> (Session, solidcraft_doc::Mat) {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": part["occurrence"], "translate": [30, 5, 2], "axis": [1, 2, 0], "angle": "35 deg"}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
if base {
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 5, "corner": [-40, -40, 0], "operation": "new", "body_name": "Base"}));
|
||||
}
|
||||
let placed = s.doc.component_transform(part["component"].as_u64().unwrap());
|
||||
(s, placed)
|
||||
}
|
||||
|
||||
/// A sketch on the face at world point `at`, with `draw` given the point's place in the sketch.
|
||||
fn sketch_on_face(s: &mut Session, at: solidcraft_geom::Vec3, draw: impl FnOnce(&mut Session, solidcraft_geom::Vec2, solidcraft_geom::Plane)) {
|
||||
run(s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
|
||||
let plane = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(*plane),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
draw(s, plane.to_local(at), plane);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn body_volume(s: &Session, name: &str) -> f64 {
|
||||
solidcraft_kernel::measure(&s.model.state().body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
/// #5679: with the top level active, a circle sketched on the top face of a part in a moved and
|
||||
/// turned sub-component reads as a Cut going in and a Join going out, and either one, automatic
|
||||
/// or chosen, changes that part's body in its own component: the hole is where the circle is
|
||||
/// shown, not where the numbers would be in the part's frame. The top level's own body is left
|
||||
/// alone, whether it has one or not.
|
||||
#[test]
|
||||
fn a_sketch_on_a_turned_sub_components_face_cuts_and_joins_it_from_the_top_level() {
|
||||
let hole = std::f64::consts::PI * 4.0 * 3.0;
|
||||
for base in [false, true] {
|
||||
let (mut s, placed) = turned_part(base);
|
||||
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
|
||||
sketch_on_face(&mut s, top, |s, c, _| {
|
||||
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
|
||||
});
|
||||
for (distance, expected) in [(-3.0, "cut"), (3.0, "join")] {
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": distance})), Some(expected), "base {base}, {distance}");
|
||||
for operation in ["auto", expected] {
|
||||
let mut t = s.scratch();
|
||||
run(&mut t, "solid.extrude", json!({"distance": distance, "operation": operation}));
|
||||
let case = format!("base {base}, {distance} {operation}");
|
||||
let names: Vec<String> = t.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, if base { vec!["Stock", "Base"] } else { vec!["Stock"] }, "{case}");
|
||||
let want = if expected == "cut" { 1000.0 - hole } else { 1000.0 + hole };
|
||||
assert!((body_volume(&t, "Stock") - want).abs() < 1e-3 * hole, "{case}: {}", body_volume(&t, "Stock"));
|
||||
// In the part's own frame: through the middle of its top, not beside it.
|
||||
let stock = t.model.state().body("Stock").unwrap().mesh();
|
||||
let at = |x: f64, y: f64, z: f64| stock.contains(solidcraft_geom::Vec3::new(x, y, z));
|
||||
if expected == "cut" {
|
||||
assert!(!at(5.0, 5.0, 8.0) && at(5.0, 5.0, 6.0) && at(1.0, 1.0, 9.0), "{case}");
|
||||
} else {
|
||||
assert!(at(5.0, 5.0, 12.0) && !at(1.0, 1.0, 12.0), "{case}");
|
||||
}
|
||||
if base {
|
||||
assert!((body_volume(&t, "Base") - 2000.0).abs() < 1e-6, "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #5679: a revolve from the top level, from a profile on the turned part's top face, joins or
|
||||
/// cuts that part. A 6 x 1 rectangle turned about its long edge, which lies on the face, is a
|
||||
/// cylinder of radius 1 half above the face and half in the part: a Join adds 3π, a Cut takes it.
|
||||
#[test]
|
||||
fn a_revolve_from_a_turned_sub_components_face_joins_and_cuts_it_from_the_top_level() {
|
||||
let half = std::f64::consts::PI * 3.0;
|
||||
for (operation, want) in [("join", 1000.0 + half), ("cut", 1000.0 - half)] {
|
||||
let (mut s, placed) = turned_part(true);
|
||||
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
|
||||
let mut axis = json!(null);
|
||||
sketch_on_face(&mut s, top, |s, c, plane| {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [c.x - 3.0, c.y], "p1": [c.x + 3.0, c.y + 1.0]}));
|
||||
axis = json!({"origin": [top.x, top.y, top.z], "dir": [plane.x.x, plane.x.y, plane.x.z]});
|
||||
});
|
||||
run(&mut s, "solid.revolve", json!({"axis": axis, "operation": operation}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Stock", "Base"], "{operation}");
|
||||
assert_eq!(s.model.state().body("Stock").map(|b| b.body.shell_count()), Some(1), "{operation}");
|
||||
assert!((body_volume(&s, "Stock") - want).abs() < 1e-3 * half, "{operation}: {}", body_volume(&s, "Stock"));
|
||||
assert!((body_volume(&s, "Base") - 2000.0).abs() < 1e-6, "{operation}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5679 for Loft (and Sweep, through the same join target): with the top level active, a loft
|
||||
/// from a circle on the turned part's top face to one 5 mm above joins the part, in one piece.
|
||||
#[test]
|
||||
fn a_loft_from_a_turned_sub_components_face_joins_it_from_the_top_level() {
|
||||
let (mut s, placed) = turned_part(true);
|
||||
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
|
||||
let mut centre = solidcraft_geom::Vec2::new(0.0, 0.0);
|
||||
sketch_on_face(&mut s, top, |s, c, _| {
|
||||
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
|
||||
centre = c;
|
||||
});
|
||||
let low = s.doc.features.iter().rev().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
|
||||
let high = run(&mut s, "sketch.create", json!({"plane": {"face": [top.x, top.y, top.z]}, "offset": 5, "project_edges": false}))["sketch"].clone();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": low}, {"sketch": high}], "operation": "join"}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Stock", "Base"]);
|
||||
assert_eq!(s.model.state().body("Stock").map(|b| b.body.shell_count()), Some(1));
|
||||
let added = std::f64::consts::PI * 4.0 * 5.0;
|
||||
assert!((body_volume(&s, "Stock") - 1000.0 - added).abs() < 1e-3 * added, "{}", body_volume(&s, "Stock"));
|
||||
assert!((body_volume(&s, "Base") - 2000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// #5679: the part changes in its component, so a second placement of it shows the hole too. A
|
||||
/// sketch on that second placement's face would draw the hole where the first one is: the cut
|
||||
/// is refused with the reason, and the design stays as it was.
|
||||
#[test]
|
||||
fn a_cut_from_the_top_level_shows_in_every_placement_and_a_later_placement_is_refused() {
|
||||
let (mut s, placed) = turned_part(true);
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": "Part", "translate": [0, 40, 0]}))["occurrence"].as_u64().unwrap();
|
||||
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
|
||||
let mut first = s.scratch();
|
||||
sketch_on_face(&mut first, top, |s, c, _| {
|
||||
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
|
||||
});
|
||||
run(&mut first, "solid.extrude", json!({"distance": -3, "operation": "cut"}));
|
||||
let world = first.world_state();
|
||||
let placements: Vec<f64> =
|
||||
world.bodies.iter().filter(|b| b.definition_name() == "Stock").map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).collect();
|
||||
let want = 1000.0 - std::f64::consts::PI * 4.0 * 3.0;
|
||||
assert_eq!(placements.len(), 2, "{placements:?}");
|
||||
assert!(placements.iter().all(|v| (v - want).abs() < 1e-2), "{placements:?}");
|
||||
|
||||
let (_, moved) = s.doc.path_placement(&[second]).unwrap();
|
||||
let top = solidcraft_doc::apply_point(&moved, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
|
||||
sketch_on_face(&mut s, top, |s, c, _| {
|
||||
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
|
||||
});
|
||||
let features = s.doc.features.len();
|
||||
for operation in ["cut", "join"] {
|
||||
let refused = s.execute("solid.extrude", &json!({"distance": -3, "operation": operation})).unwrap_err().to_string();
|
||||
assert!(refused.contains("placed more than once") && refused.contains("activate"), "{refused}");
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
assert!((body_volume(&s, "Stock") - 1000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// #5679: with the top level's own body as the target, nothing changes: a cut from a sketch on
|
||||
/// its face keeps the default scope (no bodies stored), cuts it, and leaves the part alone.
|
||||
#[test]
|
||||
fn a_sketch_on_a_top_level_body_beside_a_turned_part_cuts_only_that_body() {
|
||||
let (mut s, _) = turned_part(true);
|
||||
sketch_on_face(&mut s, solidcraft_geom::Vec3::new(-30.0, -30.0, 5.0), |s, c, _| {
|
||||
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
|
||||
});
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": -2})), Some("cut"));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": 2})), Some("join"));
|
||||
let made = run(&mut s, "solid.extrude", json!({"distance": -2, "operation": "auto"}));
|
||||
let feature = s.doc.feature(made["feature"].as_u64().unwrap()).unwrap();
|
||||
assert!(feature.participants.is_empty(), "{:?}", feature.participants);
|
||||
let hole = std::f64::consts::PI * 4.0 * 2.0;
|
||||
assert!((body_volume(&s, "Base") - (2000.0 - hole)).abs() < 1e-3 * hole, "{}", body_volume(&s, "Base"));
|
||||
assert!((body_volume(&s, "Stock") - 1000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
|
||||
@@ -36,6 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as)
|
||||
.icon("save")
|
||||
.key("Ctrl+Shift+S")
|
||||
.noundo()
|
||||
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"),
|
||||
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
|
||||
@@ -304,8 +305,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(stem) = default_name_for(&doc.name, path) {
|
||||
doc.name = stem;
|
||||
}
|
||||
s.replace_design(Session::opened(doc, saved.as_ref()));
|
||||
s.path = Some(path.to_string());
|
||||
s.replace_design(Session::opened_at(doc, saved.as_ref(), Some(path)));
|
||||
s.disk = disk;
|
||||
s.read_only = read_only;
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
@@ -402,16 +402,7 @@ fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, prev
|
||||
"compact" => solidcraft_io::DesignCompression::Compact,
|
||||
_ => return Err(bad("file.save", "compression must be fast or compact")),
|
||||
};
|
||||
// Linked designs are kept relative to the folder the design is saved in.
|
||||
let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path);
|
||||
let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links));
|
||||
let r = write_bytes(s, path, format, compression, preview, cache);
|
||||
if r.is_err()
|
||||
&& let Some(old) = old_links
|
||||
{
|
||||
s.doc_mut().links = old;
|
||||
}
|
||||
r
|
||||
write_bytes(s, path, format, compression, preview, cache)
|
||||
}
|
||||
|
||||
fn write_bytes(
|
||||
@@ -423,8 +414,8 @@ fn write_bytes(
|
||||
cache: bool,
|
||||
) -> Result<Value> {
|
||||
let bytes = match format {
|
||||
"container" => s.design_container(compression, preview, cache)?,
|
||||
"json" => solidcraft_io::write_design(&s.design()),
|
||||
"container" => s.design_container_at(Some(path), compression, preview, cache)?,
|
||||
"json" => solidcraft_io::write_design(&s.design_at(Some(path))),
|
||||
_ => return Err(bad("file.save", "format must be container or json")),
|
||||
};
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`.
|
||||
@@ -437,18 +428,39 @@ fn write_bytes(
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Open a design with its file location, resolving component links before history keeps
|
||||
/// them. Absolute in-memory targets survive Save As, undo, redo and recovery unchanged.
|
||||
pub fn opened_at(mut doc: Document, saved: Option<&solidcraft_doc::SavedModel>, path: Option<&str>) -> Self {
|
||||
super::component_link::resolve_design_links(&mut doc, path);
|
||||
let mut session = Self::opened(doc, saved);
|
||||
session.path = path.map(str::to_owned);
|
||||
session
|
||||
}
|
||||
|
||||
/// Prepare only the file's copy with relative paths; saving never rewrites live history.
|
||||
pub(crate) fn design_at(&self, path: Option<&str>) -> std::borrow::Cow<'_, Document> {
|
||||
let mut design = std::borrow::Cow::Borrowed(&*self.doc);
|
||||
if let Some(path) = path {
|
||||
let links = super::component_link::rebased(&self.doc, self.path.as_deref(), path);
|
||||
if links != self.doc.links {
|
||||
design.to_mut().links = links;
|
||||
}
|
||||
}
|
||||
if !self.visibility.is_empty() {
|
||||
design.to_mut().visibility = self.visibility.clone();
|
||||
}
|
||||
design
|
||||
}
|
||||
|
||||
/// Take up a save that ran on `done`, a [`Session::scratch`] copy of this session that
|
||||
/// `file.save` or `file.save_as` ran on (on another thread, so the window keeps drawing)
|
||||
/// while this one did not change: the name and link paths the save gave the design, its
|
||||
/// while this one did not change: the name the save gave the design, its
|
||||
/// file, and its saved state, as running the command here would have left them.
|
||||
pub fn adopt_save(&mut self, done: &Session) {
|
||||
if done.doc.name != self.doc.name {
|
||||
let (from, to) = (self.doc.name.clone(), done.doc.name.clone());
|
||||
rename_design(self, &from, &to);
|
||||
}
|
||||
if done.doc.links != self.doc.links {
|
||||
self.doc_mut().links.clone_from(&done.doc.links);
|
||||
}
|
||||
self.path.clone_from(&done.path);
|
||||
self.disk = done.disk;
|
||||
self.read_only = done.read_only;
|
||||
@@ -467,7 +479,17 @@ impl Session {
|
||||
/// Container bytes for desktop and browser saves. Only already evaluated bodies
|
||||
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
|
||||
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
|
||||
let design = self.design();
|
||||
self.design_container_at(self.path.as_deref(), compression, preview, cache)
|
||||
}
|
||||
|
||||
fn design_container_at(
|
||||
&self,
|
||||
path: Option<&str>,
|
||||
compression: solidcraft_io::DesignCompression,
|
||||
preview: bool,
|
||||
cache: bool,
|
||||
) -> solidcraft_io::Result<Vec<u8>> {
|
||||
let design = self.design_at(path);
|
||||
let geometry = cache.then(|| saved_geometry(self, &design)).flatten();
|
||||
let preview = preview.then(|| saved_preview(self)).flatten();
|
||||
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
|
||||
@@ -708,8 +730,7 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
|
||||
doc.name = stem;
|
||||
}
|
||||
s.replace_design(Session::new(doc));
|
||||
s.path = entry.path.clone();
|
||||
s.replace_design(Session::opened_at(doc, None, entry.path.as_deref()));
|
||||
s.mark_unsaved();
|
||||
if bool_(p, "discard").unwrap_or(false) {
|
||||
crate::recovery::remove(&dir, id)?;
|
||||
|
||||
@@ -30,7 +30,7 @@ macro_rules! profile_doc {
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("frame.member", "Frame Member", member).at("SOLID", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
|
||||
CommandSpec::new("frame.member", "Frame Member", member).at("WELDMENTS", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
|
||||
"sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ",
|
||||
profile_doc!(),
|
||||
" | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! Inspection: measure bodies, describe the document, sketches and model topology.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{BuildHasher, RandomState};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_sketch::CurveKind;
|
||||
|
||||
@@ -18,6 +23,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.params("bodies?: [names] (default all) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements)"),
|
||||
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
|
||||
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
|
||||
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
|
||||
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
|
||||
),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).icon("inspect").noundo().params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("model.edges", "List Edges", model_edges).icon("inspect").noundo().params("body: name"),
|
||||
CommandSpec::new("model.faces", "List Faces", model_faces).icon("inspect").noundo().params(
|
||||
@@ -226,7 +236,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
|
||||
"visible": crate::view::sketch_shown(s, id),
|
||||
}))
|
||||
}
|
||||
@@ -241,6 +251,59 @@ fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sketch_json(s, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
|
||||
}
|
||||
|
||||
/// Revisions of every design of this run (#6646).
|
||||
static NEXT_REVISION: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// The design as `document.revision` last saw it, which its revision names.
|
||||
pub(crate) struct Followed {
|
||||
/// Held weakly: a design something holds weakly is never changed in place (`Arc::make_mut`
|
||||
/// moves it), and its address can't go to another design meanwhile.
|
||||
doc: Weak<solidcraft_doc::Document>,
|
||||
visibility: crate::Visibility,
|
||||
pending_moves: BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// A new design's id: one for this run, then a number, so no two designs of one run, or of two
|
||||
/// runs, have the same.
|
||||
pub(crate) fn new_document_id() -> String {
|
||||
static RUN: OnceLock<u64> = OnceLock::new();
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
let run = RUN.get_or_init(|| {
|
||||
let started = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
// Hashed with this process's random keys (where the system has them), so two runs differ
|
||||
// even with the clock set back.
|
||||
RandomState::new().hash_one(started)
|
||||
});
|
||||
format!("{run:016x}-{}", NEXT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The revision of what the design holds now (`document.revision`), a new one when it changed
|
||||
/// since last asked. Changes between two asks that end where they began (an edit undone) need
|
||||
/// no new one: the design is the one that revision named.
|
||||
pub fn design_revision(&mut self) -> u64 {
|
||||
let seen = |f: &Followed| {
|
||||
std::ptr::eq(f.doc.as_ptr(), Arc::as_ptr(&self.doc)) && f.visibility == self.visibility && f.pending_moves == self.pending_moves
|
||||
};
|
||||
match self.followed.as_ref().filter(|f| seen(f)) {
|
||||
Some(f) => f.revision,
|
||||
None => {
|
||||
let revision = NEXT_REVISION.fetch_add(1, Ordering::Relaxed);
|
||||
let (doc, visibility, pending_moves) = (Arc::downgrade(&self.doc), self.visibility.clone(), self.pending_moves.clone());
|
||||
self.followed = Some(Followed { doc, visibility, pending_moves, revision });
|
||||
revision
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.revision`: the design's id and [`Session::design_revision`].
|
||||
fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let revision = s.design_revision();
|
||||
Ok(json!({"document_id": s.document_id, "revision": revision}))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
|
||||
@@ -667,6 +667,65 @@ fn slotted_arm_drives(yaw_first: bool, gap: f64) {
|
||||
assert!(arm > 19.9 && arm <= 20.0 + 1e-9, "yaw first {yaw_first}, gap {gap}: the arm stopped at {arm}°");
|
||||
}
|
||||
|
||||
/// The four-bar with its rocker a rod on an axle (a cylindrical joint), and the rocker pin
|
||||
/// joining the coupler to the rod 4 mm up the axle from where the rod sits, its axis turned
|
||||
/// `tilt` rad from the others about Y. Returns the closing joint's answer and the rocker pin.
|
||||
fn rod_on_an_axle(tilt: f64) -> (Session, Value, Vec3) {
|
||||
let mut s = Session::default();
|
||||
let (o2, o4, p0) = (Vec3::ZERO, Vec3::new(45.0, 0.0, 0.0), Vec3::new(0.0, 20.0, 0.0));
|
||||
let [q0, _] = meet(p0, 50.0, o4, 40.0);
|
||||
for (name, c) in [("Ground", o2), ("Crank", p0), ("Coupler", (p0 + q0) * 0.5), ("Rod", q0)] {
|
||||
part(&mut s, name, [c.x, c.y, -5.0]);
|
||||
}
|
||||
let g = occ(&s, "Ground");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
pin(&mut s, "revolute", "Crank", "Ground", "Crank", o2, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Crank pin", "Crank", "Coupler", p0, Vec3::Z);
|
||||
pin(&mut s, "cylindrical", "Axle", "Ground", "Rod", o4, Vec3::Z);
|
||||
let z = [tilt.sin(), 0.0, tilt.cos()];
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": "Coupler", "point": [q0.x, q0.y, 0.0], "z": z},
|
||||
"b": {"occurrence": "Rod", "point": [q0.x, q0.y, 4.0], "z": z}, "flip": true, "name": "Rocker pin"}),
|
||||
);
|
||||
(s, r, q0)
|
||||
}
|
||||
|
||||
/// Axes of holes picked on parts are seldom exactly parallel (#6211: a few hundredths of a
|
||||
/// microradian apart on a lever). A closed loop through them leaves a gap of nanometres no value
|
||||
/// takes out; it still closes, the rod sliding down its axle, and follows its crank. A tilt that
|
||||
/// shows still locks it.
|
||||
#[test]
|
||||
fn a_loop_whose_axes_are_a_hair_out_of_parallel_still_moves() {
|
||||
let (mut s, r, q0) = rod_on_an_axle(1e-7);
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let up = Vec3::new(0.0, 0.0, 4.0);
|
||||
let close = |a: Vec3, b: Vec3| a.dist(b) < 1e-4;
|
||||
assert!(close(place(&s, "Rod", q0 + up), q0), "{:?}", place(&s, "Rod", q0 + up));
|
||||
for part in ["Crank", "Coupler", "Rod"] {
|
||||
assert_eq!(dof_of(&mut s, part), 1, "{part}");
|
||||
}
|
||||
for v in [30.0, 60.0, -45.0] {
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v}));
|
||||
assert!(r["stopped_by_loop"].is_null() && r["conflicts"].as_array().map(Vec::len) == Some(0), "{v}°: {r}");
|
||||
let q = rocker_pin(0.0, v, q0);
|
||||
assert!(close(place(&s, "Rod", q0 + up), q), "{v}°: {:?} wants {q:?}", place(&s, "Rod", q0 + up));
|
||||
}
|
||||
// Dragged by its crank pin (the viewport's drag of a part joints hold), the rod follows too.
|
||||
let (at, th) = (place(&s, "Crank", Vec3::new(0.0, 20.0, 0.0)), 110f64.to_radians());
|
||||
let r = run(&mut s, "joint.reach", json!({"occurrence": "Crank", "at": [at.x, at.y, at.z], "point": [20.0 * th.cos(), 20.0 * th.sin(), 0.0]}));
|
||||
assert!(r["reached"] == json!(true) && r["stopped_by_loop"].is_null(), "{r}");
|
||||
let q = rocker_pin(0.0, 20.0, q0);
|
||||
assert!(close(place(&s, "Rod", q0 + up), q), "reach: {:?} wants {q:?}", place(&s, "Rod", q0 + up));
|
||||
// Tilted a milliradian, the loop is locked: no longer planar, it can't turn.
|
||||
let (mut s, r, _) = rod_on_an_axle(1e-3);
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert_eq!(dof_of(&mut s, "Rod"), 0);
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 30}));
|
||||
assert!(r["stopped_by_loop"].is_object() && r["values"][0]["value"].as_f64().is_some_and(|v| v.abs() < 0.1), "{r}");
|
||||
}
|
||||
|
||||
/// A joint frame on an occurrence, by origin and axes.
|
||||
fn framed(o: &str, origin: [f64; 3], z: [f64; 3], x: [f64; 3]) -> Value {
|
||||
json!({"occurrence": o, "frame": {"origin": origin, "z": z, "x": x}})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Design properties (#1015): `document.metadata` reads the design's `metadata` object, and
|
||||
//! `document.metadata.set` and `document.metadata.remove` change it, each in one undo step. The
|
||||
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s; keys
|
||||
//! other tools wrote are kept as they are. The UI's Design Properties dialog, scripts and MCP
|
||||
//! agents all go through these commands.
|
||||
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s
|
||||
//! (`KEYS`, the rows the design takes); keys other tools wrote are kept as they are. The UI's Design
|
||||
//! Properties dialog, scripts and MCP agents all go through these commands. A change evaluates
|
||||
//! nothing: properties are no geometry.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
|
||||
use solidcraft_doc::metadata::{self, MAX_KEYS, Owner};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
@@ -13,13 +14,13 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: project, part_number, revision, \
|
||||
author, organization, description and license, each its text or null (absent, or not text)",
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys: project, part_number, \
|
||||
revision, author, organization, description, license (text; \"\" or null removes); another tool's data goes under its \
|
||||
reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
|
||||
.icon("delete")
|
||||
@@ -29,7 +30,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn read(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let m = &s.doc.metadata;
|
||||
let properties: Map<String, Value> = WELL_KNOWN.iter().map(|k| (k.to_string(), m.text(k).map_or(Value::Null, |t| json!(t)))).collect();
|
||||
let properties: Map<String, Value> = metadata::keys(Owner::Design).map(|k| (k.name.to_string(), json!(m.text(k.name)))).collect();
|
||||
Ok(json!({"metadata": m, "properties": properties}))
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut next = s.doc.metadata.clone();
|
||||
let mut changed = Vec::new();
|
||||
for (key, value) in entries {
|
||||
if next.set(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
if next.set(Owner::Design, key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
changed.push(key.to_string());
|
||||
}
|
||||
}
|
||||
@@ -252,6 +253,80 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_design_properties_does_not_evaluate_the_model_again() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
s.evaluations = 0;
|
||||
let revision = s.revision;
|
||||
run(&mut s, "document.metadata.set", json!({"values": {"author": "Jane", "com.acme.tool": {"id": 7}}}));
|
||||
run(&mut s, "document.metadata.remove", json!({"key": "com.acme.tool"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.evaluations, 0, "design properties change no geometry");
|
||||
assert!(s.revision > revision, "the views still see each change");
|
||||
assert_eq!(s.model.state().bodies.len(), 1, "the model is kept as it was");
|
||||
assert_eq!(s.doc.metadata.author(), Some("Jane"));
|
||||
// Anything else still evaluates.
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
|
||||
assert!(s.evaluations > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_design_that_differs_only_in_its_properties_refreshes_it() {
|
||||
// Two revisions of one assembly, B held on A by a rigid joint. The open session's B was
|
||||
// moved off the joint and never solved since; the file is the same design with another
|
||||
// author. Opening it is no property edit: the new session's joints are solved.
|
||||
let dir = scratch("revision");
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let placement =
|
||||
|s: &Session, name: &str| s.doc.find_component(name).and_then(|c| s.doc.occurrence_of(c)).map(|o| (o.id, o.transform)).unwrap();
|
||||
let a = placement(&s, "A").0;
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}}));
|
||||
let (b, solved) = placement(&s, "B");
|
||||
let mut unsolved = (*s.doc).clone();
|
||||
unsolved.name = "Bracket".into();
|
||||
if let Some(o) = unsolved.occurrences.iter_mut().find(|o| o.id == b) {
|
||||
o.transform[3][0] += 3.0;
|
||||
}
|
||||
let mut s = Session::new(unsolved.clone());
|
||||
let mut revision_c = unsolved;
|
||||
revision_c.metadata.set(Owner::Design, "author", json!("Jane")).unwrap();
|
||||
let path = dir.join("bracket-rev-c.json");
|
||||
std::fs::write(&path, revision_c.to_json()).unwrap();
|
||||
let (read, _) = solidcraft_io::read_design_file(path.to_str().unwrap(), None::<fn(solidcraft_io::DesignPreview)>).unwrap();
|
||||
assert!(read.differs_only_in_part_properties(&s.doc), "the two revisions differ only in their properties");
|
||||
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert_eq!(s.doc.metadata.author(), Some("Jane"));
|
||||
let opened = s.doc.occurrences.iter().find(|o| o.id == b).map(|o| o.transform).unwrap();
|
||||
let off = opened.iter().flatten().zip(solved.iter().flatten()).map(|(p, q)| (p - q).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 1e-6, "the opened design's joint is solved: B is {off} mm off it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_key_the_design_took_before_is_still_taken() {
|
||||
let mut s = Session::default();
|
||||
let keys = ["project", "part_number", "revision", "author", "organization", "description", "license"];
|
||||
let values: Map<String, Value> = keys.iter().map(|k| (k.to_string(), json!(format!("{k} text")))).collect();
|
||||
let r = run(&mut s, "document.metadata.set", json!({ "values": values }));
|
||||
assert_eq!(r["changed"].as_array().map(Vec::len), Some(keys.len()), "{r}");
|
||||
let properties = run(&mut s, "document.metadata", json!({}))["properties"].clone();
|
||||
for k in keys {
|
||||
assert_eq!(properties[k], format!("{k} text"), "{k}");
|
||||
}
|
||||
assert_eq!(properties.as_object().map(Map::len), Some(keys.len()), "{properties}");
|
||||
let refused = s.execute("document.metadata.set", &json!({"key": "colour", "value": "red"})).unwrap_err().to_string();
|
||||
assert!(refused.contains("`colour` is not a design property: SolidCraft's are project, part_number,"), "{refused}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_lists_the_metadata() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -44,10 +44,11 @@ pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
|
||||
pub use view_dxf::sketch::exportable as view_dxf_exportable;
|
||||
pub use view_navigation::standard_view;
|
||||
mod component_link;
|
||||
pub use component_link::resolve_design_links;
|
||||
mod config;
|
||||
mod file;
|
||||
mod frame;
|
||||
mod inspect;
|
||||
pub(crate) mod inspect;
|
||||
mod interference;
|
||||
pub(crate) mod joints;
|
||||
mod libraries;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! An axis on an edge or round face of the model (`{edge|face: [x,y,z]}`): Revolve, Surface
|
||||
//! Revolve, Circular Pattern and Plane at Angle turn about a circular edge's or a cylinder's
|
||||
//! axis, and follow it when a parameter moves the cylinder.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
use crate::view::construction_planes;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// A Ø10 × 10 shaft standing on XY at x = `cx` (a parameter, 40 mm).
|
||||
fn shaft() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "cx", "expression": "40 mm"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": ["cx", 0, 0], "radius": 5, "height": 10, "body_name": "Shaft"}));
|
||||
s
|
||||
}
|
||||
|
||||
const SHAFT: f64 = PI * 25.0 * 10.0;
|
||||
|
||||
/// A 2 × 6 rectangle on XZ, x 46…48, z 2…8: 6 to 8 mm from the shaft's axis.
|
||||
fn ring_profile(s: &mut Session) {
|
||||
run(s, "sketch.create", json!({"plane": "XZ", "name": "Profile"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [46, -2], "p1": [48, -8]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// Pappus: a 2 × 6 section whose centre is `r` from the axis, turned a full turn.
|
||||
fn ring(r: f64) -> f64 {
|
||||
2.0 * PI * r * 12.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_about_a_circular_edge_follows_the_cylinder() {
|
||||
let mut s = shaft();
|
||||
ring_profile(&mut s);
|
||||
// The shaft's top rim.
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"edge": [45, 0, 10]}, "operation": "new", "name": "Turn"}));
|
||||
assert!(rel(volume(&mut s), SHAFT + ring(7.0)) < 1e-4, "turned about the shaft's axis: {}", volume(&mut s));
|
||||
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "40.5 mm"}));
|
||||
assert!(rel(volume(&mut s), SHAFT + ring(6.5)) < 1e-4, "follows the shaft: {}", volume(&mut s));
|
||||
// Saved and opened again it still follows.
|
||||
let saved = s.doc.to_json();
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(rel(volume(&mut reopened), volume(&mut s)) < 1e-9);
|
||||
run(&mut reopened, "parameters.change", json!({"name": "cx", "expression": "40 mm"}));
|
||||
assert!(rel(volume(&mut reopened), SHAFT + ring(7.0)) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_about_a_cylinder_wall() {
|
||||
let mut s = shaft();
|
||||
ring_profile(&mut s);
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"face": [45, 0, 5]}, "operation": "new"}));
|
||||
assert!(rel(volume(&mut s), SHAFT + ring(7.0)) < 1e-4, "{}", volume(&mut s));
|
||||
let Some(solidcraft_doc::Feature { kind: solidcraft_doc::FeatureKind::Revolve { axis, .. }, .. }) = s.doc.features.last() else {
|
||||
panic!("no revolve")
|
||||
};
|
||||
assert!(matches!(axis, solidcraft_doc::AxisRef::Model { .. }), "kept as a reference: {axis:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_revolve_about_a_circular_edge() {
|
||||
let mut s = shaft();
|
||||
ring_profile(&mut s);
|
||||
run(&mut s, "surface.revolve", json!({"sketch": "Profile", "axis": {"edge": [45, 0, 0]}, "angle": "180 deg"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "41 mm"}));
|
||||
let st = s.world_state();
|
||||
// A half turn about x = 41: the far side of the profile ends up at x = 41 − 7 ± 1.
|
||||
let b = st.bodies.last().map(|b| b.mesh().bounds()).unwrap();
|
||||
assert!((b.min.x - (41.0 - 7.0)).abs() < 1e-3, "{b:?}");
|
||||
assert!((b.max.x - 48.0).abs() < 1e-3, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_pattern_about_a_boss_wall_follows_the_boss() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "cx", "expression": "100 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 200, "width": 200, "height": 5, "corner": [0, 0, 0], "body_name": "Plate"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": ["cx", 100, 0], "radius": 10, "height": 20, "operation": "join", "targets": ["Plate"]}));
|
||||
run(&mut s, "solid.hole", json!({"position": [130, 100, 5], "diameter": 2, "name": "Hole"}));
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Hole"], "axis": {"face": [110, 100, 10]}, "count": 4, "name": "Ring"}));
|
||||
let holes = |s: &Session, cx: f64, r: f64| {
|
||||
let st = s.world_state();
|
||||
let body = &st.bodies[0].body;
|
||||
[(r, 0.0), (0.0, r), (-r, 0.0), (0.0, -r)].iter().all(|(dx, dy)| !body.contains(Vec3::new(cx + dx, 100.0 + dy, 2.5)))
|
||||
};
|
||||
assert!(holes(&s, 100.0, 30.0), "four holes round the boss");
|
||||
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "90 mm"}));
|
||||
assert!(holes(&s, 90.0, 40.0), "the copies turn about the boss where it is now");
|
||||
let st = s.world_state();
|
||||
assert!(st.bodies[0].body.contains(Vec3::new(100.0, 130.0, 2.5)), "not where the boss was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_at_angle_about_a_cylinder_axis_follows_it() {
|
||||
let mut s = shaft();
|
||||
run(&mut s, "construct.plane.at_angle", json!({"base": "XZ", "axis": {"face": [45, 0, 5]}, "angle": "90 deg", "name": "Square"}));
|
||||
let plane = |s: &Session| construction_planes(s).into_iter().find(|p| p.1 == "Square").map(|p| p.2).unwrap();
|
||||
let pl = plane(&s);
|
||||
assert!(pl.normal().cross(Vec3::X).len() < 1e-9, "turned square to XZ about Z: {pl:?}");
|
||||
assert!(pl.height(Vec3::new(40.0, 0.0, 0.0)).abs() < 1e-6, "through the shaft's axis: {pl:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "25 mm"}));
|
||||
assert!(plane(&s).height(Vec3::new(25.0, 0.0, 0.0)).abs() < 1e-6, "follows the shaft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_at_angle_on_a_face_about_an_edge_follows_both() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "name": "Base"}));
|
||||
// The top face, turned 45° about its front edge (y = 0, along X).
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.at_angle",
|
||||
json!({"base": {"face": [5, 5, 20]}, "axis": {"edge": [20, 0, 20]}, "angle": "45 deg", "name": "Slanted"}),
|
||||
);
|
||||
let feature = s.doc.find_feature("Slanted").unwrap();
|
||||
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base, line, .. } } = &feature.kind else {
|
||||
panic!("at_angle plane: {:?}", feature.kind)
|
||||
};
|
||||
assert!(matches!(base.as_ref(), solidcraft_doc::PlaneRef::Face { anchor: Some(_), .. }), "{base:?}");
|
||||
assert!(line.is_some(), "the edge is kept as a model reference");
|
||||
let plane = |s: &Session| construction_planes(s).into_iter().find(|p| p.1 == "Slanted").map(|p| p.2).unwrap();
|
||||
let half = 0.5_f64.sqrt();
|
||||
let pl = plane(&s);
|
||||
assert!(pl.height(Vec3::new(20.0, 0.0, 20.0)).abs() < 1e-6, "through the edge: {pl:?}");
|
||||
assert!((pl.normal().z.abs() - half).abs() < 1e-9 && pl.normal().x.abs() < 1e-9, "45° to the top: {pl:?}");
|
||||
// A taller box: the face and the edge both move up.
|
||||
run(&mut s, "parameters.change", json!({"name": "h", "expression": "30 mm"}));
|
||||
let pl = plane(&s);
|
||||
assert!(pl.height(Vec3::new(20.0, 0.0, 30.0)).abs() < 1e-6, "follows the edge: {pl:?}");
|
||||
assert!((pl.normal().z.abs() - half).abs() < 1e-9, "{pl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_axis_keeps_its_turn_whichever_way_the_edge_runs() {
|
||||
// The top and bottom rims run opposite ways round the shaft; a quarter turn about either
|
||||
// lands in the same place.
|
||||
let quarter = |edge: [f64; 3]| {
|
||||
let mut s = shaft();
|
||||
ring_profile(&mut s);
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"edge": edge}, "angle": "90 deg", "operation": "new"}));
|
||||
let st = s.world_state();
|
||||
st.bodies.last().map(|b| b.mesh().bounds()).unwrap()
|
||||
};
|
||||
let (top, bottom) = (quarter([45.0, 0.0, 10.0]), quarter([45.0, 0.0, 0.0]));
|
||||
assert!(top.min.dist(bottom.min) < 1e-6 && top.max.dist(bottom.max) < 1e-6, "{top:?} vs {bottom:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_model_axes_are_refused_without_panicking() {
|
||||
let mut s = shaft();
|
||||
ring_profile(&mut s);
|
||||
let before = s.doc.to_json();
|
||||
for axis in [
|
||||
json!({"edge": [1e9, 0, 0]}),
|
||||
json!({"face": [1e300, -1e300, 0]}),
|
||||
json!({"edge": "top"}),
|
||||
json!({"face": [45, 0]}),
|
||||
// The flat top: no axis.
|
||||
json!({"face": [40, 0, 10]}),
|
||||
// The shaft's axis doesn't lie in a sketch on XY.
|
||||
] {
|
||||
for (cmd, p) in [
|
||||
("solid.revolve", json!({"sketch": "Profile", "axis": axis})),
|
||||
("surface.revolve", json!({"sketch": "Profile", "axis": axis})),
|
||||
("solid.pattern.circular", json!({"bodies": ["Shaft"], "axis": axis, "count": 3})),
|
||||
("construct.plane.at_angle", json!({"base": "XZ", "axis": axis, "angle": "10 deg"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
assert_eq!(s.doc.to_json(), before, "{cmd} {p} left the design as it was");
|
||||
}
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [46, 2], "p1": [48, 8]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.revolve", &json!({"sketch": "Flat", "axis": {"face": [45, 0, 5]}})).unwrap_err().to_string();
|
||||
assert!(e.contains("does not lie in the sketch plane"), "{e}");
|
||||
}
|
||||
@@ -52,6 +52,55 @@ fn component_create_rejects_unknown_keys_and_accepts_documented_keys() {
|
||||
assert_eq!(s.active_occurrence_path, Some(vec![occurrence]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_and_component_renames_keep_hidden_copies_through_history() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Knob");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Knob"}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap();
|
||||
let name = |s: &Session, path: &[u64]| s.occurrence_placement(path).unwrap().bodies[0].world.clone();
|
||||
let hidden = name(&s, &[second]);
|
||||
run(&mut s, "browser.visibility", json!({"items": [hidden], "visible": false}));
|
||||
for (command, params) in [
|
||||
("body.rename", json!({"body": "Knob", "name": "Grip"})),
|
||||
("component.rename", json!({"component": component, "name": "Dial"})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
] {
|
||||
run(&mut s, command, params);
|
||||
assert!(s.body_visible(&name(&s, &[first])), "{command}: the first copy stays visible");
|
||||
assert!(!s.body_visible(&name(&s, &[second])), "{command}: the hidden copy stays hidden");
|
||||
assert_eq!(s.visibility.hidden_bodies, [name(&s, &[second])], "no stale names after {command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_parent_preserves_hidden_nested_copies_with_ambiguous_labels() {
|
||||
let mut s = Session::default();
|
||||
let (parent, first_parent) = created(&mut s, "Assembly");
|
||||
let (_, child) = created(&mut s, "Knob");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Knob"}));
|
||||
let second_parent = run(&mut s, "occurrence.copy", json!({"component": parent, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap();
|
||||
let name = |s: &Session, path: &[u64]| s.occurrence_placement(path).unwrap().bodies[0].world.clone();
|
||||
let hidden = name(&s, &[second_parent, child]);
|
||||
run(&mut s, "browser.visibility", json!({"items": [hidden], "visible": false}));
|
||||
for (command, params) in [
|
||||
("component.rename", json!({"component": parent, "name": "Assembly/Other"})),
|
||||
("body.rename", json!({"body": "Knob", "name": "Grip (@occ[custom])"})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
] {
|
||||
run(&mut s, command, params);
|
||||
assert!(s.body_visible(&name(&s, &[first_parent, child])), "{command}");
|
||||
assert!(!s.body_visible(&name(&s, &[second_parent, child])), "{command}");
|
||||
assert_eq!(s.visibility.hidden_bodies, [name(&s, &[second_parent, child])]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_copy_uses_selected_source_transform() {
|
||||
let mut s = Session::default();
|
||||
@@ -595,6 +644,42 @@ fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #5679: a linked part's bodies come from its design file, so a cut or join from the top level
|
||||
/// on a sketch on its face is refused with the reason, and the plate stays as the file has it.
|
||||
#[test]
|
||||
fn a_cut_or_join_from_the_top_level_into_a_linked_part_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-linked-cut-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let part = dir.join("plate.solidcraft");
|
||||
saved_box_design(&part, 30.0);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, INSERT_DESIGN, json!({"path": part, "translate": [50, 0, 0]}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [55, 10, 30]}, "project_edges": false}));
|
||||
let plane = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(*plane),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let c = plane.to_local(solidcraft_geom::Vec3::new(55.0, 10.0, 30.0));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let features = s.doc.features.len();
|
||||
for (operation, distance) in [("cut", -5), ("join", 5)] {
|
||||
let refused = s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap_err().to_string();
|
||||
assert!(refused.contains("`Plate`") && refused.contains("linked to a design file"), "{operation}: {refused}");
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
let volume = run(&mut s, "inspect.measure", json!({"bodies": ["Plate"]}))["total"]["volume_mm3"].clone();
|
||||
near(&volume, 6000.0);
|
||||
}
|
||||
|
||||
/// The compact list counts what a component holds and leaves out identity transforms; the full
|
||||
/// list stays as it was.
|
||||
#[test]
|
||||
@@ -687,9 +772,9 @@ fn matrix_move_turns_a_quarter_and_moves() {
|
||||
}
|
||||
|
||||
/// #3223, #2872: a STEP assembly whose part sits two levels down, each level placed, opened and
|
||||
/// stood up. With the imported root active, as Open leaves it, a circle sketched on the part's
|
||||
/// top face cuts it through, reads as a Cut dragged in and a Join dragged out, and joins it in
|
||||
/// one piece instead of starting a body of the root's own.
|
||||
/// stood up. With the imported root active, as Open leaves it, or the top level, a circle sketched
|
||||
/// on the part's top face cuts it through, reads as a Cut dragged in and a Join dragged out, and
|
||||
/// joins it in one piece instead of starting a body of the active component's own.
|
||||
#[test]
|
||||
fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-nested-stood-{}", std::process::id()));
|
||||
@@ -705,40 +790,52 @@ fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
|
||||
let top = solidcraft_doc::apply_point(&src.doc.component_transform(part), solidcraft_geom::Vec3::new(20.0, 12.0, 10.0));
|
||||
run(&mut src, "file.export", json!({"path": path, "format": "step"}));
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
|
||||
let centre = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(top)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"));
|
||||
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let block = 40.0 * 24.0 * 10.0;
|
||||
let hole = std::f64::consts::PI * 3.0 * 3.0;
|
||||
// #5679: the same from the top level, where the part is two moved and turned levels down
|
||||
// (#5678 was this cut silently doing nothing; `auto` read it as a new body).
|
||||
for top_level in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
|
||||
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
|
||||
if top_level {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
}
|
||||
// The sketch is in the active component's frame: the imported root's, or the world's.
|
||||
let local = if top_level { at } else { top };
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
|
||||
let centre = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(local)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"), "top level {top_level}");
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"), "top level {top_level}");
|
||||
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let block = 40.0 * 24.0 * 10.0;
|
||||
let hole = std::f64::consts::PI * 3.0 * 3.0;
|
||||
|
||||
// The circle is measured as faceted: within a thousandth of what it removes or adds.
|
||||
for operation in ["cut", "auto"] {
|
||||
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
|
||||
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{operation}: {}", volume(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// The circle is measured as faceted: within a thousandth of what it removes or adds.
|
||||
for operation in ["cut", "auto"] {
|
||||
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
|
||||
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{top_level} {operation}: {}", volume(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
for operation in ["join", "auto"] {
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": operation}));
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"], "{top_level} {operation}");
|
||||
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1), "{top_level} {operation}");
|
||||
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "{top_level} {operation}: {}", volume(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
}
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"]);
|
||||
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1));
|
||||
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "join: {}", volume(&mut s));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user