Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65d288227e | |||
| fedb3bcdb1 | |||
| a51dfc9e1f | |||
| 9aaa8016e5 | |||
| 632ec4157c | |||
| c3ed8b1c07 | |||
| 8301d1c48e | |||
| 2a02eb71ac | |||
| 097b8b7550 | |||
| f120285c96 | |||
| af7ca3a8a2 | |||
| b31fddb98e | |||
| 27ddb725c7 | |||
| 2bf3d4e11f | |||
| 9852a2e758 | |||
| 17bac141c8 | |||
| 4b42ef50c7 | |||
| fdde3f9da9 | |||
| 74ea81519b | |||
| 769dfff1a5 | |||
| 3ef68c708f | |||
| 3ed1829cc7 | |||
| b982ccd8c4 | |||
| 2586b09f5a | |||
| a5f9ff45dd | |||
| 61360c3b0d | |||
| eec6da060f | |||
| f422bb1263 | |||
| 96ce31153c | |||
| a9c3c8bb8a | |||
| a147a7c083 | |||
| 89a11d75c0 | |||
| 84cad43154 | |||
| 5c280b9a94 | |||
| 1a953736ff | |||
| 4106332738 | |||
| 9559ad398a | |||
| bddb52c96a | |||
| a0fd39a7be | |||
| be87303683 | |||
| bd0cc4bb51 | |||
| 4c8ae5d078 | |||
| e33c371e99 | |||
| e104e241a3 | |||
| 3bca787213 | |||
| 21257c39c2 | |||
| ed6e123d54 | |||
| dad28abecf | |||
| d1f5d6e223 | |||
| 1b215b521d | |||
| 36a354e3a7 | |||
| a05a0f1053 | |||
| 60efbe39e3 | |||
| 9e20a373ca | |||
| 2f55a39c9c | |||
| 80fd2fbbf1 | |||
| c3d1ce8237 | |||
| 567bc50723 | |||
| 1a5fcd05d6 | |||
| 3a6153d18f | |||
| d273c45de6 | |||
| da46c660df | |||
| 92d1331978 | |||
| 01bbc0aa9f | |||
| dbbad6d4ee | |||
| 4f69e6540c | |||
| 55608665ef | |||
| 0fb4a3e868 | |||
| 2dc31a30d4 | |||
| 8b68eb7eac | |||
| 96679f3e2b | |||
| 3ba17bb517 | |||
| ae3b36ac88 | |||
| 5367ef4367 | |||
| caa1e6e196 | |||
| 8bd17cea42 | |||
| 4256775274 | |||
| 5962f56f87 | |||
| b6a45b8278 | |||
| 8ae90f9667 | |||
| 17f0d6bc68 | |||
| 8b1a722f06 | |||
| 63d81cc096 | |||
| 46a8f86e80 | |||
| 94279fe059 | |||
| 867df396ea | |||
| 858e5995d6 | |||
| edf57cec29 | |||
| 0c72ad56d2 | |||
| 92d3a2fde5 | |||
| b0048a6104 | |||
| 0db2abbeca | |||
| 9761d75699 |
@@ -203,6 +203,8 @@ jobs:
|
||||
name: Snap ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# The Snap Store hasn't accepted SolidCraft yet, so a snap failure never holds up a release.
|
||||
continue-on-error: true
|
||||
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
|
||||
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
.vscode/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
@@ -14,6 +14,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
|
||||
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
|
||||
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
|
||||
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
|
||||
@@ -107,7 +108,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft drawing code: the borderless mark, drawn from the faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -996,6 +996,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -2271,6 +2280,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
@@ -2480,6 +2499,24 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -4162,7 +4199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4172,6 +4209,7 @@ dependencies = [
|
||||
"hidapi",
|
||||
"keyring",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -4193,11 +4231,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4209,7 +4247,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4218,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4235,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4254,7 +4292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4263,7 +4301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4280,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4303,7 +4341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4323,7 +4361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4332,7 +4370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4346,7 +4384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4357,14 +4395,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4392,7 +4430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5922,7 +5960,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde_json",
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 563 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -95,6 +95,7 @@ core_detect 1.0.0 (MIT/Apache-2.0)
|
||||
cpufeatures 0.2.17 (MIT OR Apache-2.0)
|
||||
cpufeatures 0.3.1 (MIT OR Apache-2.0)
|
||||
crc32fast 1.5.2 (MIT OR Apache-2.0)
|
||||
crossbeam-channel 0.5.17 (MIT OR Apache-2.0)
|
||||
crossbeam-deque 0.8.8 (MIT OR Apache-2.0)
|
||||
crossbeam-epoch 0.9.21 (MIT OR Apache-2.0)
|
||||
crossbeam-utils 0.8.23 (MIT OR Apache-2.0)
|
||||
@@ -210,6 +211,7 @@ jni-sys 0.4.1 (MIT OR Apache-2.0)
|
||||
jni-sys-macros 0.4.1 (MIT OR Apache-2.0)
|
||||
js-sys 0.3.106 (MIT OR Apache-2.0)
|
||||
katexit 0.1.5 (MIT OR Apache-2.0)
|
||||
keyboard-types 0.8.3 (MIT OR Apache-2.0)
|
||||
keyring 3.6.3 (MIT OR Apache-2.0)
|
||||
khronos-egl 6.0.0 (MIT/Apache-2.0)
|
||||
kurbo 0.13.1 (Apache-2.0 OR MIT)
|
||||
@@ -234,6 +236,7 @@ minisign-verify 0.3.0 (MIT)
|
||||
miniz_oxide 0.8.9 (MIT OR Zlib OR Apache-2.0)
|
||||
miniz_oxide 0.9.1 (MIT OR Zlib OR Apache-2.0)
|
||||
moxcms 0.8.1 (BSD-3-Clause OR Apache-2.0)
|
||||
muda 0.21.2 (Apache-2.0 OR MIT)
|
||||
multiversion_no_op 1.0.0 (Apache-2.0 OR MIT)
|
||||
naga 30.0.1 (MIT OR Apache-2.0)
|
||||
naga-types 30.0.1 (MIT OR Apache-2.0)
|
||||
@@ -1108,26 +1111,27 @@ ahash 0.8.12, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-
|
||||
4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo
|
||||
3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation
|
||||
0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, core_detect
|
||||
1.0.0, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service
|
||||
4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14, euclid 0.22.14,
|
||||
event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, form_urlencoded
|
||||
1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow 0.17.0, gltf 1.4.1, gltf-derive
|
||||
1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna
|
||||
1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
|
||||
khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14,
|
||||
log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46,
|
||||
num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0,
|
||||
parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5,
|
||||
plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core
|
||||
1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix
|
||||
0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard
|
||||
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
|
||||
security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2,
|
||||
smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1,
|
||||
unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi
|
||||
0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures
|
||||
0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129,
|
||||
web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1
|
||||
1.0.0, crossbeam-channel 0.5.17, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils
|
||||
0.8.23, dbus-secret-service 4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14,
|
||||
euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10,
|
||||
form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow 0.17.0, gltf 1.4.1,
|
||||
gltf-derive 1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse
|
||||
1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys
|
||||
0.3.106, keyboard-types 0.8.3, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1,
|
||||
linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, muda 0.21.2, num 0.4.3, num-bigint 0.4.8,
|
||||
num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19,
|
||||
once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5,
|
||||
parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling
|
||||
3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust
|
||||
1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46,
|
||||
rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0,
|
||||
security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0,
|
||||
signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait
|
||||
1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width
|
||||
0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12,
|
||||
wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
|
||||
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
|
||||
wit-bindgen 0.57.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -4030,7 +4034,605 @@ Apache License
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23
|
||||
crossbeam-channel 0.5.17
|
||||
================================================================================
|
||||
|
||||
===============================================================================
|
||||
|
||||
matching.go
|
||||
https://creativecommons.org/licenses/by/3.0/legalcode
|
||||
|
||||
Creative Commons Legal Code
|
||||
|
||||
Attribution 3.0 Unported
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
|
||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
|
||||
DAMAGES RESULTING FROM ITS USE.
|
||||
|
||||
License
|
||||
|
||||
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
|
||||
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
|
||||
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
|
||||
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
|
||||
|
||||
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
|
||||
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
|
||||
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
|
||||
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
|
||||
CONDITIONS.
|
||||
|
||||
1. Definitions
|
||||
|
||||
a. "Adaptation" means a work based upon the Work, or upon the Work and
|
||||
other pre-existing works, such as a translation, adaptation,
|
||||
derivative work, arrangement of music or other alterations of a
|
||||
literary or artistic work, or phonogram or performance and includes
|
||||
cinematographic adaptations or any other form in which the Work may be
|
||||
recast, transformed, or adapted including in any form recognizably
|
||||
derived from the original, except that a work that constitutes a
|
||||
Collection will not be considered an Adaptation for the purpose of
|
||||
this License. For the avoidance of doubt, where the Work is a musical
|
||||
work, performance or phonogram, the synchronization of the Work in
|
||||
timed-relation with a moving image ("synching") will be considered an
|
||||
Adaptation for the purpose of this License.
|
||||
b. "Collection" means a collection of literary or artistic works, such as
|
||||
encyclopedias and anthologies, or performances, phonograms or
|
||||
broadcasts, or other works or subject matter other than works listed
|
||||
in Section 1(f) below, which, by reason of the selection and
|
||||
arrangement of their contents, constitute intellectual creations, in
|
||||
which the Work is included in its entirety in unmodified form along
|
||||
with one or more other contributions, each constituting separate and
|
||||
independent works in themselves, which together are assembled into a
|
||||
collective whole. A work that constitutes a Collection will not be
|
||||
considered an Adaptation (as defined above) for the purposes of this
|
||||
License.
|
||||
c. "Distribute" means to make available to the public the original and
|
||||
copies of the Work or Adaptation, as appropriate, through sale or
|
||||
other transfer of ownership.
|
||||
d. "Licensor" means the individual, individuals, entity or entities that
|
||||
offer(s) the Work under the terms of this License.
|
||||
e. "Original Author" means, in the case of a literary or artistic work,
|
||||
the individual, individuals, entity or entities who created the Work
|
||||
or if no individual or entity can be identified, the publisher; and in
|
||||
addition (i) in the case of a performance the actors, singers,
|
||||
musicians, dancers, and other persons who act, sing, deliver, declaim,
|
||||
play in, interpret or otherwise perform literary or artistic works or
|
||||
expressions of folklore; (ii) in the case of a phonogram the producer
|
||||
being the person or legal entity who first fixes the sounds of a
|
||||
performance or other sounds; and, (iii) in the case of broadcasts, the
|
||||
organization that transmits the broadcast.
|
||||
f. "Work" means the literary and/or artistic work offered under the terms
|
||||
of this License including without limitation any production in the
|
||||
literary, scientific and artistic domain, whatever may be the mode or
|
||||
form of its expression including digital form, such as a book,
|
||||
pamphlet and other writing; a lecture, address, sermon or other work
|
||||
of the same nature; a dramatic or dramatico-musical work; a
|
||||
choreographic work or entertainment in dumb show; a musical
|
||||
composition with or without words; a cinematographic work to which are
|
||||
assimilated works expressed by a process analogous to cinematography;
|
||||
a work of drawing, painting, architecture, sculpture, engraving or
|
||||
lithography; a photographic work to which are assimilated works
|
||||
expressed by a process analogous to photography; a work of applied
|
||||
art; an illustration, map, plan, sketch or three-dimensional work
|
||||
relative to geography, topography, architecture or science; a
|
||||
performance; a broadcast; a phonogram; a compilation of data to the
|
||||
extent it is protected as a copyrightable work; or a work performed by
|
||||
a variety or circus performer to the extent it is not otherwise
|
||||
considered a literary or artistic work.
|
||||
g. "You" means an individual or entity exercising rights under this
|
||||
License who has not previously violated the terms of this License with
|
||||
respect to the Work, or who has received express permission from the
|
||||
Licensor to exercise rights under this License despite a previous
|
||||
violation.
|
||||
h. "Publicly Perform" means to perform public recitations of the Work and
|
||||
to communicate to the public those public recitations, by any means or
|
||||
process, including by wire or wireless means or public digital
|
||||
performances; to make available to the public Works in such a way that
|
||||
members of the public may access these Works from a place and at a
|
||||
place individually chosen by them; to perform the Work to the public
|
||||
by any means or process and the communication to the public of the
|
||||
performances of the Work, including by public digital performance; to
|
||||
broadcast and rebroadcast the Work by any means including signs,
|
||||
sounds or images.
|
||||
i. "Reproduce" means to make copies of the Work by any means including
|
||||
without limitation by sound or visual recordings and the right of
|
||||
fixation and reproducing fixations of the Work, including storage of a
|
||||
protected performance or phonogram in digital form or other electronic
|
||||
medium.
|
||||
|
||||
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
|
||||
limit, or restrict any uses free from copyright or rights arising from
|
||||
limitations or exceptions that are provided for in connection with the
|
||||
copyright protection under copyright law or other applicable laws.
|
||||
|
||||
3. License Grant. Subject to the terms and conditions of this License,
|
||||
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
perpetual (for the duration of the applicable copyright) license to
|
||||
exercise the rights in the Work as stated below:
|
||||
|
||||
a. to Reproduce the Work, to incorporate the Work into one or more
|
||||
Collections, and to Reproduce the Work as incorporated in the
|
||||
Collections;
|
||||
b. to create and Reproduce Adaptations provided that any such Adaptation,
|
||||
including any translation in any medium, takes reasonable steps to
|
||||
clearly label, demarcate or otherwise identify that changes were made
|
||||
to the original Work. For example, a translation could be marked "The
|
||||
original work was translated from English to Spanish," or a
|
||||
modification could indicate "The original work has been modified.";
|
||||
c. to Distribute and Publicly Perform the Work including as incorporated
|
||||
in Collections; and,
|
||||
d. to Distribute and Publicly Perform Adaptations.
|
||||
e. For the avoidance of doubt:
|
||||
|
||||
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme cannot be waived, the Licensor
|
||||
reserves the exclusive right to collect such royalties for any
|
||||
exercise by You of the rights granted under this License;
|
||||
ii. Waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme can be waived, the Licensor waives the
|
||||
exclusive right to collect such royalties for any exercise by You
|
||||
of the rights granted under this License; and,
|
||||
iii. Voluntary License Schemes. The Licensor waives the right to
|
||||
collect royalties, whether individually or, in the event that the
|
||||
Licensor is a member of a collecting society that administers
|
||||
voluntary licensing schemes, via that society, from any exercise
|
||||
by You of the rights granted under this License.
|
||||
|
||||
The above rights may be exercised in all media and formats whether now
|
||||
known or hereafter devised. The above rights include the right to make
|
||||
such modifications as are technically necessary to exercise the rights in
|
||||
other media and formats. Subject to Section 8(f), all rights not expressly
|
||||
granted by Licensor are hereby reserved.
|
||||
|
||||
4. Restrictions. The license granted in Section 3 above is expressly made
|
||||
subject to and limited by the following restrictions:
|
||||
|
||||
a. You may Distribute or Publicly Perform the Work only under the terms
|
||||
of this License. You must include a copy of, or the Uniform Resource
|
||||
Identifier (URI) for, this License with every copy of the Work You
|
||||
Distribute or Publicly Perform. You may not offer or impose any terms
|
||||
on the Work that restrict the terms of this License or the ability of
|
||||
the recipient of the Work to exercise the rights granted to that
|
||||
recipient under the terms of the License. You may not sublicense the
|
||||
Work. You must keep intact all notices that refer to this License and
|
||||
to the disclaimer of warranties with every copy of the Work You
|
||||
Distribute or Publicly Perform. When You Distribute or Publicly
|
||||
Perform the Work, You may not impose any effective technological
|
||||
measures on the Work that restrict the ability of a recipient of the
|
||||
Work from You to exercise the rights granted to that recipient under
|
||||
the terms of the License. This Section 4(a) applies to the Work as
|
||||
incorporated in a Collection, but this does not require the Collection
|
||||
apart from the Work itself to be made subject to the terms of this
|
||||
License. If You create a Collection, upon notice from any Licensor You
|
||||
must, to the extent practicable, remove from the Collection any credit
|
||||
as required by Section 4(b), as requested. If You create an
|
||||
Adaptation, upon notice from any Licensor You must, to the extent
|
||||
practicable, remove from the Adaptation any credit as required by
|
||||
Section 4(b), as requested.
|
||||
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
|
||||
Collections, You must, unless a request has been made pursuant to
|
||||
Section 4(a), keep intact all copyright notices for the Work and
|
||||
provide, reasonable to the medium or means You are utilizing: (i) the
|
||||
name of the Original Author (or pseudonym, if applicable) if supplied,
|
||||
and/or if the Original Author and/or Licensor designate another party
|
||||
or parties (e.g., a sponsor institute, publishing entity, journal) for
|
||||
attribution ("Attribution Parties") in Licensor's copyright notice,
|
||||
terms of service or by other reasonable means, the name of such party
|
||||
or parties; (ii) the title of the Work if supplied; (iii) to the
|
||||
extent reasonably practicable, the URI, if any, that Licensor
|
||||
specifies to be associated with the Work, unless such URI does not
|
||||
refer to the copyright notice or licensing information for the Work;
|
||||
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
|
||||
a credit identifying the use of the Work in the Adaptation (e.g.,
|
||||
"French translation of the Work by Original Author," or "Screenplay
|
||||
based on original Work by Original Author"). The credit required by
|
||||
this Section 4 (b) may be implemented in any reasonable manner;
|
||||
provided, however, that in the case of a Adaptation or Collection, at
|
||||
a minimum such credit will appear, if a credit for all contributing
|
||||
authors of the Adaptation or Collection appears, then as part of these
|
||||
credits and in a manner at least as prominent as the credits for the
|
||||
other contributing authors. For the avoidance of doubt, You may only
|
||||
use the credit required by this Section for the purpose of attribution
|
||||
in the manner set out above and, by exercising Your rights under this
|
||||
License, You may not implicitly or explicitly assert or imply any
|
||||
connection with, sponsorship or endorsement by the Original Author,
|
||||
Licensor and/or Attribution Parties, as appropriate, of You or Your
|
||||
use of the Work, without the separate, express prior written
|
||||
permission of the Original Author, Licensor and/or Attribution
|
||||
Parties.
|
||||
c. Except as otherwise agreed in writing by the Licensor or as may be
|
||||
otherwise permitted by applicable law, if You Reproduce, Distribute or
|
||||
Publicly Perform the Work either by itself or as part of any
|
||||
Adaptations or Collections, You must not distort, mutilate, modify or
|
||||
take other derogatory action in relation to the Work which would be
|
||||
prejudicial to the Original Author's honor or reputation. Licensor
|
||||
agrees that in those jurisdictions (e.g. Japan), in which any exercise
|
||||
of the right granted in Section 3(b) of this License (the right to
|
||||
make Adaptations) would be deemed to be a distortion, mutilation,
|
||||
modification or other derogatory action prejudicial to the Original
|
||||
Author's honor and reputation, the Licensor will waive or not assert,
|
||||
as appropriate, this Section, to the fullest extent permitted by the
|
||||
applicable national law, to enable You to reasonably exercise Your
|
||||
right under Section 3(b) of this License (right to make Adaptations)
|
||||
but not otherwise.
|
||||
|
||||
5. Representations, Warranties and Disclaimer
|
||||
|
||||
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
|
||||
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
|
||||
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
|
||||
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
|
||||
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
|
||||
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
|
||||
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
|
||||
|
||||
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
|
||||
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
|
||||
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
|
||||
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
|
||||
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
7. Termination
|
||||
|
||||
a. This License and the rights granted hereunder will terminate
|
||||
automatically upon any breach by You of the terms of this License.
|
||||
Individuals or entities who have received Adaptations or Collections
|
||||
from You under this License, however, will not have their licenses
|
||||
terminated provided such individuals or entities remain in full
|
||||
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
|
||||
survive any termination of this License.
|
||||
b. Subject to the above terms and conditions, the license granted here is
|
||||
perpetual (for the duration of the applicable copyright in the Work).
|
||||
Notwithstanding the above, Licensor reserves the right to release the
|
||||
Work under different license terms or to stop distributing the Work at
|
||||
any time; provided, however that any such election will not serve to
|
||||
withdraw this License (or any other license that has been, or is
|
||||
required to be, granted under the terms of this License), and this
|
||||
License will continue in full force and effect unless terminated as
|
||||
stated above.
|
||||
|
||||
8. Miscellaneous
|
||||
|
||||
a. Each time You Distribute or Publicly Perform the Work or a Collection,
|
||||
the Licensor offers to the recipient a license to the Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
|
||||
offers to the recipient a license to the original Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
c. If any provision of this License is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this License, and without further action
|
||||
by the parties to this agreement, such provision shall be reformed to
|
||||
the minimum extent necessary to make such provision valid and
|
||||
enforceable.
|
||||
d. No term or provision of this License shall be deemed waived and no
|
||||
breach consented to unless such waiver or consent shall be in writing
|
||||
and signed by the party to be charged with such waiver or consent.
|
||||
e. This License constitutes the entire agreement between the parties with
|
||||
respect to the Work licensed here. There are no understandings,
|
||||
agreements or representations with respect to the Work not specified
|
||||
here. Licensor shall not be bound by any additional provisions that
|
||||
may appear in any communication from You. This License may not be
|
||||
modified without the mutual written agreement of the Licensor and You.
|
||||
f. The rights granted under, and the subject matter referenced, in this
|
||||
License were drafted utilizing the terminology of the Berne Convention
|
||||
for the Protection of Literary and Artistic Works (as amended on
|
||||
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
|
||||
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
|
||||
and the Universal Copyright Convention (as revised on July 24, 1971).
|
||||
These rights and subject matter take effect in the relevant
|
||||
jurisdiction in which the License terms are sought to be enforced
|
||||
according to the corresponding provisions of the implementation of
|
||||
those treaty provisions in the applicable national law. If the
|
||||
standard suite of rights granted under applicable copyright law
|
||||
includes additional rights not granted under this License, such
|
||||
additional rights are deemed to be included in the License; this
|
||||
License is not intended to restrict the license of any rights under
|
||||
applicable law.
|
||||
|
||||
|
||||
Creative Commons Notice
|
||||
|
||||
Creative Commons is not a party to this License, and makes no warranty
|
||||
whatsoever in connection with the Work. Creative Commons will not be
|
||||
liable to You or any party on any legal theory for any damages
|
||||
whatsoever, including without limitation any general, special,
|
||||
incidental or consequential damages arising in connection to this
|
||||
license. Notwithstanding the foregoing two (2) sentences, if Creative
|
||||
Commons has expressly identified itself as the Licensor hereunder, it
|
||||
shall have all rights and obligations of Licensor.
|
||||
|
||||
Except for the limited purpose of indicating to the public that the
|
||||
Work is licensed under the CCPL, Creative Commons does not authorize
|
||||
the use by either party of the trademark "Creative Commons" or any
|
||||
related trademark or logo of Creative Commons without the prior
|
||||
written consent of Creative Commons. Any permitted use will be in
|
||||
compliance with Creative Commons' then-current trademark usage
|
||||
guidelines, as may be published on its website or otherwise made
|
||||
available upon request from time to time. For the avoidance of doubt,
|
||||
this trademark restriction does not form part of this License.
|
||||
|
||||
Creative Commons may be contacted at https://creativecommons.org/.
|
||||
|
||||
===============================================================================
|
||||
|
||||
The Go Programming Language
|
||||
https://golang.org/LICENSE
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
===============================================================================
|
||||
|
||||
The Rust Programming Language
|
||||
https://github.com/rust-lang/rust/blob/master/LICENSE-MIT
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
===============================================================================
|
||||
|
||||
The Rust Programming Language
|
||||
https://github.com/rust-lang/rust/blob/master/LICENSE-APACHE
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
crossbeam-channel 0.5.17, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
@@ -7716,6 +8318,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
keyboard-types 0.8.3
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2017 Pyfisch
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
keyring 3.6.3
|
||||
================================================================================
|
||||
@@ -9452,6 +10078,56 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
================================================================================
|
||||
muda 0.21.2
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2022 Tauri Programme within The Commons Conservancy
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
muda 0.21.2
|
||||
================================================================================
|
||||
|
||||
SPDXVersion: SPDX-2.1
|
||||
DataLicense: CC0-1.0
|
||||
PackageName: muda
|
||||
DataFormat: SPDXRef-1
|
||||
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
|
||||
PackageHomePage: https://tauri.app
|
||||
PackageLicenseDeclared: Apache-2.0
|
||||
PackageLicenseDeclared: MIT
|
||||
PackageCopyrightText: 2020-2022, The Tauri Programme in the Commons Conservancy
|
||||
PackageSummary: <text>Menu Utilities for Desktop Applications.
|
||||
</text>
|
||||
PackageComment: <text>The package includes the following libraries; see
|
||||
Relationship information.
|
||||
</text>
|
||||
Created: 2022-12-05T09:00:00Z
|
||||
PackageDownloadLocation: git://github.com/tauri-apps/muda
|
||||
PackageDownloadLocation: git+https://github.com/tauri-apps/muda.git
|
||||
PackageDownloadLocation: git+ssh://github.com/tauri-apps/muda.git
|
||||
Creator: Person: Daniel Thompson-Yvetot
|
||||
|
||||
================================================================================
|
||||
multiversion_no_op 1.0.0
|
||||
================================================================================
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
//! `bench-assistant`: the standard Assistant model test (v1, #4851), run against a desktop app.
|
||||
//!
|
||||
//! The app is already running with `--control PORT`; everything goes through its control channel
|
||||
//! (`docs/control-protocol.md`), so the command needs no window code of its own. For each task it
|
||||
//! starts a new design, runs the task's prescribed setup (a box, for some), adds the endpoint as an
|
||||
//! API agent with All permissions, starts a new chat, sends the prompt once and waits for the turn
|
||||
//! to end. The result is checked on the design (`document.inspect` with measurements), never on
|
||||
//! what the model says, except for the one question whose answer is the reply. A failed task is
|
||||
//! put in one of the issue's failure categories from the conversation's items.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT --base-url URL --model NAME
|
||||
//! [--token T] [--tasks 1,2,5] [--repeat N] [--timeout-s 300]
|
||||
//! [--json out.json] [--markdown out.md]
|
||||
//! ```
|
||||
//! prints a table of the results; `--markdown` writes the issue's comment template filled in,
|
||||
//! `--json` every task's rules, conversation and design before and after.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Standard test v1, as #4851 states it. Changing a prompt or a rule makes it another test.
|
||||
const TASKS: &str = r#"[
|
||||
{"id": "01-cube", "prompt": "Make a 20 mm cube.", "check": {"bodies": 1, "volume": 8000, "bbox_size": [20, 20, 20]}},
|
||||
{"id": "02-cylinder", "prompt": "Make a cylinder 30 mm in diameter and 50 mm tall.", "check": {"bodies": 1, "volume": 35342.92, "bbox_size": [30, 30, 50]}},
|
||||
{"id": "03-block", "prompt": "Create a block 60 mm long, 40 mm wide and 10 mm thick.", "check": {"bodies": 1, "volume": 24000, "bbox_size": [10, 40, 60]}},
|
||||
{"id": "04-sketch-extrude", "prompt": "Sketch a 50 by 30 mm rectangle on the XY plane and extrude it 15 mm.", "check": {"bodies": 1, "volume": 22500, "bbox_size": [15, 30, 50], "feature_kinds": ["Extrude"]}},
|
||||
{"id": "05-plate-hole", "prompt": "Make a plate 80 x 60 x 10 mm with a 20 mm diameter hole straight through the middle.", "check": {"bodies": 1, "volume": 44858.41, "bbox_size": [10, 60, 80], "note": "80*60*10 - pi*10^2*10"}},
|
||||
{"id": "06-fillet-box", "prompt": "Round all the edges of the box with a 3 mm radius.", "setup": [{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 40}}], "check": {"bodies": 1, "volume": 63108.53, "tol": 0.003, "feature_kinds": ["Fillet"], "note": "all 12 edges of a 40 mm cube rounded r=3"}},
|
||||
{"id": "07-move-body", "prompt": "Move the box 50 mm along the X axis.", "setup": [{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}}], "check": {"bodies": 1, "volume": 8000, "bbox_min": [50, 0, 0]}},
|
||||
{"id": "08-rename-body", "prompt": "Rename the body to Bracket.", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "body_name": "Bracket"}},
|
||||
{"id": "09-volume-question", "prompt": "What is the volume of the part?", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "unchanged": true, "reply_number": "6[ ,. ]?000(?![0-9])|6(\\.0+)? ?cm"}},
|
||||
{"id": "10-two-holes", "prompt": "Make a 100 x 50 x 5 mm plate and drill two 10 mm holes through it, each 15 mm in from the short ends and centred across the width.", "check": {"bodies": 1, "volume": 24214.6, "bbox_size": [5, 50, 100], "note": "100*50*5 - 2*pi*5^2*5"}},
|
||||
{"id": "11-any-change", "prompt": "can you use the software to make a change to the current shape, any change at all", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"changed": true, "note": "verbatim user prompt from #4846; pass = the design changed"}}
|
||||
]"#;
|
||||
|
||||
/// The agent the test adds. A fixed id replaces it on every run instead of adding another.
|
||||
const AGENT_ID: &str = "api-bench-assistant";
|
||||
const AGENT_NAME: &str = "bench";
|
||||
/// Longest control reply read (the channel's own request limit).
|
||||
const MAX_REPLY: usize = 16 << 20;
|
||||
/// Most result data kept for `--json`.
|
||||
const MAX_RESULTS: usize = 64 << 20;
|
||||
/// Deadline of the requests that only set up or read (each is answered at once by the app).
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// How often a running turn is polled.
|
||||
const POLL: Duration = Duration::from_millis(250);
|
||||
/// After this long, an idle chat with no reply yet counts as a turn that ended without one.
|
||||
const START_GRACE: Duration = Duration::from_secs(30);
|
||||
/// Absolute tolerance of the lowest corner, in millimetres.
|
||||
const MIN_CORNER_TOL: f64 = 0.05;
|
||||
|
||||
/// A JSON-lines connection to the app. Requests are sent once; a lost reply is never retried,
|
||||
/// so nothing runs twice.
|
||||
struct Control {
|
||||
addr: SocketAddr,
|
||||
token: String,
|
||||
reader: BufReader<TcpStream>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Control {
|
||||
fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
|
||||
let addr: SocketAddr = addr.parse().map_err(|_| format!("--connect: `{addr}` is not an IP address and port"))?;
|
||||
if !addr.ip().is_loopback() || addr.port() == 0 {
|
||||
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
|
||||
}
|
||||
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
|
||||
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
|
||||
if token.len() > 4096 {
|
||||
return Err("the control token is longer than 4096 bytes".into());
|
||||
}
|
||||
Self::open(addr, token)
|
||||
}
|
||||
|
||||
fn open(addr: SocketAddr, token: String) -> Result<Self, String> {
|
||||
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("connecting to {addr}: {e}"))?;
|
||||
Ok(Self { addr, token, reader: BufReader::new(stream), id: 0 })
|
||||
}
|
||||
|
||||
/// A new connection to the same app: a request that outlived its deadline keeps the old one
|
||||
/// busy until it is answered.
|
||||
fn reopen(&self) -> Result<Self, String> {
|
||||
Self::open(self.addr, self.token.clone())
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value, deadline: Instant) -> Result<Value, String> {
|
||||
let left = || deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or_else(|| "timeout".to_string());
|
||||
let io = |e: std::io::Error| match e.kind() {
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout".to_string(),
|
||||
_ => format!("control channel: {e}"),
|
||||
};
|
||||
self.reader.get_ref().set_write_timeout(Some(left()?)).map_err(io)?;
|
||||
self.id += 1;
|
||||
// The first request on a connection carries the token; sending it on every one is allowed.
|
||||
let request = json!({"id": self.id, "method": method, "params": params, "token": self.token});
|
||||
writeln!(self.reader.get_mut(), "{request}").map_err(io)?;
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
self.reader.get_ref().set_read_timeout(Some(left()?)).map_err(io)?;
|
||||
let bytes = self.reader.fill_buf().map_err(io)?;
|
||||
if bytes.is_empty() {
|
||||
return Err("the app closed the control connection".into());
|
||||
}
|
||||
let newline = bytes.iter().position(|b| *b == b'\n');
|
||||
let used = newline.map_or(bytes.len(), |n| n + 1);
|
||||
if line.len() + used > MAX_REPLY {
|
||||
return Err("a control reply is larger than 16 MiB".into());
|
||||
}
|
||||
line.extend_from_slice(bytes.get(..used).unwrap_or_default());
|
||||
self.reader.consume(used);
|
||||
if newline.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let reply: Value = serde_json::from_slice(&line).map_err(|e| format!("control reply: {e}"))?;
|
||||
if reply["id"].as_u64() != Some(self.id) {
|
||||
return Err("control reply for another request".into());
|
||||
}
|
||||
if reply["ok"] != true {
|
||||
return Err(format!("{method}: {}", reply["error"].as_str().unwrap_or("failed")));
|
||||
}
|
||||
reply.get("result").cloned().ok_or_else(|| format!("{method}: reply without a result"))
|
||||
}
|
||||
|
||||
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
|
||||
self.call("engine.execute", json!({"command": command, "params": params, "animate": false}), Instant::now() + REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
fn chat(&mut self, params: Value) -> Result<Value, String> {
|
||||
self.call("ui.agent_chat", params, Instant::now() + REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
fn inspect(&mut self) -> Result<Value, String> {
|
||||
let design = self.call("document.inspect", json!({"measure": true}), Instant::now() + REQUEST_TIMEOUT)?;
|
||||
if design.is_object() { Ok(design) } else { Err("document.inspect returned no design".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
fn tasks() -> Result<Vec<Value>, String> {
|
||||
serde_json::from_str(TASKS).map_err(|e| format!("embedded tasks: {e}"))
|
||||
}
|
||||
|
||||
fn triple(value: &Value) -> Option<[f64; 3]> {
|
||||
match value.as_array()?.as_slice() {
|
||||
[x, y, z] => {
|
||||
let n = |v: &Value| v.as_f64().filter(|n| n.is_finite());
|
||||
Some([n(x)?, n(y)?, n(z)?])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the model said (thoughts left out).
|
||||
fn reply(items: &[Value]) -> String {
|
||||
items.iter().filter(|i| i["thought"] != true).filter_map(|i| i["agent"].as_str()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// v1's reply pattern `6[ ,. ]?000(?![0-9])|6(\.0+)? ?cm`: "6000", "6 000", "6,000", "6.000" not
|
||||
/// followed by a digit, or "6 cm", "6.0cm", … (a narrow no-break space also separates thousands).
|
||||
fn volume_reply(text: &str) -> bool {
|
||||
text.match_indices('6').any(|(at, _)| {
|
||||
let tail = text.get(at + 1..).unwrap_or_default();
|
||||
let thousands = |t: &str| t.strip_prefix("000").is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_digit()));
|
||||
let separated = tail.strip_prefix([' ', ',', '.', '\u{202f}']).is_some_and(thousands);
|
||||
let cm = {
|
||||
let t = tail.strip_prefix(".0").map_or(tail, |t| t.trim_start_matches('0'));
|
||||
t.strip_prefix(' ').unwrap_or(t).starts_with("cm")
|
||||
};
|
||||
thousands(tail) || separated || cm
|
||||
})
|
||||
}
|
||||
|
||||
/// The design as the user sees it, without what reading or saving changes (path, saved state,
|
||||
/// revision, selection, undo depth, evaluation timings).
|
||||
fn content(design: &Value) -> Value {
|
||||
let mut v = design.clone();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
for key in ["path", "dirty", "revision", "selection", "undo", "redo"] {
|
||||
o.remove(key);
|
||||
}
|
||||
}
|
||||
for feature in v.get_mut("timeline").and_then(Value::as_array_mut).into_iter().flatten() {
|
||||
if let Some(o) = feature.as_object_mut() {
|
||||
o.remove("ms");
|
||||
o.remove("boolean_diagnostics");
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether the design changed: its content, or a step on the undo stack (a colour, say, that the
|
||||
/// inspection does not list).
|
||||
fn changed(before: &Value, after: &Value) -> bool {
|
||||
content(before) != content(after) || after["undo"].as_u64() > before["undo"].as_u64()
|
||||
}
|
||||
|
||||
/// The rules a task's result breaks (none: it passed). `before` and `after` are
|
||||
/// `document.inspect {measure: true}` before the prompt and after the turn.
|
||||
pub(super) fn check(rule: &Value, before: &Value, after: &Value, items: &[Value]) -> Vec<String> {
|
||||
let Some(all) = after["bodies"].as_array() else { return vec!["the design could not be read".into()] };
|
||||
// Bodies the user sees; a hidden one (a tool body, a hidden original) is not the result.
|
||||
let bodies: Vec<&Value> = all.iter().filter(|b| b["visible"] != false).collect();
|
||||
let mut errors = Vec::new();
|
||||
if let Some(n) = rule["bodies"].as_u64()
|
||||
&& bodies.len() as u64 != n
|
||||
{
|
||||
errors.push(format!("{} bodies, expected {n}", bodies.len()));
|
||||
}
|
||||
if let Some(expected) = rule["volume"].as_f64() {
|
||||
let tol = rule["tol"].as_f64().unwrap_or(0.01);
|
||||
let volumes: Vec<f64> = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).filter(|v| v.is_finite()).collect();
|
||||
let total: f64 = volumes.iter().sum();
|
||||
if volumes.len() != bodies.len() || (total - expected).abs() > expected * tol {
|
||||
errors.push(format!("volume {total:.1} mm³, expected {expected} mm³ ±{}%", tol * 100.0));
|
||||
}
|
||||
}
|
||||
let first = bodies.first().copied().unwrap_or(&Value::Null);
|
||||
let min = triple(&first["bbox"]["min"]);
|
||||
if let Some(mut expected) = triple(&rule["bbox_size"]) {
|
||||
expected.sort_by(f64::total_cmp);
|
||||
let size = min.zip(triple(&first["bbox"]["max"])).map(|(lo, hi)| {
|
||||
let mut size = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
|
||||
size.sort_by(f64::total_cmp);
|
||||
size
|
||||
});
|
||||
if !size.is_some_and(|s| s.iter().zip(expected).all(|(a, e)| (a - e).abs() <= 0.01 * e.max(1.0))) {
|
||||
errors.push(format!("size {size:?}, expected {expected:?} in any orientation"));
|
||||
}
|
||||
}
|
||||
if let Some(expected) = triple(&rule["bbox_min"])
|
||||
&& !min.is_some_and(|m| m.iter().zip(expected).all(|(a, e)| (a - e).abs() <= MIN_CORNER_TOL))
|
||||
{
|
||||
errors.push(format!("lowest corner {min:?}, expected {expected:?}"));
|
||||
}
|
||||
if let Some(name) = rule["body_name"].as_str()
|
||||
&& !bodies.iter().any(|b| b["name"] == name)
|
||||
{
|
||||
errors.push(format!("no body named {name}"));
|
||||
}
|
||||
for kind in rule["feature_kinds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let live = |f: &&Value| f["suppressed"] != true && f["rolled_back"] != true && f["error"].is_null();
|
||||
if !after["timeline"].as_array().into_iter().flatten().filter(live).any(|f| f["type"].as_str().is_some_and(|t| t.contains(kind))) {
|
||||
errors.push(format!("no {kind} in the timeline"));
|
||||
}
|
||||
}
|
||||
if rule["changed"] == true || rule["unchanged"] == true {
|
||||
let changed = before.is_object() && changed(before, after);
|
||||
if rule["changed"] == true && !changed {
|
||||
errors.push("the design did not change".into());
|
||||
}
|
||||
if rule["unchanged"] == true && (changed || !before.is_object()) {
|
||||
errors.push("the design changed".into());
|
||||
}
|
||||
}
|
||||
if rule["reply_number"].is_string() && !volume_reply(&reply(items)) {
|
||||
errors.push("the reply does not give the volume".into());
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// A tool call written into the reply as JSON: `{"name": …`, `{"command": …`, `{"function": …`,
|
||||
/// `{"tool": …`.
|
||||
fn call_as_text(text: &str) -> bool {
|
||||
text.match_indices('{').any(|(at, _)| {
|
||||
let rest = text.get(at + 1..).unwrap_or_default().trim_start();
|
||||
["\"name\"", "\"command\"", "\"function\"", "\"tool\""]
|
||||
.iter()
|
||||
.any(|key| rest.strip_prefix(key).is_some_and(|r| r.trim_start().starts_with(':')))
|
||||
})
|
||||
}
|
||||
|
||||
/// How a failed task failed, in the issue's words, from the conversation's items.
|
||||
pub(super) fn classify(items: &[Value], timed_out: bool) -> &'static str {
|
||||
if timed_out {
|
||||
return "timeout";
|
||||
}
|
||||
let tools: Vec<&Value> = items.iter().filter(|i| i.get("tool").is_some()).collect();
|
||||
// A failed call, or a batch whose answer reports a failed step (`"ok": false`).
|
||||
let failed: Vec<String> = tools
|
||||
.iter()
|
||||
.map(|t| t["output"].as_str().unwrap_or_default())
|
||||
.zip(&tools)
|
||||
.filter(|(output, t)| t["status"] == "Failed" || serde_json::from_str::<Value>(output).is_ok_and(|v| v["ok"] == false))
|
||||
.map(|(output, _)| output.to_lowercase())
|
||||
.collect();
|
||||
let notices: Vec<String> = items.iter().filter_map(|i| i["notice"].as_str()).map(str::to_lowercase).collect();
|
||||
let answer = reply(items);
|
||||
if notices.iter().any(|n| n.contains("http 400")) {
|
||||
return "the endpoint refused tools (HTTP 400)";
|
||||
}
|
||||
if notices.iter().any(|n| n.contains("max_turn_requests")) || answer.contains("requests for one message") {
|
||||
return "hit the request limit";
|
||||
}
|
||||
if call_as_text(&answer) {
|
||||
return "wrote the call as text";
|
||||
}
|
||||
if failed.iter().any(|o| o.contains("unknown command") || o.starts_with("unknown tool")) {
|
||||
return "wrong or unknown command";
|
||||
}
|
||||
if failed.iter().any(|o| o.contains("invalid parameters") || o.starts_with("not run:") || o.contains("not valid json")) {
|
||||
return "wrong arguments";
|
||||
}
|
||||
if !failed.is_empty() || notices.iter().any(|n| n.starts_with("http ")) || items.iter().any(|i| i["error"] == true) {
|
||||
return "gave up after errors";
|
||||
}
|
||||
if tools.is_empty() {
|
||||
return if answer.trim().is_empty() { "an empty reply" } else { "only talked" };
|
||||
}
|
||||
"wrong result"
|
||||
}
|
||||
|
||||
/// One run's results in the issue's comment template (`run`: which repetition).
|
||||
pub(super) fn markdown(model: &str, version: &str, results: &[Value], run: u64) -> String {
|
||||
let clean = |s: &str| s.replace(['\n', '\r', '|'], " ");
|
||||
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
|
||||
let passed = rows.iter().filter(|r| r["passed"] == true).count();
|
||||
let mut out = format!(
|
||||
"### {} (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: {}\nResult: {passed}/11\n| # | pass/fail | how it failed |\n|---|---|---|\n",
|
||||
clean(model),
|
||||
clean(version)
|
||||
);
|
||||
for n in 1..=11 {
|
||||
match rows.iter().find(|r| r["task"] == n) {
|
||||
Some(r) if r["passed"] == true => out.push_str(&format!("| {n} | pass | |\n")),
|
||||
Some(r) => out.push_str(&format!("| {n} | fail | {} |\n", clean(r["failure"].as_str().unwrap_or_default()))),
|
||||
None => out.push_str(&format!("| {n} | | not run |\n")),
|
||||
}
|
||||
}
|
||||
out.push_str(if rows.len() == 11 {
|
||||
"Notes: <anything odd, e.g. very slow, needed a retry>\n"
|
||||
} else {
|
||||
"Notes: partial run; the tasks marked \"not run\" were left out.\n"
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &str, text: &str) -> Result<(), String> {
|
||||
solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
struct Options<'a> {
|
||||
connect: &'a str,
|
||||
token: Option<&'a str>,
|
||||
base_url: &'a str,
|
||||
model: &'a str,
|
||||
tasks: Vec<usize>,
|
||||
repeat: u64,
|
||||
timeout: Duration,
|
||||
json: Option<&'a str>,
|
||||
markdown: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn options(args: &[String]) -> Result<Options<'_>, String> {
|
||||
const FLAGS: [&str; 9] = ["--connect", "--token", "--base-url", "--model", "--tasks", "--repeat", "--timeout-s", "--json", "--markdown"];
|
||||
let mut given = std::collections::BTreeMap::new();
|
||||
let mut args = args.iter();
|
||||
while let Some(flag) = args.next() {
|
||||
let Some(flag) = FLAGS.iter().find(|f| **f == flag.as_str()) else { return Err(format!("unknown bench-assistant option: {flag}")) };
|
||||
let value = args
|
||||
.next()
|
||||
.filter(|v| !v.starts_with("--") && !v.is_empty() && v.len() <= 4096)
|
||||
.ok_or_else(|| format!("{flag} needs a value (at most 4096 bytes)"))?;
|
||||
if given.insert(*flag, value.as_str()).is_some() {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
}
|
||||
let required = |key: &str| given.get(key).copied().ok_or_else(|| format!("bench-assistant needs {key}"));
|
||||
let number = |key: &str, default: u64, max: u64| -> Result<u64, String> {
|
||||
given
|
||||
.get(key)
|
||||
.map_or(Ok(default), |s| s.parse::<u64>().ok().filter(|n| (1..=max).contains(n)).ok_or_else(|| format!("{key} must be 1 to {max}")))
|
||||
};
|
||||
let tasks = match given.get("--tasks") {
|
||||
None => (1..=11).collect(),
|
||||
Some(list) => {
|
||||
let mut tasks = Vec::new();
|
||||
for n in list.split(',') {
|
||||
let n = n
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|n| (1..=11).contains(n))
|
||||
.ok_or("--tasks takes task numbers 1 to 11, separated by commas")?;
|
||||
if tasks.contains(&n) {
|
||||
return Err(format!("--tasks lists task {n} twice"));
|
||||
}
|
||||
tasks.push(n);
|
||||
}
|
||||
tasks
|
||||
}
|
||||
};
|
||||
Ok(Options {
|
||||
connect: required("--connect")?,
|
||||
token: given.get("--token").copied(),
|
||||
base_url: required("--base-url")?,
|
||||
model: required("--model")?,
|
||||
tasks,
|
||||
repeat: number("--repeat", 1, 100)?,
|
||||
timeout: Duration::from_secs(number("--timeout-s", 300, 3600)?),
|
||||
json: given.get("--json").copied(),
|
||||
markdown: given.get("--markdown").copied(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for the turn the prompt started to end. Returns the chat's last state and whether the
|
||||
/// deadline passed first.
|
||||
fn wait_for_turn(control: &mut Control, mut chat: Value, began: Instant, end: Instant) -> Result<(Value, bool), String> {
|
||||
let mut seen_busy = false;
|
||||
loop {
|
||||
let state = chat["state"].as_str().unwrap_or_default();
|
||||
let turn = !chat["turn"].is_null();
|
||||
let queued = chat["pending"].as_array().is_some_and(|p| !p.is_empty());
|
||||
seen_busy |= state == "busy" || turn;
|
||||
let idle = !matches!(state, "busy" | "starting") && !turn && !queued;
|
||||
// A turn can start and end between two polls: then the conversation has its answer.
|
||||
let answered = chat["items"].as_array().into_iter().flatten().any(|i| i.get("user").is_none());
|
||||
if idle && (seen_busy || answered || matches!(state, "exited" | "auth" | "off") || began.elapsed() >= START_GRACE) {
|
||||
return Ok((chat, false));
|
||||
}
|
||||
let Some(left) = end.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) else { return Ok((chat, true)) };
|
||||
std::thread::sleep(POLL.min(left));
|
||||
match control.call("ui.agent_chat", json!({}), end) {
|
||||
Ok(next) => chat = next,
|
||||
Err(e) if e == "timeout" => return Ok((chat, true)),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let o = options(args)?;
|
||||
let tasks = tasks()?;
|
||||
let mut control = Control::connect(o.connect, o.token)?;
|
||||
let help = control.call("ui.help", json!({}), Instant::now() + REQUEST_TIMEOUT)?;
|
||||
let version = help["version"].as_str().filter(|s| !s.is_empty() && s.len() <= 256).ok_or("the app reported no version")?.to_string();
|
||||
let config = control.chat(json!({
|
||||
"features": true,
|
||||
"add_agent": {"id": AGENT_ID, "name": AGENT_NAME, "kind": "api", "base_url": o.base_url, "model": o.model},
|
||||
}))?;
|
||||
if !config["agents"].as_array().into_iter().flatten().any(|a| a["id"] == AGENT_ID) {
|
||||
return Err("the app did not add the benchmark's agent".into());
|
||||
}
|
||||
// Put the user's permission setting back afterwards.
|
||||
let permissions = config["permissions"].as_str().map(str::to_string);
|
||||
let result = run_tasks(&mut control, &o, &tasks, &version);
|
||||
let _ = control.chat(json!({"stop": true}));
|
||||
if let Some(p) = permissions {
|
||||
let _ = control.chat(json!({"permissions": p}));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn run_tasks(control: &mut Control, o: &Options, tasks: &[Value], version: &str) -> Result<(), String> {
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let mut kept = 0;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut say = |line: String| writeln!(stdout, "{line}").map_err(|e| format!("stdout: {e}"));
|
||||
say(format!("bench-assistant v1: {} on SolidCraft {version}", o.model))?;
|
||||
say("| run | # | task | pass/fail | how it failed | seconds |\n|---|---|---|---|---|---|".into())?;
|
||||
for run in 1..=o.repeat {
|
||||
for &n in &o.tasks {
|
||||
let task = tasks.get(n - 1).ok_or("missing embedded task")?;
|
||||
// A fresh design and a fresh chat, with nothing of the last task still running.
|
||||
control.chat(json!({"stop": true}))?;
|
||||
control.execute("file.new", json!({"name": "Benchmark"}))?;
|
||||
for step in task["setup"].as_array().into_iter().flatten() {
|
||||
control.execute(step["command"].as_str().ok_or("embedded setup without a command")?, step["params"].clone())?;
|
||||
}
|
||||
control.chat(json!({"agent": AGENT_ID, "permissions": "all", "new_chat": true, "attach_view": false}))?;
|
||||
let before = control.inspect()?;
|
||||
let began = Instant::now();
|
||||
let end = began + o.timeout;
|
||||
let (chat, timed_out) = match control.call("ui.agent_chat", json!({"send": task["prompt"]}), end) {
|
||||
Ok(chat) => wait_for_turn(control, chat, began, end)?,
|
||||
Err(e) if e == "timeout" => (json!({}), true),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if timed_out {
|
||||
// The late reply would arrive on the old connection; stop the model on a new one
|
||||
// before reading the design.
|
||||
*control = control.reopen()?;
|
||||
}
|
||||
// Stop the agent before reading the result, so nothing changes the design meanwhile.
|
||||
let stopped = control.chat(json!({"stop": true}))?;
|
||||
let items = stopped["items"].as_array().or(chat["items"].as_array()).cloned().unwrap_or_default();
|
||||
let after = control.inspect()?;
|
||||
let failed_rules = check(&task["check"], &before, &after, &items);
|
||||
let passed = failed_rules.is_empty();
|
||||
let failure = if passed { "" } else { classify(&items, timed_out) };
|
||||
let seconds = began.elapsed().as_secs_f64();
|
||||
say(format!(
|
||||
"| {run} | {n} | {} | {} | {failure} | {seconds:.0} |",
|
||||
task["id"].as_str().unwrap_or_default(),
|
||||
if passed { "pass" } else { "fail" }
|
||||
))?;
|
||||
let result = json!({
|
||||
"task": n, "id": task["id"], "prompt": task["prompt"], "repeat": run, "passed": passed, "failure": failure,
|
||||
"failed_rules": failed_rules, "timed_out": timed_out, "seconds": seconds, "check": task["check"],
|
||||
"items": items, "before": before, "after": after,
|
||||
});
|
||||
kept += result.to_string().len();
|
||||
if kept > MAX_RESULTS {
|
||||
return Err("the results passed 64 MiB; run fewer tasks or repetitions".into());
|
||||
}
|
||||
results.push(result);
|
||||
// Written after every task, so an interrupted run keeps what it measured.
|
||||
if let Some(path) = o.json {
|
||||
let all = json!({"test": "v1", "model": o.model, "base_url": o.base_url, "solidcraft": version, "results": results});
|
||||
write(path, &serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?)?;
|
||||
}
|
||||
if let Some(path) = o.markdown {
|
||||
write(path, &(1..=run).map(|r| markdown(o.model, version, &results, r)).collect::<Vec<_>>().join("\n"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in 1..=o.repeat {
|
||||
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
|
||||
say(format!("run {run}: {}/{} passed", rows.iter().filter(|r| r["passed"] == true).count(), rows.len()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn design(bodies: Value, timeline: Value) -> Value {
|
||||
json!({"name": "Benchmark", "params": [], "timeline": timeline, "bodies": bodies, "sketches": [], "undo": 1, "revision": 3, "dirty": true})
|
||||
}
|
||||
|
||||
fn body(name: &str, volume: f64, min: [f64; 3], max: [f64; 3]) -> Value {
|
||||
json!({"name": name, "volume_mm3": volume, "bbox": {"min": min, "max": max}, "visible": true})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_embedded_tasks_are_v1() {
|
||||
let tasks = tasks().unwrap();
|
||||
assert_eq!(tasks.len(), 11);
|
||||
assert_eq!(tasks[0]["prompt"], "Make a 20 mm cube.");
|
||||
assert_eq!(tasks[10]["prompt"], "can you use the software to make a change to the current shape, any change at all");
|
||||
assert_eq!(tasks[5]["check"]["tol"], 0.003);
|
||||
assert_eq!(tasks[6]["check"]["bbox_min"], json!([50, 0, 0]));
|
||||
let boxes: Vec<_> = tasks.iter().filter(|t| t["setup"].is_array()).map(|t| t["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(boxes, ["06-fillet-box", "07-move-body", "08-rename-body", "09-volume-question", "11-any-change"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_rules() {
|
||||
let tasks = tasks().unwrap();
|
||||
let rule = |n: usize| tasks[n - 1]["check"].clone();
|
||||
let empty = design(json!([]), json!([]));
|
||||
let pass = |n: usize, before: &Value, after: &Value, items: &[Value]| check(&rule(n), before, after, items);
|
||||
// 3: any orientation, within 1%.
|
||||
let block = design(json!([body("Body1", 24_100.0, [0.0; 3], [40.0, 10.0, 60.2])]), json!([{"type": "Box"}]));
|
||||
assert!(pass(3, &empty, &block, &[]).is_empty());
|
||||
let thick = design(json!([body("Body1", 24_000.0, [0.0; 3], [40.0, 12.0, 60.0])]), json!([]));
|
||||
assert_eq!(pass(3, &empty, &thick, &[]).len(), 1);
|
||||
let two = design(json!([body("A", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0]), body("B", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0])]), json!([]));
|
||||
assert!(!pass(3, &empty, &two, &[]).is_empty());
|
||||
// A hidden body is not part of the result.
|
||||
let mut hidden = two.clone();
|
||||
hidden["bodies"][1]["visible"] = json!(false);
|
||||
hidden["bodies"][0]["volume_mm3"] = json!(24_000.0);
|
||||
hidden["bodies"][0]["bbox"]["max"] = json!([60.0, 40.0, 10.0]);
|
||||
assert!(pass(3, &empty, &hidden, &[]).is_empty());
|
||||
// 4: the timeline needs a working Extrude.
|
||||
let plate = |kind: Value| design(json!([body("Body1", 22_500.0, [0.0; 3], [50.0, 30.0, 15.0])]), kind);
|
||||
assert!(pass(4, &empty, &plate(json!([{"type": "Sketch"}, {"type": "Extrude"}])), &[]).is_empty());
|
||||
assert!(!pass(4, &empty, &plate(json!([{"type": "Box"}])), &[]).is_empty());
|
||||
for flag in [json!({"suppressed": true}), json!({"rolled_back": true}), json!({"error": "failed"})] {
|
||||
let mut feature = json!({"type": "Extrude"});
|
||||
feature.as_object_mut().unwrap().extend(flag.as_object().unwrap().clone());
|
||||
assert!(!pass(4, &empty, &plate(json!([feature])), &[]).is_empty(), "{flag}");
|
||||
}
|
||||
// 6: the fillet's tolerance is 0.3%.
|
||||
let start = design(json!([body("Body1", 64_000.0, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}]));
|
||||
let filleted = |v: f64| design(json!([body("Body1", v, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}, {"type": "Fillet"}]));
|
||||
assert!(pass(6, &start, &filleted(63_108.53 * 1.002), &[]).is_empty());
|
||||
assert!(!pass(6, &start, &filleted(63_108.53 * 1.004), &[]).is_empty());
|
||||
// 7: the lowest corner.
|
||||
let moved = |x: f64| design(json!([body("Body1", 8000.0, [x, 0.0, 0.0], [x + 20.0, 20.0, 20.0])]), json!([]));
|
||||
assert!(pass(7, &empty, &moved(50.0), &[]).is_empty());
|
||||
assert!(!pass(7, &empty, &moved(0.0), &[]).is_empty());
|
||||
// 8: the name.
|
||||
let named = |name: &str| design(json!([body(name, 6000.0, [0.0; 3], [30.0, 20.0, 10.0])]), json!([]));
|
||||
assert!(pass(8, &empty, &named("Bracket"), &[]).is_empty());
|
||||
assert!(!pass(8, &empty, &named("Body1"), &[]).is_empty());
|
||||
// 9: unchanged, and the reply gives the volume.
|
||||
let start = named("Body1");
|
||||
let said = |text: &str| [json!({"user": "What is the volume of the part?"}), json!({"agent": text, "thought": false})];
|
||||
let mut read = start.clone();
|
||||
read["revision"] = json!(7);
|
||||
read["dirty"] = json!(false);
|
||||
read["selection"] = json!([{"body": "Body1"}]);
|
||||
assert!(pass(9, &start, &read, &said("The volume is 6000 mm³.")).is_empty());
|
||||
assert!(!pass(9, &start, &read, &said("About 60 cm³.")).is_empty());
|
||||
assert!(!pass(9, &start, &named("Bracket"), &said("6 000 mm³")).is_empty());
|
||||
let mut undone = start.clone();
|
||||
undone["undo"] = json!(2);
|
||||
assert!(!pass(9, &start, &undone, &said("6000 mm³")).is_empty());
|
||||
// 11: any change counts; reading does not.
|
||||
assert!(pass(11, &start, &named("Bracket"), &[]).is_empty());
|
||||
assert!(pass(11, &start, &undone, &[]).is_empty());
|
||||
assert!(!pass(11, &start, &read, &[]).is_empty());
|
||||
// An unreadable design passes nothing.
|
||||
assert!(!pass(11, &start, &json!({}), &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_volume_reply() {
|
||||
for yes in ["6000", "6 000 mm³", "6,000", "6.000", "6\u{202f}000", "6 cm³", "6.00cm3", "is 6000."] {
|
||||
assert!(volume_reply(yes), "{yes}");
|
||||
}
|
||||
for no in ["60000", "6,0001", "60 cm³", "600", "6.01 cm³", ""] {
|
||||
assert!(!volume_reply(no), "{no}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_categories() {
|
||||
for (items, expected) in [
|
||||
(
|
||||
json!([{"user": "Make a 20 mm cube."}, {"agent": "{\"name\": \"execute\", \"arguments\": {}}", "thought": false}]),
|
||||
"wrote the call as text",
|
||||
),
|
||||
(
|
||||
json!([{"notice": "HTTP 400: registry.ollama.ai/library/gemma3:4b does not support tools", "error": true}]),
|
||||
"the endpoint refused tools (HTTP 400)",
|
||||
),
|
||||
(json!([{"user": "Make a 20 mm cube."}, {"agent": "", "thought": false}]), "an empty reply"),
|
||||
(json!([{"agent": "Let me think", "thought": true}]), "an empty reply"),
|
||||
(json!([{"agent": "Sure, here is how to make a cube.", "thought": false}]), "only talked"),
|
||||
(
|
||||
json!([{"tool": "1", "status": "Failed", "output": "unknown command `solid.cube` (did you mean `solid.box`?)"}]),
|
||||
"wrong or unknown command",
|
||||
),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "unknown tool `make_cube`"}]), "wrong or unknown command"),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "invalid parameters for `solid.box`: `length` is required"}]), "wrong arguments"),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "Not run: the tool arguments were not valid JSON"}]), "wrong arguments"),
|
||||
(json!([{"tool": "1", "status": "Failed", "output": "no body `Box`"}]), "gave up after errors"),
|
||||
(
|
||||
json!([{"tool": "1", "status": "Completed", "output": "{\"ok\": false, \"failed\": {\"error\": \"unknown command `solid.delete`\"}}"}]),
|
||||
"wrong or unknown command",
|
||||
),
|
||||
(
|
||||
json!([{"tool": "1", "status": "Completed"}, {"agent": "\n(stopped after 24 requests for one message; send another message to go on)"}]),
|
||||
"hit the request limit",
|
||||
),
|
||||
(json!([{"tool": "1", "status": "Completed", "output": "{}"}]), "wrong result"),
|
||||
] {
|
||||
let items = items.as_array().unwrap();
|
||||
assert_eq!(classify(items, false), expected, "{items:?}");
|
||||
assert_eq!(classify(items, true), "timeout");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_is_the_comment_template() {
|
||||
let rows: Vec<Value> =
|
||||
(1..=11).map(|n| json!({"task": n, "repeat": 1, "passed": n != 2, "failure": if n == 2 { "an empty reply" } else { "" }})).collect();
|
||||
let report = markdown("qwen2.5:7b", "0.19.0", &rows, 1);
|
||||
assert!(report.starts_with(
|
||||
"### qwen2.5:7b (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: 0.19.0\nResult: 10/11\n| # | pass/fail | how it failed |\n|---|---|---|\n| 1 | pass | |\n| 2 | fail | an empty reply |\n"
|
||||
));
|
||||
assert!(report.ends_with("| 11 | pass | |\nNotes: <anything odd, e.g. very slow, needed a retry>\n"));
|
||||
let partial = markdown("m|x", "v", &rows[..1], 1);
|
||||
assert!(partial.starts_with("### m x ("));
|
||||
assert!(partial.contains("Result: 1/11") && partial.contains("| 2 | | not run |") && partial.contains("partial run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_are_checked_before_connecting() {
|
||||
for extra in [
|
||||
&["--repeat", "0"][..],
|
||||
&["--repeat", "101"],
|
||||
&["--timeout-s", "3601"],
|
||||
&["--tasks", "0"],
|
||||
&["--tasks", "12"],
|
||||
&["--tasks", "1,1"],
|
||||
&["--model", "again"],
|
||||
&["--unexpected", "1"],
|
||||
&["--token"],
|
||||
] {
|
||||
let mut args: Vec<String> =
|
||||
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "http://127.0.0.1:11434/v1"].iter().map(|s| s.to_string()).collect();
|
||||
args.extend(extra.iter().map(|s| s.to_string()));
|
||||
assert!(options(&args).is_err(), "{extra:?}");
|
||||
}
|
||||
let args: Vec<String> =
|
||||
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "u", "--tasks", "2, 5"].iter().map(|s| s.to_string()).collect();
|
||||
let o = options(&args).unwrap();
|
||||
assert_eq!((o.tasks, o.repeat, o.timeout), (vec![2, 5], 1, Duration::from_secs(300)));
|
||||
assert!(Control::connect("10.0.0.1:9271", Some("t")).is_err());
|
||||
assert!(Control::connect("localhost", Some("t")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
|
||||
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N]
|
||||
//! [--timeout-s 300] [--json out.json] [--markdown out.md]
|
||||
//! the standard Assistant model test (#4851) on a running app
|
||||
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
@@ -24,6 +27,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bench_agent;
|
||||
mod bench_assistant;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
@@ -57,6 +61,7 @@ const USAGE: &str = "usage:
|
||||
(--ops: after opening, run each command of the script and undo it, timing the command, the
|
||||
evaluations it caused and the view it leaves: placing and meshing what changed)
|
||||
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
|
||||
solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]
|
||||
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
|
||||
@@ -136,6 +141,8 @@ fn main() -> ExitCode {
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
// A ~1,300-body design built command by command, as an agent builds one over MCP.
|
||||
Some("bench-agent") => bench_agent::run(&args[1..]),
|
||||
// The standard Assistant model test (#4851), run against a desktop app over its control channel.
|
||||
Some("bench-assistant") => bench_assistant::run(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -716,9 +723,10 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::opened(doc, saved.as_ref());
|
||||
drop(read);
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway).
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
|
||||
// only the features whose state a thinned file left out.
|
||||
let adopted = saved.is_some() && s.model.last_recomputed == 0;
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted}));
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted, "recomputed": s.model.last_recomputed}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::{ModelBody, ModelState};
|
||||
use solidcraft_engine::doc::{ModelBody, ModelState, SavedModel};
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
@@ -149,6 +149,87 @@ fn imported_assemblies_open_from_their_model_as_evaluated() {
|
||||
check("imported hinge", s);
|
||||
}
|
||||
|
||||
/// `built` saved with `saved`, a model of it that keeps only some states (as a file without room
|
||||
/// for all of them does, #3220), opens as the design evaluated again: evaluating just the
|
||||
/// features whose state the file left out, then after suppressing a feature at, before or after
|
||||
/// each state it kept, after undoing that, and with the marker moved there and back.
|
||||
fn check_thinned(name: &str, built: &Session, saved: &SavedModel) {
|
||||
let design = built.design();
|
||||
let bytes =
|
||||
solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(saved)).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the file keeps the final state");
|
||||
let path = temp(&name.replace(' ', "-"));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut cached = open(&path);
|
||||
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
|
||||
let features = saved.records["features"].as_array().unwrap();
|
||||
let unkept = features.iter().filter(|f| f["skipped"] == false && f.get("state").is_none()).count();
|
||||
assert!(unkept > 0, "{name}: some states left out");
|
||||
assert_eq!(cached.model.recomputed, unkept, "{name}: evaluated just the features whose state the file left out");
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
|
||||
let n = cached.doc.features.len();
|
||||
let mut at: Vec<usize> = features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f["skipped"] == false && f.get("state").is_some())
|
||||
.flat_map(|(k, _)| [k.saturating_sub(1), k, k + 1])
|
||||
.filter(|i| *i < n)
|
||||
.collect();
|
||||
at.dedup();
|
||||
for i in at {
|
||||
let edit = json!({"feature": cached.doc.features[i].id, "suppressed": true});
|
||||
let (a, b) = (cached.execute("timeline.suppress", &edit), rebuilt.execute("timeline.suppress", &edit));
|
||||
assert_eq!(a.is_ok(), b.is_ok(), "{name}: suppressing feature {i}");
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: feature {i} suppressed");
|
||||
if a.is_ok() {
|
||||
cached.execute("edit.undo", &json!({})).unwrap();
|
||||
rebuilt.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: suppressing feature {i} undone");
|
||||
}
|
||||
for marker in [json!({"position": i}), json!({})] {
|
||||
cached.execute("timeline.rollback", &marker).unwrap();
|
||||
rebuilt.execute("timeline.rollback", &marker).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: the marker at {marker}, from feature {i}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A model too big for its file keeps the final state, then the slowest features' states as fit
|
||||
/// (#3220). Both the smallest such model (the final state, and states that add no body to it)
|
||||
/// and one that keeps more open as the design evaluated again. The placed components' design has
|
||||
/// a cut whose fingerprint reads where the bodies before it lie, which a state left out does not
|
||||
/// say until it is evaluated.
|
||||
#[test]
|
||||
fn thinned_models_open_as_evaluated() {
|
||||
let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/components-arm.solidcraft");
|
||||
for sample in ["Sample Plate", "Plastic Enclosure", "components-arm"] {
|
||||
let built = match solidcraft_engine::sample::samples().into_iter().find(|s| s.name == sample) {
|
||||
Some(s) => {
|
||||
let mut built = Session::default();
|
||||
for c in (s.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
built
|
||||
}
|
||||
None => open(&corpus),
|
||||
};
|
||||
let saved = built.model.saved(&built.design()).unwrap();
|
||||
// Every body a megabyte (the records fit in the room left over): each budget keeps one more
|
||||
// body's worth of states.
|
||||
let body = 1 << 20;
|
||||
let pieces: Vec<Vec<(usize, usize)>> = (0..saved.bodies.len()).map(|i| vec![(i, body)]).collect();
|
||||
let thinned: Vec<SavedModel> =
|
||||
(0..=saved.bodies.len()).filter_map(|n| saved.thinned(&pieces, n * body + body - 1).map(|(m, _, _)| m)).collect();
|
||||
let kept = |m: &SavedModel| m.records["features"].as_array().unwrap().iter().filter(|f| f.get("state").is_some()).count();
|
||||
let smallest = thinned.first().unwrap();
|
||||
assert!(smallest.records["states"].as_array().unwrap().len() < saved.records["states"].as_array().unwrap().len(), "{sample}");
|
||||
let more = thinned.iter().find(|m| kept(m) > kept(smallest) && kept(m) < built.doc.features.len()).unwrap();
|
||||
check_thinned(&format!("{sample} smallest"), &built, smallest);
|
||||
check_thinned(&format!("{sample} thinned"), &built, more);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
|
||||
/// saved states before it are kept as they were read (the same states, not evaluated again).
|
||||
#[test]
|
||||
|
||||
@@ -30,5 +30,5 @@ web-sys = { version = "0.3", features = [
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "MediaQueryList", "Gamepad", "Performance",
|
||||
] }
|
||||
|
||||
@@ -499,7 +499,9 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
|
||||
// Everything the browser build imports, from the format registry (no cadmpeg formats here).
|
||||
let accept = solidcraft_engine::io::formats::open_extensions(false).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
input.set_accept(&accept);
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
if let Some(body) = doc.body() {
|
||||
|
||||
@@ -17,6 +17,14 @@ mod install;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
|
||||
/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and
|
||||
/// Ctrl+PageDown already reach the browser.)
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn browser_switches_tabs(key: &str, ctrl: bool) -> bool {
|
||||
ctrl && key == "Tab"
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
@@ -84,6 +92,20 @@ mod web {
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
|
||||
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
|
||||
/// the canvas, before eframe sees them; the browser then acts on them as usual.
|
||||
fn leave_tab_switching_to_browser(window: &web_sys::Window) {
|
||||
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
|
||||
if crate::browser_switches_tabs(&e.key(), e.ctrl_key()) {
|
||||
e.stop_propagation();
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true);
|
||||
// The listener lives as long as the page.
|
||||
on_key.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
@@ -109,6 +131,7 @@ mod web {
|
||||
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -487,6 +510,10 @@ mod web {
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
|
||||
if let Some(delay) = self.wheel_gestures.update(raw, now) {
|
||||
ctx.request_repaint_after(delay);
|
||||
}
|
||||
files::take_drops(&self.files, raw);
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
|
||||
@@ -553,6 +580,7 @@ mod web {
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
leave_tab_switching_to_browser(&window);
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
@@ -650,6 +678,7 @@ mod web {
|
||||
cjk_bytes: None,
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
@@ -675,3 +704,17 @@ fn main() {
|
||||
fn main() {
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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.
|
||||
assert!(browser_switches_tabs("Tab", true));
|
||||
// Tab and Shift+Tab move between the app's fields; Ctrl with other keys stays the app's.
|
||||
assert!(!browser_switches_tabs("Tab", false));
|
||||
assert!(!browser_switches_tabs("s", true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ windows-sys = { version = "0.61", features = [
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
|
||||
solidcraft-3dx-client = { workspace = true }
|
||||
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
|
||||
@@ -78,7 +79,7 @@ hidapi = { version = "2.6.7", default-features = false, features = ["macos-share
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! The graphics device was lost after the window opened: the driver was reset (on Windows a TDR,
|
||||
//! "Display driver stopped responding and has recovered") or the GPU went away (#4172). A lost
|
||||
//! device can't draw again, so the window can't show anything more, not even a message. The
|
||||
//! painter stops drawing (`egui_wgpu::DeviceLost`); the app then closes, keeping unsaved changes
|
||||
//! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what
|
||||
//! happened ([`note`]). A device lost again soon after such a restart starts the next one on the
|
||||
//! software renderer, which doesn't depend on the graphics driver.
|
||||
|
||||
use crate::startup;
|
||||
|
||||
/// Set on the start after a lost device: when the device was lost (seconds since 1970).
|
||||
pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST";
|
||||
|
||||
/// A loss this recent (seconds) counts for the start after it: its note, and a second loss
|
||||
/// within it starts on the software renderer. Inherited by later starts, an older stamp is
|
||||
/// ignored.
|
||||
const RECENT_SECS: u64 = 600;
|
||||
|
||||
/// Seconds since 1970.
|
||||
pub fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Whether `stamp` ([`LOST_ENV`]) names a loss at most [`RECENT_SECS`] before `now`.
|
||||
pub fn recent(stamp: Option<&str>, now: u64) -> bool {
|
||||
stamp.and_then(|s| s.trim().parse::<u64>().ok()).is_some_and(|at| at <= now && now - at <= RECENT_SECS)
|
||||
}
|
||||
|
||||
/// Whether the start after a lost device uses the software renderer: when this launch already
|
||||
/// followed a recent loss (the driver keeps losing it), or ran on it anyway.
|
||||
pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool {
|
||||
after_recent_loss || cpu_renderer
|
||||
}
|
||||
|
||||
/// What the start after a lost device tells the user, `software` when it runs on the software
|
||||
/// renderer.
|
||||
pub fn note(software: bool) -> String {
|
||||
let mut note = String::from(
|
||||
"The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.",
|
||||
);
|
||||
if software {
|
||||
note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver.");
|
||||
}
|
||||
note
|
||||
}
|
||||
|
||||
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software
|
||||
/// renderer when `software`.
|
||||
pub fn restart(software: bool, lost_at: u64) {
|
||||
let Ok(exe) = std::env::current_exe() else { return };
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
|
||||
if software {
|
||||
command.env(startup::RENDERER_ENV, "cpu");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Not tied to this process: its own process group, no console.
|
||||
command.creation_flags(0x0000_0200 | 0x0000_0008);
|
||||
}
|
||||
if let Err(e) = command.spawn() {
|
||||
log::error!("graphics: starting again after the device was lost: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
|
||||
#[test]
|
||||
fn only_a_recent_loss_counts() {
|
||||
let now = 1_000_000;
|
||||
assert!(super::recent(Some("999990"), now));
|
||||
assert!(super::recent(Some(" 999400 "), now));
|
||||
assert!(!super::recent(Some("999399"), now), "older than ten minutes");
|
||||
assert!(!super::recent(Some("1000001"), now), "from the future");
|
||||
assert!(!super::recent(Some("soon"), now));
|
||||
assert!(!super::recent(None, now));
|
||||
}
|
||||
|
||||
/// The first loss starts again on the GPU; a second one soon after, on the software renderer.
|
||||
#[test]
|
||||
fn a_second_loss_in_a_row_starts_on_the_software_renderer() {
|
||||
assert!(!super::restart_on_software(false, false));
|
||||
assert!(super::restart_on_software(true, false));
|
||||
assert!(super::restart_on_software(false, true));
|
||||
assert!(super::note(true).contains("software rendering"));
|
||||
assert!(!super::note(false).contains("software rendering"));
|
||||
assert!(super::note(false).contains("kept for recovery"));
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,14 @@
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
mod device_lost;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
mod space_mouse;
|
||||
@@ -76,8 +79,12 @@ struct App {
|
||||
frames: u32,
|
||||
/// Started over the control channel: the window doesn't ask for the foreground.
|
||||
take_focus_unwanted: bool,
|
||||
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
|
||||
gpu: Option<LostWatch>,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -107,6 +114,7 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.watch_device();
|
||||
// The design on screen is the one whose commands a crash report replays.
|
||||
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
@@ -119,6 +127,10 @@ impl eframe::App for App {
|
||||
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
|
||||
self.solve.open_path_async(&f);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.native_menu {
|
||||
menu.tick(&mut self.solve, ctx);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
if !self.solve.file_picker_busy() {
|
||||
@@ -129,6 +141,10 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.native_menu {
|
||||
menu.raw_input_hook(&mut self.solve, _ctx, raw);
|
||||
}
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
@@ -147,6 +163,9 @@ impl eframe::App for App {
|
||||
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
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);
|
||||
}
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
|
||||
let mut command = std::process::Command::new(&program);
|
||||
@@ -166,6 +185,48 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's graphics device, watched for its loss.
|
||||
struct LostWatch {
|
||||
lost: eframe::egui_wgpu::DeviceLost,
|
||||
/// Where the next start runs once this device is lost ([`device_lost::restart_on_software`]).
|
||||
restart_on_software: bool,
|
||||
/// When the loss was seen: the app is closing to start again.
|
||||
lost_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The graphics device was lost (#4172): the window can't draw again, so close, keeping
|
||||
/// unsaved changes for recovery as a forced quit does (the window can't ask about them), and
|
||||
/// start again once closed ([`device_lost::restart`]).
|
||||
fn watch_device(&mut self) {
|
||||
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
|
||||
let reason = gpu.lost.reason().unwrap_or_default();
|
||||
log::error!("graphics: the device was lost ({reason}); closing to start again");
|
||||
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
|
||||
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
|
||||
gpu.lost_at = Some(device_lost::now_secs());
|
||||
self.solve.quit_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux file dialogs match patterns by case, so each extension goes in as lower and upper
|
||||
/// case, plus FreeCAD's own mixed casing.
|
||||
fn case_variants(exts: &[&str]) -> Vec<String> {
|
||||
let mut all: Vec<String> = Vec::new();
|
||||
for e in exts {
|
||||
let mut forms = vec![e.to_ascii_lowercase(), e.to_ascii_uppercase()];
|
||||
if e.eq_ignore_ascii_case("fcstd") {
|
||||
forms.push("FCStd".into());
|
||||
}
|
||||
for v in forms {
|
||||
if !all.contains(&v) {
|
||||
all.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
||||
let open_parent = parent.clone();
|
||||
@@ -177,48 +238,14 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let mut dialog = open_parent.clone();
|
||||
if !first.is_empty() {
|
||||
let label = first.iter().map(|e| e.to_uppercase()).collect::<Vec<_>>().join(", ");
|
||||
let both: Vec<String> = first.iter().flat_map(|e| [e.to_lowercase(), e.to_uppercase()]).collect();
|
||||
dialog = dialog.add_filter(label, &both);
|
||||
dialog = dialog.add_filter(label, &case_variants(first));
|
||||
}
|
||||
let future = dialog
|
||||
.add_filter(
|
||||
"Designs, CAD files and meshes",
|
||||
&[
|
||||
io::DESIGN_EXT,
|
||||
io::LEGACY_DESIGN_EXT,
|
||||
"json",
|
||||
"step",
|
||||
"stp",
|
||||
"STEP",
|
||||
"STP",
|
||||
"igs",
|
||||
"iges",
|
||||
"IGS",
|
||||
"IGES",
|
||||
"FCStd",
|
||||
"fcstd",
|
||||
"3dm",
|
||||
"3DM",
|
||||
"3mf",
|
||||
"3MF",
|
||||
"stl",
|
||||
"STL",
|
||||
"obj",
|
||||
"OBJ",
|
||||
"glb",
|
||||
"GLB",
|
||||
"gltf",
|
||||
"GLTF",
|
||||
],
|
||||
)
|
||||
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
|
||||
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file();
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
let future = dialog.add_filter("All files", &["*"]).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
// One kind of file (the agent chat's attachments): only the types it names, as given and in
|
||||
@@ -483,6 +510,18 @@ fn backend_preference() -> Option<String> {
|
||||
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// The viewport scene's sample count: the app's anti-aliasing (the window's pass is 1x), clamped
|
||||
/// by the backend's policy (OpenGL renders 1x, see `effective_msaa_samples`). A count the device
|
||||
/// cannot render with falls back to the supported default.
|
||||
fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]) -> u32 {
|
||||
let requested = eframe::egui_wgpu::effective_msaa_samples(backend, samples.max(1));
|
||||
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
|
||||
requested
|
||||
} else {
|
||||
supported.iter().copied().filter(|&n| n <= solidcraft_ui_egui::prefs::Prefs::default().msaa).max().unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics()
|
||||
@@ -529,6 +568,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
|
||||
mark("main");
|
||||
adopt_legacy_settings();
|
||||
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
||||
@@ -579,6 +620,11 @@ fn main() -> eframe::Result {
|
||||
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
|
||||
}
|
||||
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
|
||||
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
|
||||
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
|
||||
if after_loss {
|
||||
notes.push(device_lost::note(cpu_renderer));
|
||||
}
|
||||
if safe_mode {
|
||||
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
||||
}
|
||||
@@ -604,9 +650,11 @@ fn main() -> eframe::Result {
|
||||
solidcraft_engine::crash::phase("window");
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: depth_bits,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// The window's pass (egui's) has no depth and one sample: the viewport draws its scene
|
||||
// offscreen with its own depth and MSAA, then blits it, so the UI pass pays for neither.
|
||||
depth_buffer: 0,
|
||||
stencil_buffer: 0,
|
||||
multisampling: 1,
|
||||
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
|
||||
// and writes a scratch copy, leaving the user's settings as they were.
|
||||
persistence_path: if safe_mode {
|
||||
@@ -669,10 +717,16 @@ fn main() -> eframe::Result {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
mark("app state and prefs");
|
||||
let gpu = cc.wgpu_render_state.as_ref().map(|rs| LostWatch {
|
||||
lost: rs.device_lost.clone(),
|
||||
restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer),
|
||||
lost_at: None,
|
||||
});
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu(rs, depth_bits, STENCIL_BITS, rs.msaa_samples);
|
||||
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
|
||||
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
@@ -682,8 +736,8 @@ fn main() -> eframe::Result {
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
solidcraft_engine::crash::set_environment(&format!(
|
||||
"Renderer: {:?} {} ({}), MSAA {}x",
|
||||
info.backend, info.name, info.driver, rs.msaa_samples
|
||||
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
|
||||
info.backend, info.name, info.driver
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
@@ -739,6 +793,18 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
|
||||
Ok(menu) => Some(menu),
|
||||
Err(error) => {
|
||||
app.set_status(format!("System menu: {error}"), true);
|
||||
None
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.native_file_menu = native_menu.is_some();
|
||||
}
|
||||
mark("app created");
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
@@ -746,8 +812,11 @@ fn main() -> eframe::Result {
|
||||
starting,
|
||||
frames: 0,
|
||||
take_focus_unwanted: control_port.is_some(),
|
||||
gpu,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
@@ -909,4 +978,13 @@ mod msaa_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scene_follows_the_backend_policy_too() {
|
||||
use eframe::wgpu::Backend;
|
||||
assert_eq!(super::scene_samples(Backend::Gl, 4, &[1, 4]), 1);
|
||||
assert_eq!(super::scene_samples(Backend::Gl, 4, &[]), 1);
|
||||
assert_eq!(super::scene_samples(Backend::Vulkan, 4, &[1, 4]), 4);
|
||||
assert_eq!(super::scene_samples(Backend::Dx12, 8, &[1, 2, 4]), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! macOS system menus, owned and updated on eframe's main thread.
|
||||
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use muda::{
|
||||
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
accelerator::{Accelerator, Code, Modifiers},
|
||||
};
|
||||
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
|
||||
|
||||
pub struct NativeMenu {
|
||||
menu: Menu,
|
||||
actions: Vec<(MenuItem, FileAction)>,
|
||||
help_actions: Vec<(MenuItem, &'static str)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
recent: Submenu,
|
||||
recent_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
|
||||
let menu = Menu::new();
|
||||
let application = Submenu::new("SolidCraft", true);
|
||||
let file = Submenu::new(app.tr("ui.file"), true);
|
||||
let edit = Submenu::new(app.tr_label("Edit"), true);
|
||||
let recent = Submenu::new(app.tr("ui.open_recent"), true);
|
||||
let window = Submenu::new("Window", true);
|
||||
let help = Submenu::new(app.tr("ui.help"), true);
|
||||
let mut help_actions = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
|
||||
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
|
||||
parent.append(&item)?;
|
||||
actions.push((item, action));
|
||||
Ok(())
|
||||
};
|
||||
application.append(&PredefinedMenuItem::about(
|
||||
Some("About SolidCraft"),
|
||||
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
|
||||
))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
add(
|
||||
&application,
|
||||
&format!("{}\u{2026}", app.tr("ui.preferences")),
|
||||
FileAction::Settings,
|
||||
Some(Accelerator::new(Modifiers::META, Code::Comma)),
|
||||
)?;
|
||||
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append(&PredefinedMenuItem::services(None))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append_items(&[
|
||||
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
])?;
|
||||
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
|
||||
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
|
||||
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
|
||||
file.append(&recent)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, command) in
|
||||
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
|
||||
{
|
||||
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
|
||||
add(
|
||||
&file,
|
||||
&format!("{}\u{2026}", app.tr("ui.save_as")),
|
||||
FileAction::SaveAs,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
|
||||
)?;
|
||||
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, action) in [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
] {
|
||||
add(&file, app.tr_label(label), action, None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
|
||||
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
|
||||
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
|
||||
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
|
||||
window.append(&PredefinedMenuItem::separator())?;
|
||||
window.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::fullscreen(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
])?;
|
||||
for (action, label) in [
|
||||
("docs", "Documentation"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("messages", "Messages\u{2026}"),
|
||||
("report", "Report an Issue\u{2026}"),
|
||||
("crashes", "Crash Reports\u{2026}"),
|
||||
] {
|
||||
let item = MenuItem::new(app.tr_label(label), true, None);
|
||||
help.append(&item)?;
|
||||
help_actions.push((item, action));
|
||||
}
|
||||
menu.append_items(&[&application, &file, &edit, &window, &help])?;
|
||||
let (sender, events) = sync_channel(64);
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.try_send(event);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
menu.init_for_nsapp();
|
||||
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
|
||||
native.update_recent(app)?;
|
||||
Ok(native)
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
for event in self.events.try_iter().take(64) {
|
||||
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
solidcraft_ui_egui::help::run(app, ctx, action);
|
||||
}
|
||||
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
|
||||
raw.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
|
||||
});
|
||||
} else {
|
||||
action.run(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
|
||||
action.enabled(app)
|
||||
|| (matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() && FileAction::Settings.enabled(app))
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
for (item, action) in &self.actions {
|
||||
let enabled = Self::action_enabled(action, app, ctx);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
|
||||
&& let Err(error) = self.update_recent(app)
|
||||
{
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
|
||||
while self.recent.remove_at(0).is_some() {}
|
||||
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
|
||||
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
|
||||
for path in &self.recent_paths {
|
||||
let action = FileAction::OpenRecent(path.clone());
|
||||
let item = MenuItem::new(path, action.enabled(app), None);
|
||||
self.recent.append(&item)?;
|
||||
self.actions.push((item, action));
|
||||
}
|
||||
self.recent.append(&PredefinedMenuItem::separator())?;
|
||||
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
|
||||
self.recent.append(&clear)?;
|
||||
self.actions.push((clear, FileAction::ClearRecent));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeMenu {
|
||||
fn drop(&mut self) {
|
||||
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
|
||||
self.menu.remove_for_nsapp();
|
||||
}
|
||||
}
|
||||
@@ -284,15 +284,18 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst
|
||||
true
|
||||
}
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
/// Publishes one input report from the device to the egui thread. A report that reads the same as
|
||||
/// the reading it replaces is no news — a device may repeat its report at rest — so it asks for no
|
||||
/// frame; while the cap moves, the view asks for the next frame itself.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let before = reading.axes;
|
||||
if decode_report(report, layout, &mut reading, Instant::now()) && reading.axes != before {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,6 +889,31 @@ mod tests {
|
||||
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
|
||||
}
|
||||
|
||||
/// A device that repeats its reading at rest asks for no frame; a reading that changes does.
|
||||
#[test]
|
||||
fn a_repeated_report_at_rest_asks_for_no_frame() {
|
||||
let ctx = Context::default();
|
||||
let shared = Mutex::new(Reading::new(Instant::now()));
|
||||
let layout = Layout::default();
|
||||
let frame = || {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let asked = || {
|
||||
frame();
|
||||
ctx.repaint_causes().iter().any(|c| c.file.ends_with("space_mouse.rs"))
|
||||
};
|
||||
frame();
|
||||
// Report 1 carries translation (see the split-layout test).
|
||||
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0];
|
||||
publish(&ctx, &shared, layout, &report);
|
||||
assert!(asked(), "a report that moves the cap asks for a frame");
|
||||
publish(&ctx, &shared, layout, &report);
|
||||
assert!(!asked(), "the same reading at rest is no news");
|
||||
publish(&ctx, &shared, layout, &[1, 0x9d, 0xff, 0x2c, 0x01, 0, 0]);
|
||||
assert!(asked(), "a changed reading asks for a frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() {
|
||||
let now = Instant::now();
|
||||
|
||||
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 629 B After Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -1,88 +1,93 @@
|
||||
//! Original SolidCraft mark, shared by desktop, packaging and CLI.
|
||||
//! Original SolidCraft mark, shared by desktop, packaging and CLI: the borderless mark, five
|
||||
//! faces with transparent gaps between them, drawn from the macOS icon's
|
||||
//! `macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg`.
|
||||
type P = (f32, f32);
|
||||
|
||||
/// One face in that SVG's units: its outline, and its linear gradient from one point and
|
||||
/// colour to another.
|
||||
struct Face {
|
||||
outline: &'static [P],
|
||||
from: (P, [u8; 3]),
|
||||
to: (P, [u8; 3]),
|
||||
}
|
||||
|
||||
const FACES: [Face; 5] = [
|
||||
// Inner side of the upper step.
|
||||
Face {
|
||||
outline: &[(547.883, 282.66), (709.797, 188.211), (709.797, 375.985)],
|
||||
from: ((619.14, 179.734), [29, 99, 198]),
|
||||
to: ((694.687, 396.761), [21, 73, 158]),
|
||||
},
|
||||
// Top of the lower step.
|
||||
Face {
|
||||
outline: &[(727.797, 593.626), (727.797, 407.136), (889.571, 500.381)],
|
||||
from: ((619.14, 413.244), [69, 159, 255]),
|
||||
to: ((790.839, 605.547), [35, 121, 231]),
|
||||
},
|
||||
// Right face, with the recess.
|
||||
Face {
|
||||
outline: &[(530.0, 521.17), (691.797, 427.912), (691.797, 655.928), (907.594, 531.545), (907.594, 740.159), (530.0, 957.8)],
|
||||
from: ((632.876, 399.508), [27, 102, 207]),
|
||||
to: ((783.971, 968.175), [18, 69, 150]),
|
||||
},
|
||||
// Left face, stepped.
|
||||
Face {
|
||||
outline: &[(116.406, 510.769), (116.406, 303.529), (494.0, 521.17), (494.0, 957.8), (314.203, 854.167), (314.203, 624.777)],
|
||||
from: ((207.063, 303.357), [37, 141, 245]),
|
||||
to: ((426.837, 968.175), [20, 97, 218]),
|
||||
},
|
||||
// Top of the extruded profile.
|
||||
Face {
|
||||
outline: &[(134.374, 272.334), (512.0, 53.364), (691.828, 157.015), (476.117, 282.846), (673.752, 396.761), (512.0, 489.993)],
|
||||
from: ((385.63, 53.364), [81, 173, 255]),
|
||||
to: ((522.989, 510.769), [43, 131, 241]),
|
||||
},
|
||||
];
|
||||
|
||||
/// The mark's extent in SVG units: left, top, width, height.
|
||||
const BOUNDS: (f32, f32, f32, f32) = (116.406, 53.364, 791.188, 904.436);
|
||||
|
||||
/// This block is the icon image data
|
||||
pub fn rgba(size: usize) -> Result<Vec<u8>, String> {
|
||||
if !(1..=2048).contains(&size) {
|
||||
return Err("icon size must be 1..=2048".into());
|
||||
}
|
||||
type P = (f32, f32);
|
||||
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
|
||||
let ss = if size > 512 { 2 } else { 4 };
|
||||
let n = size * ss;
|
||||
let mut buf = vec![[0u8; 4]; n * n];
|
||||
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
|
||||
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
|
||||
// Fill a convex polygon given in SVG units.
|
||||
let mut fill = |pts: &[P], col: [u8; 3]| {
|
||||
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
|
||||
let (bx, by, bw, bh) = BOUNDS;
|
||||
let s = n as f32 / bh; // SVG units -> samples: the mark fills the height
|
||||
let (ox, oy) = ((n as f32 - bw * s) / 2.0 - bx * s, -by * s); // and is centred sideways
|
||||
// Fill each face's outline (even-odd spans per row, so concave outlines work) with its gradient.
|
||||
for face in &FACES {
|
||||
let v: Vec<P> = face.outline.iter().map(|p| (ox + p.0 * s, oy + p.1 * s)).collect();
|
||||
let (((fx, fy), c0), ((tx, ty), c1)) = (face.from, face.to);
|
||||
let (gx, gy) = (tx - fx, ty - fy);
|
||||
let g2 = gx * gx + gy * gy;
|
||||
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
|
||||
let mut xs = Vec::with_capacity(v.len());
|
||||
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
let cy = y as f32 + 0.5;
|
||||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||
xs.clear();
|
||||
for i in 0..v.len() {
|
||||
let (a, b) = (v[i], v[(i + 1) % v.len()]);
|
||||
if (a.1 <= cy) != (b.1 <= cy) {
|
||||
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
xs.push(a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0));
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
continue;
|
||||
}
|
||||
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
|
||||
xs.sort_by(f32::total_cmp);
|
||||
for &[from, to] in xs.as_chunks::<2>().0 {
|
||||
for x in ((from - 0.5).ceil() as i32).max(0)..=((to - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
// The sample's place along the gradient, in SVG units.
|
||||
let (px, py) = ((x as f32 + 0.5 - ox) / s, (cy - oy) / s);
|
||||
let t = (((px - fx) * gx + (py - fy) * gy) / g2).clamp(0.0, 1.0);
|
||||
let mix = |k: usize| (f32::from(c0[k]) + (f32::from(c1[k]) - f32::from(c0[k])) * t).round() as u8;
|
||||
buf[y as usize * n + x as usize] = [mix(0), mix(1), mix(2), 255];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
|
||||
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
|
||||
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
|
||||
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
|
||||
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
|
||||
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
|
||||
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
|
||||
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
|
||||
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
|
||||
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
|
||||
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
|
||||
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
|
||||
let white = [255, 255, 255];
|
||||
let mut stroke = |pts: &[P], closed: bool| {
|
||||
let m = pts.len();
|
||||
for i in 0..(if closed { m } else { m - 1 }) {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
||||
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
||||
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
|
||||
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
|
||||
}
|
||||
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
|
||||
let disc: Vec<P> = (0..16)
|
||||
.map(|k| {
|
||||
let t = k as f32 * std::f32::consts::TAU / 16.0;
|
||||
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
|
||||
})
|
||||
.collect();
|
||||
fill(&disc, white);
|
||||
}
|
||||
};
|
||||
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
|
||||
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
|
||||
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
|
||||
stroke(
|
||||
&[
|
||||
(178.5, 5.8),
|
||||
(5.0, 105.9),
|
||||
(5.0, 206.1),
|
||||
(91.7, 256.2),
|
||||
(91.7, 356.4),
|
||||
(178.5, 406.4),
|
||||
(352.0, 306.3),
|
||||
(352.0, 206.1),
|
||||
(265.2, 156.0),
|
||||
(265.2, 55.9),
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Average each ss x ss block down to one pixel (straight alpha = covered share).
|
||||
let mut rgba = Vec::with_capacity(size * size * 4);
|
||||
for y in 0..size {
|
||||
@@ -123,4 +128,20 @@ mod tests {
|
||||
assert!(super::rgba(0).is_err());
|
||||
assert!(super::rgba(2049).is_err());
|
||||
}
|
||||
|
||||
/// Borderless: no white edge lines, and the gaps between faces are see-through. At 256 px
|
||||
/// the gap between the left and right faces (SVG x 494..530) is transparent on the row
|
||||
/// through the middle of the mark, and no opaque pixel is white.
|
||||
#[test]
|
||||
fn shared_mark_is_borderless() {
|
||||
let size = 256;
|
||||
let pixels = super::rgba(size).expect("valid size");
|
||||
let px = |x: usize, y: usize| pixels.as_chunks::<4>().0[y * size + x];
|
||||
let s = size as f32 / 904.436;
|
||||
let gap_x = ((size as f32 - 791.188 * s) / 2.0 + (512.0 - 116.406) * s) as usize;
|
||||
let row = ((750.0 - 53.364) * s) as usize;
|
||||
assert_eq!(px(gap_x, row)[3], 0, "the seam between the left and right faces is a gap");
|
||||
assert!(px(gap_x - 8, row)[3] == 255 && px(gap_x + 8, row)[3] == 255, "faces on both sides of the gap");
|
||||
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 0 || p[0] < 200), "no white edges");
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 44 KiB |
@@ -104,10 +104,10 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
|
||||
|
||||
/// The same original mark as a single quiet silhouette, without face seams or outlines.
|
||||
pub(crate) fn brand_mark_silhouette(p: &egui::Painter, c: Pos2, height: f32, ink: Color32) {
|
||||
brand_mark_styled(p, c, height, [ink; 4], false);
|
||||
brand_mark_styled(p, c, height, [ink; 3], false);
|
||||
}
|
||||
|
||||
fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32; 4], edges: bool) {
|
||||
fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32; 3], edges: bool) {
|
||||
let s = height / 412.217;
|
||||
let m = |pts: &[(f32, f32)]| -> Vec<egui::Pos2> { pts.iter().map(|&(x, y)| pos2(c.x + (x - 178.5) * s, c.y + (y - 206.1) * s)).collect() };
|
||||
// egui only fills convex shapes, so each face is a few overlapping convex pieces.
|
||||
@@ -115,7 +115,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
|
||||
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
|
||||
};
|
||||
// The faces are separated by gaps that show the application bar, not by white lines.
|
||||
let [light, mid, dark, _] = colours;
|
||||
let [light, mid, dark] = colours;
|
||||
let edge_col = crate::theme::Tokens::get().app_bar;
|
||||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
@@ -129,7 +129,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
|
||||
if !edges {
|
||||
return;
|
||||
}
|
||||
// White edge lines.
|
||||
// The seams between the faces and around the mark, in the bar's colour, so they read as gaps.
|
||||
let edge = Stroke::new(10.0 * s, edge_col);
|
||||
let line = |pts: &[(f32, f32)]| {
|
||||
p.add(egui::Shape::line(m(pts), edge));
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Zadejte název příkazu, prvek v tomto návrhu nebo parametr. Chcete-li spustit příkaz s hodnotami, zadejte jeho id a objekt JSON: {example}",
|
||||
"search.valid": "Platné",
|
||||
"settings.display_quality_note": "Jemnější úrovně kreslí zakřivené plochy a hrany hladčeji, ale síťování trvá déle. Mění se jen zobrazení, nikdy návrh ani exporty.",
|
||||
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu. Dokud je zapnuto, pohled se překresluje nepřetržitě.",
|
||||
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu.",
|
||||
"settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.",
|
||||
"settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "K jedné zprávě lze připojit nejvýše {max} souborů; vynecháno: {files}",
|
||||
"ui.agent_chat_files_loading": "Čekání na přečtení souborů",
|
||||
"ui.agent_chat_files_max": "K jedné zprávě nejvýše {max} souborů",
|
||||
"ui.agent_chat_flatpak_none_found": "Nebyl nalezen žádný kódovací agent. Sandbox Flatpaku ve výchozím stavu nevidí agenty nainstalované ve vaší domovské složce a nemá přístup k síti. Chcete-li povolit Claude Code a jeho adaptér ACP nainstalované přes nvm nebo do ~/.local/bin, spusťte v terminálu tento příkaz a poté SolidCraft restartujte. Udělí přístup k uvedeným programům a přihlašovacím souborům a také přístup k síti:",
|
||||
"ui.agent_chat_import_failed": "{name}: import selhal: {error}",
|
||||
"ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů SolidCraftu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.",
|
||||
"ui.agent_chat_key_in_env": "klíč nastaven",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "Boční pohledy zobrazí mřížku na svislé rovině, která je k nim přivrácená, místo na zemi",
|
||||
"ui.ground_shadow": "Stín na podložce",
|
||||
"ui.groupSelected": "Seskupit vybrané",
|
||||
"ui.hatch": "Šrafování",
|
||||
"ui.heading_add_ins": "DOPLŇKY",
|
||||
"ui.heading_assemble": "SESTAVA",
|
||||
"ui.heading_box": "KVÁDR",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Skriv et kommandonavn, en feature i dette design eller en parameter. Du kører en kommando med værdier ved at skrive dens id og et JSON-objekt: {example}",
|
||||
"search.valid": "Gyldig",
|
||||
"settings.display_quality_note": "Finere niveauer viser krumme flader og kanter mere jævnt og tager længere tid at opbygge mesh til. Kun modelvisningen ændres, aldrig designet eller eksporten.",
|
||||
"settings.frame_rate_note": "Viser billeder pr. sekund nederst til højre i modelvisningen. Når dette er slået til, gentegnes visningen løbende.",
|
||||
"settings.frame_rate_note": "Viser billeder pr. sekund nederst til højre i modelvisningen.",
|
||||
"settings.measure_axis_distances": "Vis X/Y/Z-afstande",
|
||||
"settings.measure_axis_distances_help": "Vis afstanden langs X-, Y- og Z-aksen sammen med den samlede afstand i Mål.",
|
||||
"settings.msaa_applies_on_restart": "Kantudjævning træder i kraft, næste gang SolidCraft starter",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "Højst {max} filer pr. besked; udeladt: {files}",
|
||||
"ui.agent_chat_files_loading": "Venter på indlæsning af filerne",
|
||||
"ui.agent_chat_files_max": "Højst {max} filer i én besked",
|
||||
"ui.agent_chat_flatpak_none_found": "Ingen kodeagent fundet. Flatpak-sandkassen kan som standard ikke se agenter, der er installeret i din hjemmemappe, og har ingen netværksadgang. For at tillade Claude Code og dets ACP-adapter installeret via nvm eller i ~/.local/bin skal du køre denne kommando i en terminal og derefter genstarte SolidCraft. Den giver adgang til de nævnte programmer og loginfiler samt netværksadgang:",
|
||||
"ui.agent_chat_import_failed": "{name}: Importen mislykkedes: {error}",
|
||||
"ui.agent_chat_intro": "Chat med en kodeagent, du allerede bruger, eller en modeltjeneste, du vælger. Den arbejder på det åbne design gennem SolidCrafts værktøjer: Ændringer vises undervejs og fortrydes som ét trin pr. besked. Hvad den må gøre uden at spørge, angives i Indstillinger > AI.",
|
||||
"ui.agent_chat_key_in_env": "nøgle angivet",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "Sidevisninger viser gitteret på det lodrette plan, der vender mod dem, i stedet for grundplanet",
|
||||
"ui.ground_shadow": "Skygge på grundplan",
|
||||
"ui.groupSelected": "Gruppér valgte",
|
||||
"ui.hatch": "Skravering",
|
||||
"ui.heading_add_ins": "ADD-INS",
|
||||
"ui.heading_assemble": "SAMLING",
|
||||
"ui.heading_box": "BOKS",
|
||||
|
||||
@@ -562,8 +562,10 @@
|
||||
"cmd.component_activate": "Komponente aktivieren",
|
||||
"cmd.component_break_link": "Verknüpfung trennen",
|
||||
"cmd.component_capture_position": "Position erfassen",
|
||||
"cmd.component_copies": "Identische Kopien finden",
|
||||
"cmd.component_create": "Neue Komponente",
|
||||
"cmd.component_delete": "Komponente löschen",
|
||||
"cmd.component_extract": "In Datei extrahieren",
|
||||
"cmd.component_from_bodies": "Komponenten aus Körpern erstellen",
|
||||
"cmd.component_insert_design": "Konstruktion einfügen",
|
||||
"cmd.component_links": "Verknüpfungen auflisten",
|
||||
@@ -576,6 +578,7 @@
|
||||
"cmd.component_properties": "Physikalische Eigenschaften der Komponente",
|
||||
"cmd.component_relink": "Quelle neu verknüpfen",
|
||||
"cmd.component_rename": "Komponente umbenennen",
|
||||
"cmd.component_replace_with_link": "Durch Verknüpfung ersetzen",
|
||||
"cmd.component_revert_position": "Position zurücksetzen",
|
||||
"cmd.component_unlock": "Inhalt entsperren",
|
||||
"cmd.component_update_links": "Verknüpfungen aktualisieren",
|
||||
@@ -1178,7 +1181,7 @@
|
||||
"search.try": "Geben Sie einen Befehlsnamen, ein Merkmal in diesem Design oder einen Parameter ein. Um einen Befehl mit Werten auszuführen, geben Sie seine ID und ein JSON-Objekt ein: {example}",
|
||||
"search.valid": "Gültig",
|
||||
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
|
||||
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht. Solange sie an ist, wird die Ansicht fortlaufend neu gezeichnet.",
|
||||
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht.",
|
||||
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
|
||||
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
|
||||
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
|
||||
@@ -1301,6 +1304,7 @@
|
||||
"ui.agent_chat_files_left_out": "Höchstens {max} Dateien pro Nachricht; ausgelassen: {files}",
|
||||
"ui.agent_chat_files_loading": "Warten, bis die Dateien gelesen sind",
|
||||
"ui.agent_chat_files_max": "Höchstens {max} Dateien pro Nachricht",
|
||||
"ui.agent_chat_flatpak_none_found": "Kein Coding-Agent gefunden. Die Flatpak-Sandbox sieht standardmäßig keine Agenten in Ihrem Benutzerordner und hat keinen Netzwerkzugriff. Um Claude Code und seinen ACP-Adapter zu erlauben, die über nvm oder in ~/.local/bin installiert sind, führen Sie diesen Befehl in einem Terminal aus und starten Sie SolidCraft dann neu. Er gewährt Zugriff auf die genannten Programme und Anmeldedateien sowie Netzwerkzugriff:",
|
||||
"ui.agent_chat_import_failed": "{name}: Der Import ist fehlgeschlagen: {error}",
|
||||
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
|
||||
"ui.agent_chat_key_in_env": "Schlüssel gesetzt",
|
||||
@@ -1589,6 +1593,9 @@
|
||||
"ui.export_keymap_tip": "Tasten, S-Box-Favoriten und die Symbolleiste als Datei",
|
||||
"ui.extend": "Verlängern",
|
||||
"ui.extent_type": "Ausdehnungstyp",
|
||||
"ui.extract_component": "In Datei extrahieren…",
|
||||
"ui.extract_design_changed": "Das Design hat sich beim Wählen einer Datei geändert. Starten Sie In Datei extrahieren erneut.",
|
||||
"ui.extract_done": "Die Komponente ist jetzt ein eigenes Design und hier verknüpft.",
|
||||
"ui.extrude": "Extrudieren",
|
||||
"ui.factor": "Faktor",
|
||||
"ui.feature_history": "Feature-Verlauf",
|
||||
@@ -1620,6 +1627,7 @@
|
||||
"ui.grid_follows_view_hint": "Seitenansichten zeigen das Raster auf der aufrechten Ebene, die ihnen zugewandt ist, statt auf dem Boden",
|
||||
"ui.ground_shadow": "Bodenschatten",
|
||||
"ui.groupSelected": "Auswahl gruppieren",
|
||||
"ui.hatch": "Schraffur",
|
||||
"ui.heading_add_ins": "ADD-INS",
|
||||
"ui.heading_assemble": "ZUSAMMENBAU",
|
||||
"ui.heading_box": "BLOCK",
|
||||
@@ -1720,6 +1728,10 @@
|
||||
"ui.length_decimals": "Längen-Dezimalstellen",
|
||||
"ui.light": "Hell",
|
||||
"ui.line": "Linie",
|
||||
"ui.link_copies": "Identische Kopien verknüpfen",
|
||||
"ui.link_copies_keep": "Behalten",
|
||||
"ui.link_copies_link": "Verknüpfen",
|
||||
"ui.link_copies_text": "Diese Komponenten erzeugen dieselben Körper wie {name}. Sollen sie zu Platzierungen seiner Verknüpfung werden?",
|
||||
"ui.lock": "Sperren",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Ansehen",
|
||||
@@ -1843,6 +1855,7 @@
|
||||
"ui.render": "Rendern",
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Wiederholen",
|
||||
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
|
||||
"ui.report_a_bug": "Fehler melden",
|
||||
"ui.reset": "Zurücksetzen",
|
||||
"ui.reset_all": "Alle zurücksetzen",
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"Export JSON…": "Export JSON…",
|
||||
"Export design library...": "Export design library...",
|
||||
"Export hidden edges as dashed lines": "Export hidden edges as dashed lines",
|
||||
"Exported by SolidWorks, which usually saves Y up": "Exported by SolidWorks, which usually saves Y up",
|
||||
"Expression": "Expression",
|
||||
"Extend to meet": "Extend to meet",
|
||||
"Extents": "Extents",
|
||||
@@ -609,8 +610,10 @@
|
||||
"cmd.component_activate": "Activate Component",
|
||||
"cmd.component_break_link": "Break Link",
|
||||
"cmd.component_capture_position": "Capture Position",
|
||||
"cmd.component_copies": "Find Identical Copies",
|
||||
"cmd.component_create": "New Component",
|
||||
"cmd.component_delete": "Delete Component",
|
||||
"cmd.component_extract": "Extract to File",
|
||||
"cmd.component_from_bodies": "Create Components from Bodies",
|
||||
"cmd.component_insert_design": "Insert Design",
|
||||
"cmd.component_links": "List Links",
|
||||
@@ -623,6 +626,7 @@
|
||||
"cmd.component_properties": "Component Physical Properties",
|
||||
"cmd.component_relink": "Relink Source",
|
||||
"cmd.component_rename": "Rename Component",
|
||||
"cmd.component_replace_with_link": "Replace with Link",
|
||||
"cmd.component_revert_position": "Revert Position",
|
||||
"cmd.component_unlock": "Unlock Contents",
|
||||
"cmd.component_update_links": "Update Links",
|
||||
@@ -740,6 +744,7 @@
|
||||
"cmd.motion_plot": "Plot Motion Study",
|
||||
"cmd.motion_study": "Motion Study",
|
||||
"cmd.motion_trace": "Motion Trace",
|
||||
"cmd.motion_trace_edit": "Edit Motion Trace",
|
||||
"cmd.motion_trace_keep": "Keep Motion Trace",
|
||||
"cmd.occurrence_copy": "Paste (Instance)",
|
||||
"cmd.occurrence_ground": "Ground",
|
||||
@@ -1353,7 +1358,7 @@
|
||||
"search.try": "Type a command name, a feature in this design, or a parameter. To run a command with values, type its id and a JSON object: {example}",
|
||||
"search.valid": "Valid",
|
||||
"settings.display_quality_note": "Only the display. Designs and exports are not affected.",
|
||||
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner. While it is on, the view redraws continuously.",
|
||||
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.",
|
||||
"settings.measure_axis_distances": "Show X/Y/Z distances",
|
||||
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
|
||||
@@ -1362,6 +1367,7 @@
|
||||
"settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:",
|
||||
"settings.reset_title": "Settings were changed",
|
||||
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
|
||||
"sheet.no_body": "there is no sheet metal body: start one with Flange > Base, or turn a plate into sheet metal with Convert to Sheet Metal (pick its large flat face)",
|
||||
"shortcut.json_error": "parameters must be JSON: {error}",
|
||||
"shortcut.no_command": "no command `{id}`; the text before the JSON must be a command id",
|
||||
"shortcut.pin": "Pin to the top of the S box",
|
||||
@@ -1398,6 +1404,9 @@
|
||||
"thread.designation": "Designation",
|
||||
"thread.iso_metric": "ISO metric",
|
||||
"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_many": "Delete {count} Selected Features",
|
||||
"timeline.group": "New Feature Folder",
|
||||
"timeline.move_many": "Move Timeline Items",
|
||||
"timeline.move_to_group": "Move Features to Folder",
|
||||
@@ -1476,6 +1485,7 @@
|
||||
"ui.agent_chat_files_left_out": "At most {max} files go with one message; left out: {files}",
|
||||
"ui.agent_chat_files_loading": "Waiting for the files to be read",
|
||||
"ui.agent_chat_files_max": "At most {max} files on one message",
|
||||
"ui.agent_chat_flatpak_none_found": "No coding agent found. The Flatpak sandbox cannot see agents installed in your home folder and has no network access by default. To allow Claude Code and its ACP adapter installed through nvm or ~/.local/bin, run this command in a terminal, then restart SolidCraft. It grants access to the listed program and sign-in files, plus network access:",
|
||||
"ui.agent_chat_import_failed": "{name}: the import failed: {error}",
|
||||
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
|
||||
"ui.agent_chat_key_in_env": "key set",
|
||||
@@ -1548,8 +1558,10 @@
|
||||
"ui.ai_add": "Add",
|
||||
"ui.ai_add_agent": "Add agent…",
|
||||
"ui.ai_add_agent_title": "Add agent",
|
||||
"ui.ai_add_anthropic": "Anthropic-compatible API",
|
||||
"ui.ai_add_api": "OpenAI-compatible API",
|
||||
"ui.ai_add_custom": "Custom command",
|
||||
"ui.ai_add_endpoint": "API endpoint",
|
||||
"ui.ai_add_installed": "Installed agent",
|
||||
"ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.",
|
||||
"ui.ai_added": "added",
|
||||
@@ -1578,6 +1590,7 @@
|
||||
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
|
||||
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
|
||||
"ui.ai_keychain_error": "The system keychain refused: {error}",
|
||||
"ui.ai_kind": "Kind",
|
||||
"ui.ai_mode_all": "Allow everything",
|
||||
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
|
||||
"ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.",
|
||||
@@ -1599,6 +1612,7 @@
|
||||
"ui.ai_panel": "Panel",
|
||||
"ui.ai_permissions": "Permissions",
|
||||
"ui.ai_preset": "Preset",
|
||||
"ui.ai_protocol": "Protocol",
|
||||
"ui.ai_remove": "Remove",
|
||||
"ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on",
|
||||
"ui.ai_reset": "Reset",
|
||||
@@ -1609,6 +1623,8 @@
|
||||
"ui.ai_test": "Test",
|
||||
"ui.ai_test_acp_ok": "Answers: {agent}",
|
||||
"ui.ai_test_acp_tip": "Starts the agent briefly and asks it for a session; ends it again",
|
||||
"ui.ai_test_answered": "Reached: {model} answered",
|
||||
"ui.ai_test_anthropic_tip": "Sends a short message to the endpoint",
|
||||
"ui.ai_test_api_tip": "Asks the endpoint for its models",
|
||||
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
|
||||
"ui.ai_test_ok": "Reached: {count} models",
|
||||
@@ -1766,6 +1782,9 @@
|
||||
"ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file",
|
||||
"ui.extend": "Extend",
|
||||
"ui.extent_type": "Extent Type",
|
||||
"ui.extract_component": "Extract to File…",
|
||||
"ui.extract_design_changed": "The design changed while choosing a file. Start Extract to File again.",
|
||||
"ui.extract_done": "The component is now its own design, linked here.",
|
||||
"ui.extrude": "Extrude",
|
||||
"ui.factor": "Factor",
|
||||
"ui.feature_history": "Feature history",
|
||||
@@ -1797,6 +1816,7 @@
|
||||
"ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground",
|
||||
"ui.ground_shadow": "Ground shadow",
|
||||
"ui.groupSelected": "Group Selected",
|
||||
"ui.hatch": "Hatch",
|
||||
"ui.heading_add_ins": "ADD-INS",
|
||||
"ui.heading_assemble": "ASSEMBLE",
|
||||
"ui.heading_box": "BOX",
|
||||
@@ -1904,6 +1924,10 @@
|
||||
"ui.length_decimals": "Length decimals",
|
||||
"ui.light": "Light",
|
||||
"ui.line": "Line",
|
||||
"ui.link_copies": "Link Identical Copies",
|
||||
"ui.link_copies_keep": "Keep",
|
||||
"ui.link_copies_link": "Link",
|
||||
"ui.link_copies_text": "These components build the same bodies as {name}. Make them placements of its link?",
|
||||
"ui.lock": "Lock",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Look At",
|
||||
@@ -2027,6 +2051,7 @@
|
||||
"ui.render": "Render",
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Repeat",
|
||||
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
|
||||
"ui.report_a_bug": "Report a bug",
|
||||
"ui.reset": "Reset",
|
||||
"ui.reset_all": "Reset All",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Escribe el nombre de un comando, una operación de este diseño o un parámetro. Para ejecutar un comando con valores, escribe su id y un objeto JSON: {example}",
|
||||
"search.valid": "Válido",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
|
||||
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
|
||||
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista.",
|
||||
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
|
||||
"settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "Se pueden enviar como máximo {max} archivos con un mensaje; omitidos: {files}",
|
||||
"ui.agent_chat_files_loading": "Esperando a que se lean los archivos",
|
||||
"ui.agent_chat_files_max": "Como máximo {max} archivos por mensaje",
|
||||
"ui.agent_chat_flatpak_none_found": "No se ha encontrado ningún agente de programación. De forma predeterminada, el entorno aislado de Flatpak no ve los agentes instalados en tu carpeta personal y no tiene acceso a la red. Para permitir Claude Code y su adaptador ACP instalados con nvm o en ~/.local/bin, ejecuta este comando en un terminal y reinicia SolidCraft. Concede acceso a los programas y archivos de inicio de sesión indicados, además de acceso a la red:",
|
||||
"ui.agent_chat_import_failed": "{name}: la importación falló: {error}",
|
||||
"ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de SolidCraft: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.",
|
||||
"ui.agent_chat_key_in_env": "clave configurada",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "Las vistas laterales muestran la cuadrícula en el plano vertical que tienen enfrente, en lugar del suelo",
|
||||
"ui.ground_shadow": "Sombra en el suelo",
|
||||
"ui.groupSelected": "Agrupar selección",
|
||||
"ui.hatch": "Rayado",
|
||||
"ui.heading_add_ins": "COMPLEMENTOS",
|
||||
"ui.heading_assemble": "ENSAMBLAR",
|
||||
"ui.heading_box": "CAJA",
|
||||
|
||||
@@ -1044,13 +1044,13 @@
|
||||
"sample.bracket.name": "Équerre en tôle",
|
||||
"sample.enclosure.about": "Boîte en coque avec lèvre, bossages de vis, appui et clip",
|
||||
"sample.enclosure.name": "Boîtier plastique",
|
||||
"sample.flange.about": "Moyeu et bride avec un réseau circulaire piloté par un paramètre",
|
||||
"sample.flange.about": "Moyeu et bride avec une répétition circulaire pilotée par un paramètre",
|
||||
"sample.flange.name": "Bride boulonnée",
|
||||
"sample.hinge.about": "Deux composants et une liaison pivot, entraînée à 60°",
|
||||
"sample.hinge.name": "Assemblage de charnière",
|
||||
"sample.plate.about": "Plaque paramétrique avec congés, perçages et un bossage alésé",
|
||||
"sample.plate.name": "Plaque d'exemple",
|
||||
"sample.vents.about": "Rainures en réseau rectangulaire : lignes, colonnes et pas sont des paramètres",
|
||||
"sample.vents.about": "Rainures en répétition rectangulaire : lignes, colonnes et pas sont des paramètres",
|
||||
"sample.vents.name": "Plaque d'aération",
|
||||
"search.command_keys": "Entrée exécuter Maj+Entrée exécuter et garder ouvert Échap fermer",
|
||||
"search.commands": "Commandes",
|
||||
@@ -1066,7 +1066,7 @@
|
||||
"search.try": "Saisissez un nom de commande, une entité de cette conception ou un paramètre. Pour exécuter une commande avec des valeurs, saisissez son id et un objet JSON : {example}",
|
||||
"search.valid": "Valide",
|
||||
"settings.display_quality_note": "Les niveaux plus fins affichent plus lisses les faces et arêtes courbes, mais leur maillage prend plus de temps. Seule la vue change, jamais la conception ni les exports.",
|
||||
"settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue. Tant qu'elle est activée, la vue est redessinée en continu.",
|
||||
"settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue.",
|
||||
"settings.measure_axis_distances": "Afficher les cotes X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.",
|
||||
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft",
|
||||
@@ -1189,6 +1189,7 @@
|
||||
"ui.agent_chat_files_left_out": "Un message ne peut contenir que {max} fichiers au plus ; non joints : {files}",
|
||||
"ui.agent_chat_files_loading": "Lecture des fichiers en cours",
|
||||
"ui.agent_chat_files_max": "{max} fichiers au plus par message",
|
||||
"ui.agent_chat_flatpak_none_found": "Aucun agent de programmation trouvé. Par défaut, le bac à sable Flatpak ne voit pas les agents installés dans votre dossier personnel et n’a pas accès au réseau. Pour autoriser Claude Code et son adaptateur ACP installés avec nvm ou dans ~/.local/bin, exécuter cette commande dans un terminal, puis redémarrer SolidCraft. Elle donne accès aux programmes et fichiers de connexion indiqués, ainsi qu’au réseau :",
|
||||
"ui.agent_chat_import_failed": "{name} : l'importation a échoué : {error}",
|
||||
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
|
||||
"ui.agent_chat_key_in_env": "clé définie",
|
||||
@@ -1373,7 +1374,7 @@
|
||||
"ui.chamfer_type": "Type de chanfrein",
|
||||
"ui.change_workspace": "Changer d'espace de travail",
|
||||
"ui.circle": "Cercle",
|
||||
"ui.circular_pattern": "Réseau circulaire",
|
||||
"ui.circular_pattern": "Répétition circulaire",
|
||||
"ui.clear": "Effacer",
|
||||
"ui.clear_recent": "Effacer les récents",
|
||||
"ui.clear_the_selection": "Effacer la sélection",
|
||||
@@ -1508,11 +1509,12 @@
|
||||
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
|
||||
"ui.ground_shadow": "Ombre au sol",
|
||||
"ui.groupSelected": "Grouper la sélection",
|
||||
"ui.hatch": "Hachures",
|
||||
"ui.heading_add_ins": "COMPLÉMENTS",
|
||||
"ui.heading_assemble": "ASSEMBLER",
|
||||
"ui.heading_box": "BOÎTE",
|
||||
"ui.heading_chamfer": "CHANFREIN",
|
||||
"ui.heading_circular_pattern": "RÉSEAU CIRCULAIRE",
|
||||
"ui.heading_circular_pattern": "RÉPÉTITION CIRCULAIRE",
|
||||
"ui.heading_coil": "HÉLICE",
|
||||
"ui.heading_combine": "COMBINER",
|
||||
"ui.heading_configure": "CONFIGURER",
|
||||
@@ -1545,14 +1547,14 @@
|
||||
"ui.heading_offset_plane": "PLAN DÉCALÉ",
|
||||
"ui.heading_ok": "OK",
|
||||
"ui.heading_parameters": "PARAMÈTRES",
|
||||
"ui.heading_pattern": "RÉSEAU",
|
||||
"ui.heading_pattern_on_path": "RÉSEAU SUR TRAJECTOIRE",
|
||||
"ui.heading_pattern": "RÉPÉTITION",
|
||||
"ui.heading_pattern_on_path": "RÉPÉTITION SUR TRAJECTOIRE",
|
||||
"ui.heading_pipe": "TUYAU",
|
||||
"ui.heading_plane_at_angle": "PLAN INCLINÉ",
|
||||
"ui.heading_plastic": "PLASTIQUE",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "PRÉPARER",
|
||||
"ui.heading_rectangular_pattern": "RÉSEAU RECTANGULAIRE",
|
||||
"ui.heading_rectangular_pattern": "RÉPÉTITION RECTANGULAIRE",
|
||||
"ui.heading_revolve": "RÉVOLUTION",
|
||||
"ui.heading_right": "DROITE",
|
||||
"ui.heading_save_mesh": "ENREGISTRER COMME MAILLAGE",
|
||||
@@ -1682,7 +1684,7 @@
|
||||
"ui.part_size_shared": "Reconstruit la pièce en remplaçant les entités ajoutées. Toutes les occurrences de ce composant sont mises à jour.",
|
||||
"ui.paste": "Coller",
|
||||
"ui.path": "Trajectoire",
|
||||
"ui.pattern_on_path": "Réseau sur trajectoire",
|
||||
"ui.pattern_on_path": "Répétition sur trajectoire",
|
||||
"ui.perpendicular": "Perpendicularité",
|
||||
"ui.perspective_view": "Vue en perspective",
|
||||
"ui.pinToShortcuts": "Épingler aux raccourcis",
|
||||
@@ -1719,7 +1721,7 @@
|
||||
"ui.rail_fit": "Ajustement au rail",
|
||||
"ui.reachable": "Accessible",
|
||||
"ui.rectangle": "Rectangle",
|
||||
"ui.rectangular_pattern": "Réseau rectangulaire",
|
||||
"ui.rectangular_pattern": "Répétition rectangulaire",
|
||||
"ui.redo": "Rétablir",
|
||||
"ui.relink_design_changed": "L’assemblage a changé pendant le choix d’une source. Relancez Relier à nouveau la source.",
|
||||
"ui.relink_done": "Source reliée à nouveau pour toutes les instances.",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Escribe o nome dunha orde, unha operación deste deseño ou un parámetro. Para executar unha orde con valores, escribe o seu id e un obxecto JSON: {example}",
|
||||
"search.valid": "Válido",
|
||||
"settings.display_quality_note": "Os niveis máis finos debuxan as caras e arestas curvas máis suaves e tardan máis en xerar a malla. Só cambia a vista, nunca o deseño nin as exportacións.",
|
||||
"settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista. Mentres está activado, a vista redebúxase continuamente.",
|
||||
"settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista.",
|
||||
"settings.measure_axis_distances": "Mostrar as distancias en X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostra as compoñentes da distancia segundo os eixes do mundo xunto á distancia total mentres Medir está aberto.",
|
||||
"settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie SolidCraft",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "Cunha mensaxe van como máximo {max} ficheiros; deixados fóra: {files}",
|
||||
"ui.agent_chat_files_loading": "Agardando a que se lean os ficheiros",
|
||||
"ui.agent_chat_files_max": "Como máximo {max} ficheiros por mensaxe",
|
||||
"ui.agent_chat_flatpak_none_found": "Non se atopou ningún axente de programación. De forma predeterminada, o illamento de Flatpak non ve os axentes instalados no teu cartafol persoal e non ten acceso á rede. Para permitir Claude Code e o seu adaptador ACP instalados con nvm ou en ~/.local/bin, executa esta orde nun terminal e despois reinicia SolidCraft. Concede acceso aos programas e ficheiros de inicio de sesión indicados, ademais de acceso á rede:",
|
||||
"ui.agent_chat_import_failed": "{name}: fallou a importación: {error}",
|
||||
"ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de SolidCraft: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.",
|
||||
"ui.agent_chat_key_in_env": "clave configurada",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "As vistas laterais mostran a grella no plano vertical que ten diante en vez do chan",
|
||||
"ui.ground_shadow": "Sombra no chan",
|
||||
"ui.groupSelected": "Agrupar a selección",
|
||||
"ui.hatch": "Raiado",
|
||||
"ui.heading_add_ins": "COMPLEMENTOS",
|
||||
"ui.heading_assemble": "ENSAMBLAR",
|
||||
"ui.heading_box": "CAIXA",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Írja be egy parancs nevét, a terv egy elemét vagy egy paramétert. Parancs értékekkel való futtatásához írja be az azonosítóját és egy JSON-objektumot: {example}",
|
||||
"search.valid": "Érvényes",
|
||||
"settings.display_quality_note": "A finomabb szintek simábban rajzolják az ívelt lapokat és éleket, de tovább tart a hálózás. Csak a nézetablak változik, a terv és az exportok soha.",
|
||||
"settings.frame_rate_note": "Megjeleníti a másodpercenkénti képkockaszámot a nézet jobb alsó sarkában. Bekapcsolt állapotban a nézet folyamatosan újrarajzolódik.",
|
||||
"settings.frame_rate_note": "Megjeleníti a másodpercenkénti képkockaszámot a nézet jobb alsó sarkában.",
|
||||
"settings.measure_axis_distances": "X/Y/Z távolságok megjelenítése",
|
||||
"settings.measure_axis_distances_help": "A világtengely-összetevők méreteit is megjeleníti a teljes távolság mellett, amíg a Mérés nyitva van.",
|
||||
"settings.msaa_applies_on_restart": "Az élsimítás a SolidCraft következő indításakor lép érvénybe",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "Egy üzenethez legfeljebb {max} fájl tartozhat; kihagyva: {files}",
|
||||
"ui.agent_chat_files_loading": "Várakozás a fájlok beolvasására",
|
||||
"ui.agent_chat_files_max": "Egy üzenetben legfeljebb {max} fájl lehet",
|
||||
"ui.agent_chat_flatpak_none_found": "Nem található kódolóügynök. A Flatpak homokozó alapértelmezés szerint nem látja a saját mappájába telepített ügynököket, és nincs hálózati hozzáférése. Az nvm-mel vagy a ~/.local/bin mappába telepített Claude Code és ACP-adaptere engedélyezéséhez futtassa ezt a parancsot egy terminálban, majd indítsa újra a SolidCraftot. Hozzáférést ad a felsorolt programokhoz és bejelentkezési fájlokhoz, valamint a hálózathoz:",
|
||||
"ui.agent_chat_import_failed": "{name}: az importálás sikertelen: {error}",
|
||||
"ui.agent_chat_intro": "Csevegjen egy már használt kódolóügynökkel vagy egy választott modellvégponttal. A megnyitott terven a SolidCraft eszközein keresztül dolgozik: a módosításai láthatók közben, és üzenetenként egy lépésben vonhatók vissza. Hogy mit tehet kérdés nélkül, azt a Beállítások > AI menüpontban adhatja meg.",
|
||||
"ui.agent_chat_key_in_env": "kulcs beállítva",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "Az oldalnézetek a talaj helyett a feléjük néző függőleges síkon mutatják a rácsot",
|
||||
"ui.ground_shadow": "Talajárnyék",
|
||||
"ui.groupSelected": "Kijelöltek csoportosítása",
|
||||
"ui.hatch": "Sraffozás",
|
||||
"ui.heading_add_ins": "BŐVÍTMÉNYEK",
|
||||
"ui.heading_assemble": "ÖSSZESZERELÉS",
|
||||
"ui.heading_box": "DOBOZ",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Ketik nama perintah, fitur di desain ini, atau parameter. Untuk menjalankan perintah dengan nilai, ketik id-nya dan objek JSON: {example}",
|
||||
"search.valid": "Valid",
|
||||
"settings.display_quality_note": "Level lebih halus menggambar muka dan tepi lengkung lebih mulus dan butuh waktu meshing lebih lama. Hanya viewport yang berubah, tidak pernah desain atau ekspor.",
|
||||
"settings.frame_rate_note": "Menampilkan frame per detik di sudut kanan bawah tampilan. Saat aktif, tampilan digambar ulang terus-menerus.",
|
||||
"settings.frame_rate_note": "Menampilkan frame per detik di sudut kanan bawah tampilan.",
|
||||
"settings.measure_axis_distances": "Tampilkan jarak X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Tampilkan dimensi komponen sumbu dunia di samping jarak total saat Ukur terbuka.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing berlaku saat SolidCraft dimulai berikutnya",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "Maksimal {max} file per pesan; tidak disertakan: {files}",
|
||||
"ui.agent_chat_files_loading": "Menunggu file selesai dibaca",
|
||||
"ui.agent_chat_files_max": "Maksimal {max} file per pesan",
|
||||
"ui.agent_chat_flatpak_none_found": "Agen coding tidak ditemukan. Secara bawaan, sandbox Flatpak tidak dapat melihat agen yang terpasang di folder rumah Anda dan tidak memiliki akses jaringan. Untuk mengizinkan Claude Code dan adaptor ACP-nya yang dipasang melalui nvm atau di ~/.local/bin, jalankan perintah ini di terminal, lalu mulai ulang SolidCraft. Perintah ini memberi akses ke program dan berkas masuk yang disebutkan, serta akses jaringan:",
|
||||
"ui.agent_chat_import_failed": "{name}: impor gagal: {error}",
|
||||
"ui.agent_chat_intro": "Mengobrol dengan agen coding yang sudah Anda pakai, atau endpoint model pilihan Anda. Agen bekerja pada desain yang terbuka lewat alat SolidCraft: perubahannya tampil saat dibuat dan dapat diurungkan satu langkah per pesan. Yang boleh dilakukannya tanpa bertanya diatur di Preferensi > AI.",
|
||||
"ui.agent_chat_key_in_env": "key diatur",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "Tampilan samping menampilkan grid pada bidang tegak yang menghadapnya, bukan pada dasar",
|
||||
"ui.ground_shadow": "Bayangan dasar",
|
||||
"ui.groupSelected": "Kelompokkan yang Dipilih",
|
||||
"ui.hatch": "Arsir",
|
||||
"ui.heading_add_ins": "ADD-IN",
|
||||
"ui.heading_assemble": "RAKIT",
|
||||
"ui.heading_box": "KOTAK",
|
||||
|
||||
@@ -1063,7 +1063,7 @@
|
||||
"search.try": "Digita il nome di un comando, una feature di questo progetto o un parametro. Per eseguire un comando con dei valori, digita il suo id e un oggetto JSON: {example}",
|
||||
"search.valid": "Valido",
|
||||
"settings.display_quality_note": "I livelli più fini rendono più uniformi le facce curve e gli spigoli e richiedono più tempo per la mesh. Cambia solo la vista, mai il progetto o le esportazioni.",
|
||||
"settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista. Quando è attivo, la vista viene ridisegnata continuamente.",
|
||||
"settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista.",
|
||||
"settings.measure_axis_distances": "Mostra distanze X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostra le componenti lungo gli assi globali accanto alla distanza totale quando Misura è aperto.",
|
||||
"settings.msaa_applies_on_restart": "L'antialiasing verrà applicato al prossimo avvio di SolidCraft",
|
||||
@@ -1186,6 +1186,7 @@
|
||||
"ui.agent_chat_files_left_out": "Un messaggio può contenere al massimo {max} file; esclusi: {files}",
|
||||
"ui.agent_chat_files_loading": "In attesa della lettura dei file",
|
||||
"ui.agent_chat_files_max": "Al massimo {max} file per messaggio",
|
||||
"ui.agent_chat_flatpak_none_found": "Nessun agente di programmazione trovato. Per impostazione predefinita, la sandbox Flatpak non vede gli agenti installati nella tua cartella home e non ha accesso alla rete. Per consentire Claude Code e il suo adattatore ACP installati con nvm o in ~/.local/bin, esegui questo comando in un terminale, poi riavvia SolidCraft. Concede l’accesso ai programmi e ai file di accesso elencati, oltre all’accesso alla rete:",
|
||||
"ui.agent_chat_import_failed": "{name}: importazione non riuscita: {error}",
|
||||
"ui.agent_chat_intro": "Conversa con un agente di programmazione che già usi o con un endpoint di modello a tua scelta. Opera sul progetto aperto tramite gli strumenti di SolidCraft: le modifiche sono visibili mentre vengono eseguite e si annullano in un solo passo per messaggio. Le azioni consentite senza conferma si impostano in Preferenze > IA.",
|
||||
"ui.agent_chat_key_in_env": "chiave impostata",
|
||||
@@ -1505,6 +1506,7 @@
|
||||
"ui.grid_follows_view_hint": "Le viste laterali mostrano la griglia sul piano verticale rivolto verso di esse anziché sul piano di appoggio",
|
||||
"ui.ground_shadow": "Ombra sul piano di appoggio",
|
||||
"ui.groupSelected": "Raggruppa selezionati",
|
||||
"ui.hatch": "Tratteggio",
|
||||
"ui.heading_add_ins": "COMPONENTI AGGIUNTIVI",
|
||||
"ui.heading_assemble": "ASSEMBLA",
|
||||
"ui.heading_box": "PARALLELEPIPEDO",
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"Export JSON…": "JSONを書き出し…",
|
||||
"Export design library...": "デザインライブラリを書き出す...",
|
||||
"Export hidden edges as dashed lines": "点線で出力",
|
||||
"Exported by SolidWorks, which usually saves Y up": "SolidWorks で書き出されたファイルです。通常は Y 軸が上になります",
|
||||
"Expression": "式",
|
||||
"Extend to meet": "交わるまで延長",
|
||||
"Extents": "範囲",
|
||||
@@ -609,8 +610,10 @@
|
||||
"cmd.component_activate": "コンポーネントをアクティブ化",
|
||||
"cmd.component_break_link": "リンクを解除",
|
||||
"cmd.component_capture_position": "位置をキャプチャ",
|
||||
"cmd.component_copies": "同一のコピーを検索",
|
||||
"cmd.component_create": "新規コンポーネント",
|
||||
"cmd.component_delete": "コンポーネントを削除",
|
||||
"cmd.component_extract": "ファイルに抽出",
|
||||
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
|
||||
"cmd.component_insert_design": "デザインを挿入",
|
||||
"cmd.component_links": "リンクを一覧表示",
|
||||
@@ -623,6 +626,7 @@
|
||||
"cmd.component_properties": "コンポーネントの物理的プロパティ",
|
||||
"cmd.component_relink": "参照元を再リンク",
|
||||
"cmd.component_rename": "コンポーネントの名前を変更",
|
||||
"cmd.component_replace_with_link": "リンクに置き換え",
|
||||
"cmd.component_revert_position": "位置を元に戻す",
|
||||
"cmd.component_unlock": "内容のロックを解除",
|
||||
"cmd.component_update_links": "リンクを更新",
|
||||
@@ -740,6 +744,7 @@
|
||||
"cmd.motion_plot": "モーション スタディのグラフ",
|
||||
"cmd.motion_study": "モーション スタディ",
|
||||
"cmd.motion_trace": "モーション トレース",
|
||||
"cmd.motion_trace_edit": "モーション トレースを編集",
|
||||
"cmd.motion_trace_keep": "モーション トレースを保持",
|
||||
"cmd.occurrence_copy": "貼り付け(インスタンス)",
|
||||
"cmd.occurrence_ground": "空間固定",
|
||||
@@ -1353,7 +1358,7 @@
|
||||
"search.try": "コマンド名、このデザインのフィーチャー、またはパラメーターを入力します。値を指定して実行するには、IDとJSONオブジェクトを入力します:{example}",
|
||||
"search.valid": "有効",
|
||||
"settings.display_quality_note": "表示だけに影響します。設計や書き出しは変わりません。",
|
||||
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。オンの間、ビューは常に再描画されます。",
|
||||
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。",
|
||||
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
|
||||
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
|
||||
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
|
||||
@@ -1362,6 +1367,7 @@
|
||||
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:",
|
||||
"settings.reset_title": "設定が変更されました",
|
||||
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
|
||||
"sheet.no_body": "板金ボディがありません。フランジ > Base で作成するか、板金に変換でプレートを板金に変換してください(広い平面を選択)。",
|
||||
"shortcut.json_error": "パラメータはJSON形式で指定してください: {error}",
|
||||
"shortcut.no_command": "コマンド`{id}`がありません。JSONの前のテキストはコマンドIDである必要があります",
|
||||
"shortcut.pin": "Sボックスの先頭にピン留め",
|
||||
@@ -1398,6 +1404,9 @@
|
||||
"thread.designation": "ねじの呼び",
|
||||
"thread.iso_metric": "ISOメートルねじ",
|
||||
"thread.select_size": "サイズを選択",
|
||||
"timeline.delete_after_marker_one": "履歴マーカー以降の 1 個のフィーチャーを削除",
|
||||
"timeline.delete_after_marker_other": "履歴マーカー以降の {count} 個のフィーチャーを削除",
|
||||
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
|
||||
"timeline.group": "フィーチャーフォルダを新規作成",
|
||||
"timeline.move_many": "タイムライン項目を移動",
|
||||
"timeline.move_to_group": "フィーチャーをフォルダへ移動",
|
||||
@@ -1476,6 +1485,7 @@
|
||||
"ui.agent_chat_files_left_out": "1 つのメッセージで送れるファイルは {max} 個までです。送らなかったファイル: {files}",
|
||||
"ui.agent_chat_files_loading": "ファイルの読み込みを待っています",
|
||||
"ui.agent_chat_files_max": "1 つのメッセージに添付できるのは {max} 個までです",
|
||||
"ui.agent_chat_flatpak_none_found": "コーディングエージェントが見つかりません。Flatpak のサンドボックスは、既定ではホームフォルダー内のエージェントにアクセスできず、ネットワークにも接続できません。nvm または ~/.local/bin にインストールした Claude Code と ACP アダプターを許可するには、端末で次のコマンドを実行し、SolidCraft を再起動してください。指定したプログラムとログインファイルへのアクセス、およびネットワーク接続を許可します:",
|
||||
"ui.agent_chat_import_failed": "{name}: インポートに失敗しました: {error}",
|
||||
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
|
||||
"ui.agent_chat_key_in_env": "キー設定済み",
|
||||
@@ -1548,8 +1558,10 @@
|
||||
"ui.ai_add": "追加",
|
||||
"ui.ai_add_agent": "エージェントを追加…",
|
||||
"ui.ai_add_agent_title": "エージェントを追加",
|
||||
"ui.ai_add_anthropic": "Anthropic 互換 API",
|
||||
"ui.ai_add_api": "OpenAI 互換 API",
|
||||
"ui.ai_add_custom": "カスタムコマンド",
|
||||
"ui.ai_add_endpoint": "API エンドポイント",
|
||||
"ui.ai_add_installed": "インストール済みエージェント",
|
||||
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
|
||||
"ui.ai_added": "追加済み",
|
||||
@@ -1578,6 +1590,7 @@
|
||||
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
|
||||
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
|
||||
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
|
||||
"ui.ai_kind": "種類",
|
||||
"ui.ai_mode_all": "すべて許可",
|
||||
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
|
||||
"ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。",
|
||||
@@ -1599,6 +1612,7 @@
|
||||
"ui.ai_panel": "パネル",
|
||||
"ui.ai_permissions": "権限",
|
||||
"ui.ai_preset": "プリセット",
|
||||
"ui.ai_protocol": "プロトコル",
|
||||
"ui.ai_remove": "削除",
|
||||
"ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください",
|
||||
"ui.ai_reset": "リセット",
|
||||
@@ -1609,6 +1623,8 @@
|
||||
"ui.ai_test": "テスト",
|
||||
"ui.ai_test_acp_ok": "応答あり: {agent}",
|
||||
"ui.ai_test_acp_tip": "エージェントを短時間起動してセッションを要求し、終了します",
|
||||
"ui.ai_test_answered": "接続成功: {model} が応答しました",
|
||||
"ui.ai_test_anthropic_tip": "エンドポイントに短いメッセージを送信します",
|
||||
"ui.ai_test_api_tip": "エンドポイントにモデル一覧を問い合わせます",
|
||||
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
|
||||
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
|
||||
@@ -1766,6 +1782,9 @@
|
||||
"ui.export_keymap_tip": "ショートカット、Sボックスのお気に入り、ツールバー設定をファイルに保存",
|
||||
"ui.extend": "延長",
|
||||
"ui.extent_type": "範囲タイプ",
|
||||
"ui.extract_component": "ファイルに抽出…",
|
||||
"ui.extract_design_changed": "ファイルの選択中にデザインが変更されました。ファイルへの抽出をやり直してください。",
|
||||
"ui.extract_done": "コンポーネントは独立したデザインになり、ここにリンクされました。",
|
||||
"ui.extrude": "押し出し",
|
||||
"ui.factor": "倍率",
|
||||
"ui.feature_history": "フィーチャー履歴",
|
||||
@@ -1797,6 +1816,7 @@
|
||||
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
|
||||
"ui.ground_shadow": "地面の影",
|
||||
"ui.groupSelected": "選択をグループ化",
|
||||
"ui.hatch": "ハッチング",
|
||||
"ui.heading_add_ins": "アドイン",
|
||||
"ui.heading_assemble": "アセンブリ",
|
||||
"ui.heading_box": "直方体",
|
||||
@@ -1904,6 +1924,10 @@
|
||||
"ui.length_decimals": "長さの小数点以下の桁数",
|
||||
"ui.light": "ライト",
|
||||
"ui.line": "線分",
|
||||
"ui.link_copies": "同一のコピーをリンク",
|
||||
"ui.link_copies_keep": "そのまま",
|
||||
"ui.link_copies_link": "リンク",
|
||||
"ui.link_copies_text": "これらのコンポーネントは {name} と同じボディを作成します。そのリンクの配置にしますか?",
|
||||
"ui.lock": "ロック",
|
||||
"ui.loft": "ロフト",
|
||||
"ui.look_at": "正面視",
|
||||
@@ -2027,6 +2051,7 @@
|
||||
"ui.render": "レンダリング",
|
||||
"ui.renderer": "レンダラー",
|
||||
"ui.repeat": "繰り返し",
|
||||
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
|
||||
"ui.report_a_bug": "不具合を報告",
|
||||
"ui.reset": "リセット",
|
||||
"ui.reset_all": "すべてリセット",
|
||||
|
||||
@@ -1064,7 +1064,7 @@
|
||||
"search.try": "명령 이름, 이 디자인의 피처 또는 매개변수를 입력하세요. 값과 함께 명령을 실행하려면 명령 ID와 JSON 객체를 입력하세요: {example}",
|
||||
"search.valid": "올바름",
|
||||
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
|
||||
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다. 켜져 있는 동안 뷰를 계속 다시 그립니다.",
|
||||
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다.",
|
||||
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
|
||||
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
|
||||
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
|
||||
@@ -1187,6 +1187,7 @@
|
||||
"ui.agent_chat_files_left_out": "메시지 하나에는 파일을 최대 {max}개까지 보낼 수 있습니다. 제외됨: {files}",
|
||||
"ui.agent_chat_files_loading": "파일을 읽는 동안 기다리는 중",
|
||||
"ui.agent_chat_files_max": "메시지 하나에 파일은 최대 {max}개",
|
||||
"ui.agent_chat_flatpak_none_found": "코딩 에이전트를 찾지 못했습니다. Flatpak 샌드박스는 기본적으로 홈 폴더에 설치된 에이전트를 볼 수 없고 네트워크에도 접근할 수 없습니다. nvm 또는 ~/.local/bin에 설치한 Claude Code와 ACP 어댑터를 허용하려면 터미널에서 다음 명령을 실행한 뒤 SolidCraft를 다시 시작하세요. 나열된 프로그램과 로그인 파일에 대한 접근 권한 및 네트워크 접근 권한을 부여합니다:",
|
||||
"ui.agent_chat_import_failed": "{name}: 가져오기 실패: {error}",
|
||||
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
|
||||
"ui.agent_chat_key_in_env": "키 설정됨",
|
||||
@@ -1506,6 +1507,7 @@
|
||||
"ui.grid_follows_view_hint": "측면 뷰에서는 지면 대신 마주 보는 수직 평면에 격자를 표시합니다",
|
||||
"ui.ground_shadow": "바닥 그림자",
|
||||
"ui.groupSelected": "선택 항목 그룹화",
|
||||
"ui.hatch": "해칭",
|
||||
"ui.heading_add_ins": "애드인",
|
||||
"ui.heading_assemble": "조립",
|
||||
"ui.heading_box": "박스",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Taip nama arahan, ciri dalam reka bentuk ini, atau parameter. Untuk menjalankan arahan dengan nilai, taip ID-nya dan objek JSON: {example}",
|
||||
"search.valid": "Sah",
|
||||
"settings.display_quality_note": "Tahap halus melicinkan muka dan tepi melengkung, tetapi mesh lebih lambat disiapkan. Hanya paparan berubah, bukan reka bentuk atau eksport.",
|
||||
"settings.frame_rate_note": "Papar bingkai sesaat di kanan bawah. Jika diaktifkan, pandangan dilukis semula tanpa henti.",
|
||||
"settings.frame_rate_note": "Papar bingkai sesaat di kanan bawah.",
|
||||
"settings.measure_axis_distances": "Papar jarak X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Papar jarak paksi dunia X/Y/Z bersama jarak keseluruhan semasa Ukur dibuka.",
|
||||
"settings.msaa_applies_on_restart": "Antialias digunakan apabila SolidCraft dimulakan semula",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "Paling banyak {max} fail bersama satu mesej; ditinggalkan: {files}",
|
||||
"ui.agent_chat_files_loading": "Menunggu fail dibaca",
|
||||
"ui.agent_chat_files_max": "Paling banyak {max} fail pada satu mesej",
|
||||
"ui.agent_chat_flatpak_none_found": "Tiada ejen pengekodan. Secara lalai, kotak pasir Flatpak tidak dapat melihat ejen yang dipasang dalam folder rumah anda dan tiada akses rangkaian. Untuk membenarkan Claude Code dan penyesuai ACP-nya yang dipasang melalui nvm atau dalam ~/.local/bin, jalankan arahan ini dalam terminal, kemudian mulakan semula SolidCraft. Ia memberi akses kepada program dan fail log masuk yang disenaraikan, serta akses rangkaian:",
|
||||
"ui.agent_chat_import_failed": "{name}: import gagal: {error}",
|
||||
"ui.agent_chat_intro": "Sembang dengan ejen pengekodan biasa anda atau titik akhir model pilihan. Ejen mengubah reka bentuk melalui alat SolidCraft; perubahan dipaparkan terus dan boleh dibatalkan sekali bagi setiap mesej. Tetapkan kebenaran di Tetapan > AI.",
|
||||
"ui.agent_chat_key_in_env": "kunci tersedia",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "Pandangan sisi guna grid satah tegak yang menghadapnya, bukan satah tanah",
|
||||
"ui.ground_shadow": "Bayang tanah",
|
||||
"ui.groupSelected": "Kumpulkan Pilihan",
|
||||
"ui.hatch": "Lorek",
|
||||
"ui.heading_add_ins": "TAMBAHAN",
|
||||
"ui.heading_assemble": "PASANG",
|
||||
"ui.heading_box": "KOTAK",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Typ een opdrachtnaam, een kenmerk in dit ontwerp of een parameter. Om een opdracht met waarden uit te voeren, typt u de id en een JSON-object: {example}",
|
||||
"search.valid": "Geldig",
|
||||
"settings.display_quality_note": "Fijnere niveaus tekenen gebogen vlakken en randen vloeiender en kosten meer tijd om te meshen. Alleen de viewport verandert, nooit het ontwerp of de exports.",
|
||||
"settings.frame_rate_note": "Toont frames per seconde in de rechteronderhoek van de weergave. Zolang dit aan staat, wordt de weergave continu opnieuw getekend.",
|
||||
"settings.frame_rate_note": "Toont frames per seconde in de rechteronderhoek van de weergave.",
|
||||
"settings.measure_axis_distances": "X/Y/Z-afstanden tonen",
|
||||
"settings.measure_axis_distances_help": "Toon de componentmaten langs de wereldassen naast de totale afstand terwijl Meten open is.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing wordt toegepast bij de volgende start van SolidCraft",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "Maximaal {max} bestanden per bericht; weggelaten: {files}",
|
||||
"ui.agent_chat_files_loading": "Wachten tot de bestanden zijn gelezen",
|
||||
"ui.agent_chat_files_max": "Maximaal {max} bestanden per bericht",
|
||||
"ui.agent_chat_flatpak_none_found": "Geen codeeragent gevonden. De Flatpak-sandbox ziet standaard geen agents die in je persoonlijke map zijn geïnstalleerd en heeft geen netwerktoegang. Voer dit commando uit in een terminal en start SolidCraft daarna opnieuw om Claude Code en de ACP-adapter toe te staan die via nvm of in ~/.local/bin zijn geïnstalleerd. Het geeft toegang tot de genoemde programma’s en aanmeldbestanden, plus netwerktoegang:",
|
||||
"ui.agent_chat_import_failed": "{name}: importeren mislukt: {error}",
|
||||
"ui.agent_chat_intro": "Chat met een codeeragent die u al gebruikt, of met een modeleindpunt naar keuze. De agent werkt aan het geopende ontwerp via de tools van SolidCraft: de wijzigingen verschijnen terwijl ze worden gemaakt en worden per bericht als één stap ongedaan gemaakt. Wat hij zonder te vragen mag doen, stelt u in via Voorkeuren > AI.",
|
||||
"ui.agent_chat_key_in_env": "sleutel ingesteld",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "Zijaanzichten tonen het raster op het rechtopstaande vlak dat naar hen toe gericht is in plaats van op de grond",
|
||||
"ui.ground_shadow": "Grondschaduw",
|
||||
"ui.groupSelected": "Selectie groeperen",
|
||||
"ui.hatch": "Arcering",
|
||||
"ui.heading_add_ins": "INVOEGMODULES",
|
||||
"ui.heading_assemble": "SAMENSTELLEN",
|
||||
"ui.heading_box": "KUB",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"Also deletes": "Usuwa również",
|
||||
"Angle 2": "Kąt 2",
|
||||
"Animate": "Animuj",
|
||||
"App/system information and command settings are included. Review private details before sharing.": "Dołączone są informacje o aplikacji i systemie oraz ustawienia poleceń. Przed udostępnieniem sprawdź, czy nie ma tam prywatnych danych.",
|
||||
"Appearance name": "Nazwa wyglądu",
|
||||
"Applied Radius": "Zastosowany promień",
|
||||
"Apply To": "Zastosuj do",
|
||||
@@ -83,6 +84,7 @@
|
||||
"Contact": "Kontakt",
|
||||
"Contact Sets": "Zestawy kontaktów",
|
||||
"Contact sets": "Zestawy kontaktów",
|
||||
"Continue on GitHub": "Kontynuuj na GitHubie",
|
||||
"Continuity": "Ciągłość",
|
||||
"Copy Error Message": "Kopiuj komunikat błędu",
|
||||
"Copy Warning Message": "Kopiuj komunikat ostrzeżenia",
|
||||
@@ -102,6 +104,7 @@
|
||||
"Curv. Comb": "Grzebień krzyw.",
|
||||
"Curvature (G2)": "Krzywizna (G2)",
|
||||
"Curvature change (G3)": "Zmiana krzywizny (G3)",
|
||||
"Custom": "Niestandardowy",
|
||||
"Customize Toolbar": "Dostosuj pasek narzędzi",
|
||||
"Delete": "Usuń",
|
||||
"Delete all": "Usuń wszystko",
|
||||
@@ -110,6 +113,8 @@
|
||||
"Delete the set": "Usuń zestaw",
|
||||
"Density": "Gęstość",
|
||||
"Dependents": "Elementy zależne",
|
||||
"Describe the problem you saw": "Opisz zauważony problem",
|
||||
"Describe what happened before continuing.": "Zanim przejdziesz dalej, opisz, co się stało.",
|
||||
"Designs you open or save appear here.": "Tutaj pojawiają się otwierane lub zapisywane projekty.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Wynika z opcji Do obiektu. Wybierz Kąt, aby edytować zakres kątowy.",
|
||||
"Direction": "Kierunek",
|
||||
@@ -127,6 +132,7 @@
|
||||
"Enabled": "Włączone",
|
||||
"End bulge": "Wybrzuszenie końca",
|
||||
"End continuity": "Ciągłość końca",
|
||||
"Expected result (optional)": "Oczekiwany wynik (opcjonalnie)",
|
||||
"Explode": "Rozstrzel",
|
||||
"Explode Text": "Rozbij tekst",
|
||||
"Export Animation…": "Eksportuj animację…",
|
||||
@@ -168,6 +174,7 @@
|
||||
"Height": "Wysokość",
|
||||
"Hidden edges": "Krawędzie niewidoczne",
|
||||
"History layout": "Układ historii",
|
||||
"History names": "Nazwy w historii",
|
||||
"Holes": "Otwory",
|
||||
"Holes (edges on them)": "Otwory (krawędzie na nich)",
|
||||
"Home view": "Widok główny",
|
||||
@@ -234,6 +241,7 @@
|
||||
"No matching appearance presets.": "Brak pasujących ustawień wyglądu.",
|
||||
"No matching appearances": "Brak pasujących wyglądów",
|
||||
"No visible bodies or sketches to export.": "Brak widocznych brył lub szkiców do wyeksportowania.",
|
||||
"Nominal diameter": "Średnica nominalna",
|
||||
"None": "Brak",
|
||||
"Not available yet": "Jeszcze niedostępne",
|
||||
"Notch": "Wycięcie",
|
||||
@@ -242,7 +250,10 @@
|
||||
"Open GitHub draft": "Otwórz szkic zgłoszenia na GitHubie",
|
||||
"Open local libraries in the desktop app. You can export this design's library here.": "Otwórz biblioteki lokalne w aplikacji komputerowej. Tutaj możesz wyeksportować bibliotekę tego projektu.",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Otwartoźródłowy parametryczny CAD 3D w języku Rust. Licencja MIT OR Apache-2.0.",
|
||||
"Opens your browser and copies the report text for you to paste into GitHub.": "Otwiera przeglądarkę internetową i kopiuje treść raportu, którą wkleisz na GitHubie.",
|
||||
"Opens your browser. Review and submit your report on GitHub.": "Otwiera przeglądarkę internetową. Sprawdź raport i wyślij go na GitHubie.",
|
||||
"Operator": "Operator",
|
||||
"Orbit (right drag)": "Obracanie widoku (przeciąganie prawym przyciskiem)",
|
||||
"Other side": "Druga strona",
|
||||
"Other surface (mutual trim)": "Druga powierzchnia (wzajemne przycięcie)",
|
||||
"Outside": "Na zewnątrz",
|
||||
@@ -257,6 +268,7 @@
|
||||
"Pick the tool bodies": "Wybierz bryły narzędziowe",
|
||||
"Picked": "Wybrane",
|
||||
"Picked point": "Wybrany punkt",
|
||||
"Pitch": "Skok",
|
||||
"Place at the origin instead": "Zamiast tego umieść w początku układu",
|
||||
"Plastic Rules": "Reguły tworzyw",
|
||||
"Playback": "Odtwarzanie",
|
||||
@@ -290,6 +302,8 @@
|
||||
"Remove the track": "Usuń ścieżkę",
|
||||
"Remove this point": "Usuń ten punkt",
|
||||
"Report a bug — review a screenshot and open a GitHub draft": "Zgłoś błąd — sprawdź zrzut ekranu i otwórz szkic zgłoszenia na GitHubie",
|
||||
"Report text copied. Paste it into GitHub and submit your report there.": "Skopiowano treść raportu. Wklej ją na GitHubie i tam wyślij raport.",
|
||||
"Reports are public. A GitHub account is required.": "Raporty są publiczne. Wymagane jest konto GitHub.",
|
||||
"Reset Toolbar": "Przywróć pasek narzędzi",
|
||||
"Resize Part": "Zmień rozmiar części",
|
||||
"Restore View": "Przywróć widok",
|
||||
@@ -304,6 +318,7 @@
|
||||
"Revert Pos.": "Przywróć poł.",
|
||||
"Revert Position": "Przywróć położenie",
|
||||
"Review the report and screenshot before opening GitHub.": "Przed otwarciem GitHuba sprawdź raport i zrzut ekranu.",
|
||||
"Review your report on GitHub and click Submit new issue to publish it.": "Sprawdź raport na GitHubie i kliknij Submit new issue, aby go opublikować.",
|
||||
"Rim Edge": "Krawędź obrzeża",
|
||||
"Roll History Marker Here": "Przesuń tutaj znacznik historii",
|
||||
"Round": "Okrągłe",
|
||||
@@ -314,10 +329,12 @@
|
||||
"SURFACE QUALITY": "JAKOŚĆ POWIERZCHNI",
|
||||
"Save Rule": "Zapisz regułę",
|
||||
"Save View": "Zapisz widok",
|
||||
"Save screenshot…": "Zapisz zrzut ekranu…",
|
||||
"Save the study as an animated GIF, seen as the viewport shows it": "Zapisz badanie jako animowany GIF, tak jak widać je w widoku",
|
||||
"Saved preview. Geometry is being checked before editing.": "Zapisany podgląd. Geometria jest sprawdzana przed edycją.",
|
||||
"Scale factor": "Współczynnik skali",
|
||||
"Scene": "Scena",
|
||||
"Screenshot (optional)": "Zrzut ekranu (opcjonalnie)",
|
||||
"Search": "Szukaj",
|
||||
"Search commands": "Szukaj poleceń",
|
||||
"Search this library": "Przeszukaj tę bibliotekę",
|
||||
@@ -352,17 +369,21 @@
|
||||
"Start continuity": "Ciągłość początku",
|
||||
"Step": "Krok",
|
||||
"Steps": "Kroki",
|
||||
"Steps to reproduce (optional)": "Kroki do odtworzenia (opcjonalnie)",
|
||||
"Stitch the kept pieces": "Zszyj zachowane części",
|
||||
"Stitch to both bodies": "Zszyj z obiema bryłami",
|
||||
"Study": "Badanie",
|
||||
"Style": "Styl",
|
||||
"Summary": "Podsumowanie",
|
||||
"Suppressed": "Wygaszone",
|
||||
"Surface": "Powierzchnia",
|
||||
"Swap Ends": "Zamień końce",
|
||||
"TRIM": "PRZYCINANIE",
|
||||
"Tangent (G1)": "Styczność (G1)",
|
||||
"Tangent Chain": "Łańcuch styczny",
|
||||
"Tap drill diameter": "Średnica wiertła pod gwint",
|
||||
"Tear": "Rozdarcie",
|
||||
"Technical details": "Szczegóły techniczne",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Przerywany szkic to zapisana płaszczyzna pomocnicza. Wybierz nową ścianę, aby naprawić zaczepienie.",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Brak zapisanego wystąpienia autora. Przed ponownym powiązaniem wybierz prawidłowe położenie autora.",
|
||||
"Then": "Wtedy",
|
||||
@@ -374,6 +395,7 @@
|
||||
"This child is shared. Deleting it removes it from every copy of its parent.": "Ten element podrzędny jest współdzielony. Usunięcie go usuwa go z każdej kopii elementu nadrzędnego.",
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Ten element podrzędny jest współdzielony. Zmiany położenia dotyczą każdej kopii elementu nadrzędnego.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ta ściana nie zapewnia trwałego zaczepienia. Wybierz inną ścianę lub jawnie użyj stałej płaszczyzny.",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ten obraz nie jest jeszcze dołączony na GitHubie. Skopiuj go lub zapisz, a potem dołącz na GitHubie.",
|
||||
"To Offset": "Do odsunięcia",
|
||||
"To edges": "Do krawędzi",
|
||||
"Tolerance": "Tolerancja",
|
||||
@@ -399,6 +421,9 @@
|
||||
"Wall": "Ścianka",
|
||||
"Wall Location": "Położenie ścianki",
|
||||
"Wall Thickness": "Grubość ścianki",
|
||||
"What did you do just before the problem? A short list is enough": "Co robiłeś tuż przed wystąpieniem problemu? Wystarczy krótka lista",
|
||||
"What did you expect to see?": "Czego się spodziewałeś?",
|
||||
"What happened?": "Co się stało?",
|
||||
"When": "Gdy",
|
||||
"Width": "Szerokość",
|
||||
"Will fail": "Nie powiedzie się",
|
||||
@@ -409,6 +434,29 @@
|
||||
"Z angle": "Kąt Z",
|
||||
"Z distance": "Odległość Z",
|
||||
"Zebra": "Zebra",
|
||||
"ai.prefs.availability": "Sterowanie asystentem",
|
||||
"ai.prefs.background_commands": "Uruchamiaj polecenia agenta w tle",
|
||||
"ai.prefs.follow_camera": "Obracaj kamerę do szkiców",
|
||||
"ai.prefs.follow_camera_off": "Automatyczne podążanie kamery jest wyłączone.",
|
||||
"ai.prefs.follow_camera_on": "Kamera obraca się przodem do szkiców tworzonych przez agenta.",
|
||||
"ai.prefs.intro": "Agenty, uprawnienia i sposób pokazywania ich pracy.",
|
||||
"ai.prefs.make_default": "Używaj domyślnie",
|
||||
"ai.prefs.no_agents": "Brak agentów. Dodaj agenta lub odśwież, aby wyszukać zainstalowane agenty.",
|
||||
"ai.prefs.no_saved_approvals": "Brak zapisanych zgód.",
|
||||
"ai.prefs.override_all_warning": "Ten agent może zapisywać, nadpisywać, eksportować i zastępować projekt bez pytania.",
|
||||
"ai.prefs.permission_all": "Zezwalaj na wszystko",
|
||||
"ai.prefs.permission_all_summary": "Dostępne narzędzia SolidCraft działają bez pytania.",
|
||||
"ai.prefs.permission_ask": "Pytaj przed zmianami",
|
||||
"ai.prefs.permission_ask_summary": "Zatwierdzaj każdą zmianę projektu i każdą operację na plikach.",
|
||||
"ai.prefs.permission_edits": "Zezwalaj na edycję modelu",
|
||||
"ai.prefs.permission_edits_summary": "Edycja modelu działa bez pytania; operacje na plikach i nieodwracalne nadal wymagają pytania.",
|
||||
"ai.prefs.permission_scope": "Dotyczy tylko narzędzi SolidCraft. Powłoka agenta i edycja plików podlegają jego własnym uprawnieniom.",
|
||||
"ai.prefs.pointer_preview_hidden": "Wskaźnik ukryty",
|
||||
"ai.prefs.pointer_preview_speed": "Wskaźnik agenta · {speed}",
|
||||
"ai.prefs.pointer_speed": "Szybkość ruchu",
|
||||
"ai.prefs.privacy_note": "Rozmowę otrzymuje tylko wybrany przez Ciebie agent lub punkt końcowy. Klucze punktów końcowych są przechowywane w systemowym pęku kluczy.",
|
||||
"ai.prefs.show_pointer": "Pokaż wskaźnik agenta",
|
||||
"ai.prefs.turn_limit_hint": "Maksymalna liczba wywołań narzędzi zmieniających projekt, które asystent może wykonać dla jednej wiadomości.",
|
||||
"angle, height": "kąt, wysokość",
|
||||
"appearance.dielectric_ior": "Współczynnik załamania dielektryka (IOR)",
|
||||
"appearance.metallic": "Metaliczność",
|
||||
@@ -759,6 +807,7 @@
|
||||
"cmd.view_graphics_preset": "Ustawienie graficzne",
|
||||
"cmd.view_list": "Lista widoków nazwanych",
|
||||
"cmd.view_navigate": "Nawiguj widokiem",
|
||||
"cmd.view_normal_to": "Prostopadle do zaznaczenia",
|
||||
"cmd.view_rename": "Zmień nazwę widoku nazwanego",
|
||||
"cmd.view_save": "Nowy widok nazwany",
|
||||
"color.copy": "Kliknij, aby skopiować wartości koloru",
|
||||
@@ -856,6 +905,12 @@
|
||||
"help.licences": "Licencje",
|
||||
"help.mit": "Licencja MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Ścina zaznaczone krawędzie płaską fazą o zadanej odległości. Służy do łamania ostrych krawędzi lub ułatwienia wprowadzenia kołka czy śruby.",
|
||||
"help.solid.extrude": "Wyciąga profil szkicu w 3D na zadaną odległość, dodając lub usuwając materiał. Służy do zamiany płaskiego obrysu w blok, występ lub kieszeń.",
|
||||
"help.solid.fillet": "Zaokrągla zaznaczone krawędzie gładkim łukiem o zadanym promieniu. Służy do złagodzenia ostrych krawędzi dla wytrzymałości, wygody lub wyglądu.",
|
||||
"help.solid.hole": "Wierci okrągły otwór o zadanej średnicy w wybranym punkcie ściany. Służy do otworów przelotowych pod śruby, kołki i innych prostych okrągłych otworów.",
|
||||
"help.solid.revolve": "Obraca profil szkicu wokół osi, tworząc okrągłą bryłę. Służy do wałków, gałek, misek i wszystkich części okrągłych względem osi.",
|
||||
"help.solid.shell": "Wydrąża bryłę, pozostawiając ścianki o zadanej grubości, i może otworzyć wybrane ściany. Służy do pudełek, pokryw i innych części cienkościennych.",
|
||||
"help.third_party": "Licencje stron trzecich",
|
||||
"home.back": "Wróć do {name}",
|
||||
"home.crashes": "Raporty awarii",
|
||||
@@ -965,6 +1020,8 @@
|
||||
"motion.to_end": "Na koniec",
|
||||
"motion.to_start": "Na początek",
|
||||
"motion.track_name": "Ścieżka {number}",
|
||||
"nav.free_orbit.help": "Mysz i mysz 3D obracają widok przez oba bieguny i zachowują przechylenie. Przyciąganie do osi z Shift jest wyłączone.",
|
||||
"nav.free_orbit.label": "Swobodne obracanie",
|
||||
"nav.touchpad.help": "Przytrzymaj Shift i poruszaj wskaźnikiem, aby przesuwać; przytrzymaj Alt/Option i poruszaj nim, aby obracać widok. Przycisk myszy nie jest potrzebny. Przewijanie w pionie powiększa.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.orbit": "Obracanie widoku (przytrzymaj Alt/Option i poruszaj; bez przycisku myszy)",
|
||||
@@ -987,8 +1044,130 @@
|
||||
"pattern.flip_direction": "Odwróć kierunek",
|
||||
"pick a hole to seat it, or OK places it at the origin": "wybierz otwór, aby go osadzić, lub OK umieszcza go w początku układu",
|
||||
"pick one or two vertices, edges or faces": "wybierz jeden lub dwa wierzchołki, krawędzie lub ściany",
|
||||
"prefs.add_item": "Dodaj element",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option zwalnia również strzałki manipulatora",
|
||||
"prefs.anti_aliasing_note": "Wygładza poszarpane krawędzie.",
|
||||
"prefs.applies_on_restart": "Działa po ponownym uruchomieniu",
|
||||
"prefs.assistant": "Asystent",
|
||||
"prefs.axes": "Osie",
|
||||
"prefs.axis": "Oś",
|
||||
"prefs.bottom": "Dół",
|
||||
"prefs.browser": "Przeglądarka",
|
||||
"prefs.built_in": "Wbudowana",
|
||||
"prefs.button_left": "Przeciąganie lewym",
|
||||
"prefs.button_middle": "Przeciąganie środkowym",
|
||||
"prefs.button_right": "Przeciąganie prawym",
|
||||
"prefs.choose_font": "Wybierz plik czcionki…",
|
||||
"prefs.click": "Kliknięcie",
|
||||
"prefs.contact_shading": "Miękkie cieniowanie kontaktowe",
|
||||
"prefs.contact_shading_note": "Miękkie cieniowanie tam, gdzie części są blisko siebie.",
|
||||
"prefs.context_menu": "Menu kontekstowe",
|
||||
"prefs.context_menu_ring": "Menu kontekstowe · przytrzymaj, aby otworzyć menu promieniste",
|
||||
"prefs.cpu": "CPU (programowo)",
|
||||
"prefs.crash_reports": "Otwórz raporty o awariach…",
|
||||
"prefs.crash_reports_note": "Raporty są przechowywane na tym komputerze, razem z projektem i poleceniami, które odtwarzają problem.",
|
||||
"prefs.ctrl_cmd": "Ctrl / Cmd",
|
||||
"prefs.curved_surfaces": "Powierzchnie zakrzywione",
|
||||
"prefs.curves": "Powierzchnie zakrzywione",
|
||||
"prefs.dead_zone_note": "Zakreskowany pas to strefa martwa: ruch wewnątrz niej jest pomijany.",
|
||||
"prefs.default": "domyślnie",
|
||||
"prefs.design": "Ustawienia domyślne projektu",
|
||||
"prefs.design_note": "Ustawienia, z którymi startują nowe projekty. Otwarte projekty zachowują własne.",
|
||||
"prefs.devices": "Mysz 3D",
|
||||
"prefs.display": "Wyświetlanie",
|
||||
"prefs.edges": "Krawędzie i cieniowanie",
|
||||
"prefs.extend": "Rozszerz",
|
||||
"prefs.extra_navigation": "Ctrl+przeciąganie lewym na pustym miejscu obraca widok; przytrzymaj Spację, aby przesuwać.",
|
||||
"prefs.follows_os": "Zgodnie z systemem",
|
||||
"prefs.font": "Czcionka",
|
||||
"prefs.font_fallback": "Znaki, których brakuje w wybranej czcionce, są brane z czcionki wbudowanej, więc żadna etykieta nie pokaże pustego prostokąta.",
|
||||
"prefs.font_sample": "Zażółć gęślą jaźń · Ø12.50 mm · 45.0° · 0123456789",
|
||||
"prefs.font_unreadable": "Nie można odczytać wybranej czcionki.",
|
||||
"prefs.general": "Ogólne",
|
||||
"prefs.general_note": "Język, zapisywanie i aktualizacje.",
|
||||
"prefs.gesture_orbit": "Obracanie widoku",
|
||||
"prefs.gesture_pan": "Przesuwanie",
|
||||
"prefs.gesture_zoom": "Powiększanie",
|
||||
"prefs.graphics": "Grafika",
|
||||
"prefs.graphics_note": "Wybierz między szybkością a gładkością. Każda opcja pokazuje swój efekt na przykładzie.",
|
||||
"prefs.graphics_presets_note": "Ustawienia wstępne zmieniają razem poniższe opcje.",
|
||||
"prefs.history": "Historia",
|
||||
"prefs.hold_stop_snapping": "Przytrzymaj, aby wyłączyć przyciąganie",
|
||||
"prefs.icons_only": "Tylko ikony",
|
||||
"prefs.imported_file": "Zaimportowany plik",
|
||||
"prefs.input": "Wprowadzanie",
|
||||
"prefs.interface": "Interfejs",
|
||||
"prefs.interface_language": "Język interfejsu",
|
||||
"prefs.interface_note": "Wygląd SolidCraft i rozmieszczenie jego elementów.",
|
||||
"prefs.interval_minutes": "{n} min",
|
||||
"prefs.labels": "Etykiety",
|
||||
"prefs.layout": "Układ",
|
||||
"prefs.layout_note": "Kompaktowy dokuje panele do krawędzi okna. Przezroczystość przeglądarki jest dostępna w układzie Pływający. Stronę asystenta zmienisz na stronie Asystent.",
|
||||
"prefs.left": "Lewo",
|
||||
"prefs.left_click": "Kliknięcie lewym",
|
||||
"prefs.live_input": "Bieżące wejście",
|
||||
"prefs.live_preview": "Podgląd na żywo",
|
||||
"prefs.minutes": "minut",
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.mouse": "Mysz i gładzik",
|
||||
"prefs.mouse_heading": "Mysz",
|
||||
"prefs.mouse_mapping": "Przypisanie przycisków myszy",
|
||||
"prefs.navigation": "Nawigacja",
|
||||
"prefs.navigation_note": "Jak mysz, gładzik i mysz 3D poruszają widokiem.",
|
||||
"prefs.not_on_gpu": "niedostępne na tym GPU",
|
||||
"prefs.on": "Włączone",
|
||||
"prefs.opaque": "Nieprzezroczysta",
|
||||
"prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.",
|
||||
"prefs.orientation": "Orientacja",
|
||||
"prefs.overall_speed": "Szybkość ogólna",
|
||||
"prefs.preview_choices": "Podgląd wybranych opcji",
|
||||
"prefs.quality_default": "domyślnie",
|
||||
"prefs.quality_fastest": "najszybciej",
|
||||
"prefs.quality_slowest": "najwolniej",
|
||||
"prefs.quality_smoother": "gładziej",
|
||||
"prefs.reports_saved": "Zapisane: {n}",
|
||||
"prefs.right_click": "Kliknięcie prawym",
|
||||
"prefs.saving": "Autozapis i odzyskiwanie",
|
||||
"prefs.scale_note": "Także Ctrl + / Ctrl − w oknie. Przyciski mają 28 px przy 100%.",
|
||||
"prefs.scale_text": "Skala i tekst",
|
||||
"prefs.select": "Zaznacz",
|
||||
"prefs.select_one_item": "Zaznacz jeden element",
|
||||
"prefs.selecting": "Zaznaczanie",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift tylko dodaje; Ctrl / Cmd przełącza",
|
||||
"prefs.shift_add_note": "Włączone: Shift dodaje, Ctrl / Cmd przełącza. Wyłączone (domyślnie): oba przełączają.",
|
||||
"prefs.shortcuts": "Skróty",
|
||||
"prefs.sketch_selection": "Szkic i zaznaczanie",
|
||||
"prefs.sketch_selection_heading": "Szkic i zaznaczanie",
|
||||
"prefs.sketch_selection_note": "Jak działa przyciąganie w szkicach i jak kliknięcia zaznaczają.",
|
||||
"prefs.sketching": "Szkicowanie",
|
||||
"prefs.snap_drawing_note": "Podczas rysowania lub przeciągania punktu szkicu.",
|
||||
"prefs.source": "Źródło",
|
||||
"prefs.speed": "Szybkość",
|
||||
"prefs.stand_up_note": "Gdy plik STEP lub IGES otworzy się z osią Z w górę, proponuj obrót o ćwierć obrotu wokół osi X.",
|
||||
"prefs.status_done": "Gotowe",
|
||||
"prefs.status_error": "Błąd",
|
||||
"prefs.status_selected": "Zaznaczone",
|
||||
"prefs.status_warning": "Ostrzeżenie",
|
||||
"prefs.supersampling_brief": "Rysuje widok w większym rozmiarze i go zmniejsza: bardzo ostro, ale bardziej obciąża.",
|
||||
"prefs.tab_accent_note": "Kolor akcentu podąża za aktywną kartą",
|
||||
"prefs.theme_file": "plik motywu",
|
||||
"prefs.timeline_half_hour": "−30 min",
|
||||
"prefs.timeline_now": "teraz",
|
||||
"prefs.timeline_quarter_hour": "−15",
|
||||
"prefs.toggle_item": "Dodaj element lub usuń go ponownie",
|
||||
"prefs.toolbar": "Pasek narzędzi",
|
||||
"prefs.translucent": "Półprzezroczysta",
|
||||
"prefs.under_browser": "Pod przeglądarką",
|
||||
"prefs.under_cursor": "Pod kursorem",
|
||||
"prefs.units_precision": "Jednostki i dokładność",
|
||||
"prefs.updates": "Aktualizacje",
|
||||
"prefs.view": "Zachowanie widoku",
|
||||
"prefs.viewport": "Widok",
|
||||
"prefs.wheel": "Kółko",
|
||||
"prefs.wheel_trackpad": "Kółko i gładzik",
|
||||
"prefs.workspace": "Środowisko pracy",
|
||||
"prefs.zoom_toward_cursor": "Powiększanie w stronę kursora",
|
||||
"properties.title": "Właściwości: {name}",
|
||||
"recovery.days_ago": "{count} dni temu",
|
||||
"recovery.detail": "{file} · elementy: {features} · autozapis {when}",
|
||||
@@ -1060,7 +1239,7 @@
|
||||
"search.try": "Wpisz nazwę polecenia, element tego projektu lub parametr. Aby uruchomić polecenie z wartościami, wpisz jego identyfikator i obiekt JSON: {example}",
|
||||
"search.valid": "Prawidłowe",
|
||||
"settings.display_quality_note": "Wyższe poziomy rysują zakrzywione ściany i krawędzie gładziej, ale siatka powstaje dłużej. Zmienia się tylko widok, nigdy projekt ani eksport.",
|
||||
"settings.frame_rate_note": "Pokazuje liczbę klatek na sekundę w prawym dolnym rogu widoku. Gdy jest włączone, widok odświeża się bez przerwy.",
|
||||
"settings.frame_rate_note": "Pokazuje liczbę klatek na sekundę w prawym dolnym rogu widoku.",
|
||||
"settings.measure_axis_distances": "Pokaż odległości X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Gdy pomiar jest otwarty, pokazuj składowe wzdłuż osi globalnych obok odległości całkowitej.",
|
||||
"settings.msaa_applies_on_restart": "Wygładzanie krawędzi zadziała po ponownym uruchomieniu SolidCraft",
|
||||
@@ -1105,6 +1284,9 @@
|
||||
"thread.designation": "Oznaczenie",
|
||||
"thread.iso_metric": "Metryczny ISO",
|
||||
"thread.select_size": "Wybierz rozmiar",
|
||||
"timeline.delete_after_marker_one": "Usuń 1 funkcję za znacznikiem historii",
|
||||
"timeline.delete_after_marker_other": "Usuń funkcje za znacznikiem historii: {count}",
|
||||
"timeline.delete_many": "Usuń zaznaczone funkcje: {count}",
|
||||
"timeline.group": "Nowy folder funkcji",
|
||||
"timeline.move_many": "Przenieś elementy osi czasu",
|
||||
"timeline.move_to_group": "Przenieś funkcje do folderu",
|
||||
@@ -1183,6 +1365,7 @@
|
||||
"ui.agent_chat_files_left_out": "Do jednej wiadomości można dołączyć najwyżej {max} plików; pominięto: {files}",
|
||||
"ui.agent_chat_files_loading": "Oczekiwanie na odczytanie plików",
|
||||
"ui.agent_chat_files_max": "Najwyżej {max} plików w jednej wiadomości",
|
||||
"ui.agent_chat_flatpak_none_found": "Nie znaleziono agenta programistycznego. Piaskownica Flatpak domyślnie nie widzi agentów zainstalowanych w folderze domowym i nie ma dostępu do sieci. Aby zezwolić na Claude Code i jego adapter ACP zainstalowane przez nvm lub w ~/.local/bin, uruchom to polecenie w terminalu, a potem uruchom ponownie SolidCraft. Daje ono dostęp do wymienionych programów i plików logowania oraz dostęp do sieci:",
|
||||
"ui.agent_chat_import_failed": "{name}: import się nie powiódł: {error}",
|
||||
"ui.agent_chat_intro": "Rozmawiaj z agentem programistycznym, którego już używasz, lub z wybranym punktem końcowym modelu. Pracuje on na otwartym projekcie za pomocą narzędzi SolidCraft: jego zmiany są widoczne na bieżąco, a cofa się je jednym krokiem na wiadomość. To, co może robić bez pytania, ustawisz w Preferencje > AI.",
|
||||
"ui.agent_chat_key_in_env": "klucz ustawiony",
|
||||
@@ -1250,6 +1433,8 @@
|
||||
"ui.agent_chat_waiting": "Czeka na Ciebie",
|
||||
"ui.agent_cursor": "Kursor agenta",
|
||||
"ui.agent_cursor_speed": "Szybkość kursora agenta",
|
||||
"ui.agent_off_thread": "Zachowaj responsywność okna, gdy agenty wykonują polecenia",
|
||||
"ui.agent_off_thread_note": "Wolne polecenia agenta (z panelu AI lub klienta MCP) są liczone obok okna: możesz dalej obracać i oglądać widok oraz zatrzymać polecenie przyciskiem w widoku. Wyłączone: każde działa w wątku okna, a okno na nie czeka.",
|
||||
"ui.ai_add": "Dodaj",
|
||||
"ui.ai_add_agent": "Dodaj agenta…",
|
||||
"ui.ai_add_agent_title": "Dodaj agenta",
|
||||
@@ -1502,6 +1687,7 @@
|
||||
"ui.grid_follows_view_hint": "Widoki boczne pokazują siatkę na pionowej płaszczyźnie zwróconej do nich zamiast na podłożu",
|
||||
"ui.ground_shadow": "Cień na podłożu",
|
||||
"ui.groupSelected": "Grupuj zaznaczone",
|
||||
"ui.hatch": "Kreskowanie",
|
||||
"ui.heading_add_ins": "DODATKI",
|
||||
"ui.heading_assemble": "ZŁOŻENIE",
|
||||
"ui.heading_box": "PROSTOPADŁOŚCIAN",
|
||||
@@ -1577,10 +1763,17 @@
|
||||
"ui.hideProfile": "Ukryj profil",
|
||||
"ui.history_back": "Cofnij o jeden krok",
|
||||
"ui.history_below_browser": "Pod przeglądarką",
|
||||
"ui.history_edge_hint": "kliknij dwukrotnie krawędź",
|
||||
"ui.history_first": "Przejdź do początku",
|
||||
"ui.history_forward": "Przejdź o jeden krok do przodu",
|
||||
"ui.history_hide_names": "Ukryj nazwy",
|
||||
"ui.history_last": "Przejdź do końca",
|
||||
"ui.history_marker_tip": "Znacznik historii: przeciągnij, aby cofnąć model; nowe funkcje trafiają tutaj",
|
||||
"ui.history_names": "Nazwy",
|
||||
"ui.history_play": "Odtwórz do końca",
|
||||
"ui.history_show_names": "Pokaż nazwy",
|
||||
"ui.history_title": "Historia",
|
||||
"ui.history_toggle_names": "Pokaż lub ukryj nazwy funkcji",
|
||||
"ui.hole": "Otwór",
|
||||
"ui.hole_tap_type": "Typ gwintu otworu",
|
||||
"ui.hole_type": "Typ otworu",
|
||||
@@ -1792,6 +1985,7 @@
|
||||
"ui.smooth_wheel_zoom": "Płynne powiększanie kółkiem",
|
||||
"ui.smooth_wheel_zoom_tip": "Wyłącz, aby każdy ruch kółka działał od razu, bez wygładzania",
|
||||
"ui.snap": "Przyciąganie",
|
||||
"ui.space_mouse_axes_reassigned": "Osie są przypisane inaczej w Preferencjach i to przypisanie nadal działa niezależnie od tej kalibracji.",
|
||||
"ui.space_mouse_calibrate": "Kalibruj…",
|
||||
"ui.space_mouse_calibrate_tip": "Przesuwaj nakładkę zgodnie z poleceniami okna, aby SolidCraft nauczył się kierunku i zakresu każdej osi tej myszy 3D. Zapisywane dla tej myszy 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Kalibracja myszy 3D",
|
||||
@@ -1801,12 +1995,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Jedna oś prawie się nie poruszyła, więc nie można jej skalibrować. Zacznij od nowa i za każdym razem wychylaj nakładkę mocniej.",
|
||||
"ui.space_mouse_dead_zone": "Strefa martwa",
|
||||
"ui.space_mouse_dead_zone_tip": "O ile trzeba wychylić nakładkę, zanim widok się poruszy, jako część pełnego wychylenia. Zwiększ, jeśli widok dryfuje przy nieruszanej nakładce.",
|
||||
"ui.space_mouse_driven_by": "Który skalibrowany ruch urządzenia steruje tą osią. Wybranie ruchu używanego już przez inną oś zamienia je miejscami.",
|
||||
"ui.space_mouse_driven_by_label": "sterowana przez",
|
||||
"ui.space_mouse_driver": "Sterownik myszy 3D",
|
||||
"ui.space_mouse_driver_auto": "Automatycznie",
|
||||
"ui.space_mouse_driver_direct": "Bezpośrednio (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Bezpośredni odczyt urządzenia; spacenavd nie działa",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Odczyt przez spacenavd, jedyny dostępny sposób",
|
||||
"ui.space_mouse_forget": "Zapomnij kalibrację",
|
||||
"ui.space_mouse_hint": "Przesuwaj i obracaj widok jednocześnie; w szkicach działa tylko przesuwanie i powiększanie.",
|
||||
"ui.space_mouse_motion_rotation_x": "Obrót X",
|
||||
"ui.space_mouse_motion_rotation_y": "Obrót Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Obrót Z",
|
||||
"ui.space_mouse_motion_translation_x": "Przesunięcie X",
|
||||
"ui.space_mouse_motion_translation_y": "Przesunięcie Y",
|
||||
"ui.space_mouse_motion_translation_z": "Przesunięcie Z",
|
||||
"ui.space_mouse_navigate": "Nawiguj za pomocą SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Nie znaleziono myszy 3D",
|
||||
"ui.space_mouse_not_calibrated": "Nieskalibrowano",
|
||||
"ui.space_mouse_reading": "Odczyt",
|
||||
"ui.space_mouse_reset_axes": "Resetuj przypisanie osi",
|
||||
"ui.space_mouse_step_lift": "Unieś nakładkę, przytrzymaj, a następnie puść.",
|
||||
"ui.space_mouse_step_press": "Wciśnij nakładkę w dół, przytrzymaj, a następnie puść.",
|
||||
"ui.space_mouse_step_pull": "Pociągnij nakładkę do siebie, przytrzymaj, a następnie puść.",
|
||||
@@ -1820,6 +2028,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Przechyl nakładkę w prawo (prawa krawędź w dół), przytrzymaj, a następnie puść.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Obróć nakładkę zgodnie z ruchem wskazówek zegara, przytrzymaj, a następnie puść.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Obróć nakładkę przeciwnie do ruchu wskazówek zegara, przytrzymaj, a następnie puść.",
|
||||
"ui.space_mouse_view_axis": "Oś widoku",
|
||||
"ui.space_mouse_view_off": "Wyłączona",
|
||||
"ui.space_mouse_view_off_tip": "Profil sterownika myszy 3D wyłącza tę oś, więc jej ruch nie porusza widokiem. Wybranie tutaj ruchu innej osi zamienia je miejscami, a ten ruch przestaje poruszać widokiem.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Obracanie w lewo / w prawo",
|
||||
"ui.space_mouse_view_orbit_up_down": "Obracanie w górę / w dół",
|
||||
"ui.space_mouse_view_pan_sideways": "Przesuwanie w lewo / w prawo",
|
||||
"ui.space_mouse_view_pan_up_down": "Przesuwanie w górę / w dół",
|
||||
"ui.space_mouse_view_roll": "Przechylenie",
|
||||
"ui.space_mouse_view_unmapped": "Oś {n}",
|
||||
"ui.space_mouse_view_zoom": "Powiększanie",
|
||||
"ui.spacing": "Odstęp",
|
||||
"ui.spacing_2": "Odstęp 2",
|
||||
"ui.sphere": "Sfera",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Digite o nome de um comando, um recurso deste projeto ou um parâmetro. Para executar um comando com valores, digite seu id e um objeto JSON: {example}",
|
||||
"search.valid": "Válido",
|
||||
"settings.display_quality_note": "Níveis mais finos desenham faces e arestas curvas mais suaves e demoram mais para gerar a malha. Só a janela de visualização muda, nunca o projeto ou as exportações.",
|
||||
"settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista. Enquanto estiver ativo, a vista é redesenhada continuamente.",
|
||||
"settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista.",
|
||||
"settings.measure_axis_distances": "Mostrar distâncias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar as componentes nos eixos do mundo junto com a distância total enquanto Medir está aberto.",
|
||||
"settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o SolidCraft iniciar",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "No máximo {max} arquivos acompanham uma mensagem; deixados de fora: {files}",
|
||||
"ui.agent_chat_files_loading": "Aguardando a leitura dos arquivos",
|
||||
"ui.agent_chat_files_max": "No máximo {max} arquivos por mensagem",
|
||||
"ui.agent_chat_flatpak_none_found": "Nenhum agente de programação encontrado. Por padrão, a sandbox do Flatpak não vê agentes instalados na sua pasta pessoal e não tem acesso à rede. Para permitir o Claude Code e o adaptador ACP dele instalados via nvm ou em ~/.local/bin, execute este comando em um terminal e reinicie o SolidCraft. Ele concede acesso aos programas e arquivos de login listados, além de acesso à rede:",
|
||||
"ui.agent_chat_import_failed": "{name}: falha na importação: {error}",
|
||||
"ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do SolidCraft: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.",
|
||||
"ui.agent_chat_key_in_env": "chave definida",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "As vistas laterais mostram a grade no plano vertical voltado para elas, em vez do solo",
|
||||
"ui.ground_shadow": "Sombra no chão",
|
||||
"ui.groupSelected": "Agrupar seleção",
|
||||
"ui.hatch": "Hachura",
|
||||
"ui.heading_add_ins": "COMPLEMENTOS",
|
||||
"ui.heading_assemble": "MONTAR",
|
||||
"ui.heading_box": "CAIXA",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Tastați numele unei comenzi, o operație din acest proiect sau un parametru. Pentru a executa o comandă cu valori, tastați id-ul ei și un obiect JSON: {example}",
|
||||
"search.valid": "Valid",
|
||||
"settings.display_quality_note": "Nivelurile mai fine desenează fețele și muchiile curbe mai neted și durează mai mult la generarea plasei. Se schimbă doar fereastra de vizualizare, niciodată proiectul sau exporturile.",
|
||||
"settings.frame_rate_note": "Afișează cadrele pe secundă în colțul din dreapta jos al vizualizării. Cât timp este activat, vizualizarea se redesenează continuu.",
|
||||
"settings.frame_rate_note": "Afișează cadrele pe secundă în colțul din dreapta jos al vizualizării.",
|
||||
"settings.measure_axis_distances": "Afișează distanțele X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Afișează cotele pe axele globale alături de distanța totală cât timp Măsurare este deschis.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing se aplică la următoarea pornire a SolidCraft",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "Cel mult {max} fișiere merg cu un mesaj; omise: {files}",
|
||||
"ui.agent_chat_files_loading": "Se așteaptă citirea fișierelor",
|
||||
"ui.agent_chat_files_max": "Cel mult {max} fișiere pe un mesaj",
|
||||
"ui.agent_chat_flatpak_none_found": "Nu s-a găsit niciun agent de programare. Implicit, sandboxul Flatpak nu vede agenții instalați în dosarul personal și nu are acces la rețea. Pentru a permite Claude Code și adaptorul său ACP instalate prin nvm sau în ~/.local/bin, rulați această comandă într-un terminal, apoi reporniți SolidCraft. Ea acordă acces la programele și fișierele de autentificare enumerate, precum și acces la rețea:",
|
||||
"ui.agent_chat_import_failed": "{name}: importul a eșuat: {error}",
|
||||
"ui.agent_chat_intro": "Conversați cu un agent de programare pe care îl folosiți deja sau cu un endpoint de model ales de dumneavoastră. Lucrează la proiectul deschis prin instrumentele SolidCraft: modificările lui apar pe măsură ce le face și se anulează ca un singur pas per mesaj. Ce poate face fără să întrebe se stabilește în Preferințe > AI.",
|
||||
"ui.agent_chat_key_in_env": "cheie setată",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "Vederile laterale afișează grila pe planul vertical orientat spre ele, în loc de sol",
|
||||
"ui.ground_shadow": "Umbra la sol",
|
||||
"ui.groupSelected": "Grupează selecția",
|
||||
"ui.hatch": "Hașură",
|
||||
"ui.heading_add_ins": "EXTENSII",
|
||||
"ui.heading_assemble": "ASAMBLARE",
|
||||
"ui.heading_box": "CUTIE",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Введите название команды, операцию этого дизайна или параметр. Чтобы выполнить команду со значениями, введите её id и объект JSON: {example}",
|
||||
"search.valid": "Допустимо",
|
||||
"settings.display_quality_note": "Более высокие уровни рисуют криволинейные грани и рёбра глаже, но сетка строится дольше. Меняется только видовой экран, но не дизайн и не экспорт.",
|
||||
"settings.frame_rate_note": "Показывает кадры в секунду в правом нижнем углу вида. Пока включено, вид перерисовывается непрерывно.",
|
||||
"settings.frame_rate_note": "Показывает кадры в секунду в правом нижнем углу вида.",
|
||||
"settings.measure_axis_distances": "Расстояния X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Показывать размеры вдоль мировых осей вместе с общим расстоянием, пока открыто окно измерения.",
|
||||
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "К одному сообщению можно приложить не более {max} файлов; не добавлены: {files}",
|
||||
"ui.agent_chat_files_loading": "Ожидание чтения файлов",
|
||||
"ui.agent_chat_files_max": "Не более {max} файлов в одном сообщении",
|
||||
"ui.agent_chat_flatpak_none_found": "Кодирующий агент не найден. По умолчанию песочница Flatpak не видит агентов, установленных в домашней папке, и не имеет доступа к сети. Чтобы разрешить Claude Code и его адаптер ACP, установленные через nvm или в ~/.local/bin, выполните эту команду в терминале и перезапустите SolidCraft. Она даёт доступ к перечисленным программам и файлам входа, а также доступ к сети:",
|
||||
"ui.agent_chat_import_failed": "{name}: не удалось импортировать: {error}",
|
||||
"ui.agent_chat_intro": "Общайтесь с уже используемым кодирующим агентом или выбранной конечной точкой модели. Он работает с открытым дизайном через инструменты SolidCraft: изменения видны по мере выполнения и отменяются одним шагом на сообщение. Что ему разрешено без запроса, задаётся в Настройки > ИИ.",
|
||||
"ui.agent_chat_key_in_env": "ключ задан",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "На боковых видах сетка показывается на вертикальной плоскости, обращённой к вам, а не на земле",
|
||||
"ui.ground_shadow": "Тень на земле",
|
||||
"ui.groupSelected": "Группировать выбранное",
|
||||
"ui.hatch": "Штриховка",
|
||||
"ui.heading_add_ins": "НАДСТРОЙКИ",
|
||||
"ui.heading_assemble": "СБОРКА",
|
||||
"ui.heading_box": "БОКС",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Skriv ett kommandonamn, en funktion i denna konstruktion eller en parameter. För att köra ett kommando med värden, skriv dess id och ett JSON-objekt: {example}",
|
||||
"search.valid": "Giltig",
|
||||
"settings.display_quality_note": "Finare nivåer ritar krökta ytor och kanter jämnare och tar längre tid att mesha. Endast vyporten ändras, aldrig konstruktionen eller exporter.",
|
||||
"settings.frame_rate_note": "Visar bildrutor per sekund i vyns nedre högra hörn. Medan det är på ritas vyn om kontinuerligt.",
|
||||
"settings.frame_rate_note": "Visar bildrutor per sekund i vyns nedre högra hörn.",
|
||||
"settings.measure_axis_distances": "Visa X/Y/Z-avstånd",
|
||||
"settings.measure_axis_distances_help": "Visa världsaxelns komponentmått bredvid det totala avståndet medan Mät är öppet.",
|
||||
"settings.msaa_applies_on_restart": "Kantutjämning tillämpas nästa gång SolidCraft startar",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "Högst {max} filer följer med ett meddelande; utelämnade: {files}",
|
||||
"ui.agent_chat_files_loading": "Väntar på att filerna ska läsas",
|
||||
"ui.agent_chat_files_max": "Högst {max} filer i ett meddelande",
|
||||
"ui.agent_chat_flatpak_none_found": "Ingen kodningsagent hittades. Flatpak-sandlådan ser som standard inte agenter som är installerade i din hemmapp och har ingen nätverksåtkomst. För att tillåta Claude Code och dess ACP-adapter installerade via nvm eller i ~/.local/bin kör du det här kommandot i en terminal och startar sedan om SolidCraft. Det ger åtkomst till de angivna programmen och inloggningsfilerna samt nätverksåtkomst:",
|
||||
"ui.agent_chat_import_failed": "{name}: importen misslyckades: {error}",
|
||||
"ui.agent_chat_intro": "Chatta med en kodningsagent du redan använder, eller en modellslutpunkt du väljer. Den arbetar med den öppna konstruktionen via SolidCrafts verktyg: dess ändringar syns medan den gör dem och ångras som ett steg per meddelande. Vad den får göra utan att fråga ställs in under Inställningar > AI.",
|
||||
"ui.agent_chat_key_in_env": "nyckel angiven",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "Sidovyer visar rutnätet på det upprätta planet som vetter mot dem istället för marken",
|
||||
"ui.ground_shadow": "Markskugga",
|
||||
"ui.groupSelected": "Gruppera markerade",
|
||||
"ui.hatch": "Skraffering",
|
||||
"ui.heading_add_ins": "TILLÄGG",
|
||||
"ui.heading_assemble": "MONTERA",
|
||||
"ui.heading_box": "LÅDA",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "Bir komut adı, bu tasarımdaki bir özellik veya bir parametre yazın. Bir komutu değerlerle çalıştırmak için kimliğini ve bir JSON nesnesi yazın: {example}",
|
||||
"search.valid": "Geçerli",
|
||||
"settings.display_quality_note": "Daha ince seviyeler eğri yüzeyleri ve kenarları daha pürüzsüz gösterir, ancak ağ oluşturma daha uzun sürer. Yalnızca görünüm değişir; tasarım ve dışa aktarımlar asla değişmez.",
|
||||
"settings.frame_rate_note": "Görünümün sağ alt köşesinde saniyedeki kare sayısını gösterir. Açıkken görünüm sürekli yeniden çizilir.",
|
||||
"settings.frame_rate_note": "Görünümün sağ alt köşesinde saniyedeki kare sayısını gösterir.",
|
||||
"settings.measure_axis_distances": "X/Y/Z mesafelerini göster",
|
||||
"settings.measure_axis_distances_help": "Ölç açıkken toplam mesafenin yanında dünya ekseni bileşen ölçülerini gösterir.",
|
||||
"settings.msaa_applies_on_restart": "Kenar yumuşatma, SolidCraft bir sonraki başlatıldığında uygulanır",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "Bir iletiyle en fazla {max} dosya gider; dışarıda kalanlar: {files}",
|
||||
"ui.agent_chat_files_loading": "Dosyaların okunması bekleniyor",
|
||||
"ui.agent_chat_files_max": "Bir iletide en fazla {max} dosya olabilir",
|
||||
"ui.agent_chat_flatpak_none_found": "Kodlama ajanı bulunamadı. Flatpak korumalı alanı varsayılan olarak ana klasörünüze yüklenen ajanları göremez ve ağ erişimi yoktur. nvm ile veya ~/.local/bin içine yüklenen Claude Code'a ve ACP bağdaştırıcısına izin vermek için bu komutu bir terminalde çalıştırın, ardından SolidCraft'ı yeniden başlatın. Komut, listelenen programlara ve oturum açma dosyalarına erişimin yanı sıra ağ erişimi de verir:",
|
||||
"ui.agent_chat_import_failed": "{name}: içe aktarma başarısız oldu: {error}",
|
||||
"ui.agent_chat_intro": "Zaten kullandığınız bir kodlama ajanıyla veya seçtiğiniz bir model uç noktasıyla sohbet edin. Ajan, SolidCraft'ın araçlarıyla açık tasarım üzerinde çalışır: Değişiklikleri hemen görünür ve mesaj başına tek adımda geri alınabilir. Sormadan neleri yapabileceğini Ayarlar > AI altında belirlersiniz.",
|
||||
"ui.agent_chat_key_in_env": "anahtar ayarlı",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "Yan görünümlerde ızgara, zemin yerine onlara bakan dik düzlemde gösterilir",
|
||||
"ui.ground_shadow": "Zemin gölgesi",
|
||||
"ui.groupSelected": "Seçimi grupla",
|
||||
"ui.hatch": "Tarama",
|
||||
"ui.heading_add_ins": "EKLENTİLER",
|
||||
"ui.heading_assemble": "MONTAJ",
|
||||
"ui.heading_box": "KUTU",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Введіть назву команди, елемент цього проєкту або параметр. Щоб виконати команду зі значеннями, введіть її id та об'єкт JSON: {example}",
|
||||
"search.valid": "Дійсне",
|
||||
"settings.display_quality_note": "Дрібніші рівні малюють криві грані й ребра плавнішими, але тріангуляція триває довше. Змінюється лише вікно перегляду, ніколи не проєкт чи експорт.",
|
||||
"settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду. Поки це ввімкнено, вид перемальовується безперервно.",
|
||||
"settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду.",
|
||||
"settings.measure_axis_distances": "Відстані X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.",
|
||||
"settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "З одним повідомленням надсилається щонайбільше {max} файлів; пропущено: {files}",
|
||||
"ui.agent_chat_files_loading": "Очікування читання файлів",
|
||||
"ui.agent_chat_files_max": "Щонайбільше {max} файлів на одне повідомлення",
|
||||
"ui.agent_chat_flatpak_none_found": "Агента для програмування не знайдено. Пісочниця Flatpak типово не бачить агентів, установлених у домашній теці, і не має доступу до мережі. Щоб дозволити Claude Code і його адаптер ACP, установлені через nvm або в ~/.local/bin, виконайте цю команду в терміналі, а потім перезапустіть SolidCraft. Вона надає доступ до перелічених програм і файлів входу, а також доступ до мережі:",
|
||||
"ui.agent_chat_import_failed": "{name}: не вдалося імпортувати: {error}",
|
||||
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
|
||||
"ui.agent_chat_key_in_env": "ключ задано",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "Бічні види показують сітку на вертикальній площині, повернутій до них, а не на землі",
|
||||
"ui.ground_shadow": "Тінь на землі",
|
||||
"ui.groupSelected": "Згрупувати виділене",
|
||||
"ui.hatch": "Штрихування",
|
||||
"ui.heading_add_ins": "НАДБУДОВИ",
|
||||
"ui.heading_assemble": "СКЛАДАННЯ",
|
||||
"ui.heading_box": "КУБ",
|
||||
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"search.try": "Nhập tên lệnh, một tính năng trong thiết kế này hoặc một tham số. Để chạy lệnh kèm giá trị, hãy nhập mã lệnh và một đối tượng JSON: {example}",
|
||||
"search.valid": "Hợp lệ",
|
||||
"settings.display_quality_note": "Mức độ chi tiết cao hơn giúp hiển thị các mặt cong và cạnh mượt mà hơn nhưng tốn nhiều thời gian tạo lưới hơn. Thiết lập này chỉ thay đổi vùng nhìn, không ảnh hưởng đến thiết kế hay dữ liệu xuất tệp.",
|
||||
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn. Khi bật, khung nhìn sẽ vẽ lại liên tục.",
|
||||
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn.",
|
||||
"settings.measure_axis_distances": "Hiển thị khoảng cách theo trục X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Hiển thị kích thước thành phần theo các trục tọa độ cùng với khoảng cách tổng thể khi mở công cụ Đo lường.",
|
||||
"settings.msaa_applies_on_restart": "Khử răng cưa sẽ áp dụng vào lần khởi động tiếp theo của SolidCraft",
|
||||
@@ -1183,6 +1183,7 @@
|
||||
"ui.agent_chat_files_left_out": "Tối đa {max} tệp đính kèm trong một tin nhắn; đã bỏ qua: {files}",
|
||||
"ui.agent_chat_files_loading": "Đang chờ đọc các tệp đính kèm",
|
||||
"ui.agent_chat_files_max": "Tối đa {max} tệp trong một tin nhắn",
|
||||
"ui.agent_chat_flatpak_none_found": "Không tìm thấy tác tử lập trình nào. Theo mặc định, hộp cát Flatpak không thấy các tác tử được cài trong thư mục nhà của bạn và không có quyền truy cập mạng. Để cho phép Claude Code và bộ điều hợp ACP của nó được cài qua nvm hoặc trong ~/.local/bin, hãy chạy lệnh này trong terminal, rồi khởi động lại SolidCraft. Lệnh cấp quyền truy cập các chương trình và tệp đăng nhập được liệt kê, cùng quyền truy cập mạng:",
|
||||
"ui.agent_chat_import_failed": "{name}: nhập tệp thất bại: {error}",
|
||||
"ui.agent_chat_intro": "Trò chuyện với tác tử lập trình (AI agent) bạn đang dùng hoặc một điểm cuối mô hình bạn chọn. AI thao tác trên thiết kế đang mở thông qua các công cụ của SolidCraft: các thay đổi hiển thị ngay khi thực hiện và có thể hoàn tác theo từng bước cho mỗi tin nhắn. Các thao tác AI được phép thực hiện mà không cần hỏi được đặt trong Tùy chọn cài đặt > AI.",
|
||||
"ui.agent_chat_key_in_env": "đã đặt khóa",
|
||||
@@ -1502,6 +1503,7 @@
|
||||
"ui.grid_follows_view_hint": "Các góc nhìn cạnh hiển thị lưới trên mặt phẳng thẳng đứng hướng về phía camera thay vì trên mặt sàn",
|
||||
"ui.ground_shadow": "Bóng đổ mặt sàn",
|
||||
"ui.groupSelected": "Gộp nhóm vùng chọn",
|
||||
"ui.hatch": "Gạch mặt cắt",
|
||||
"ui.heading_add_ins": "TIỆN ÍCH MỞ RỘNG",
|
||||
"ui.heading_assemble": "LẮP RÁP",
|
||||
"ui.heading_box": "KHỐI HỘP",
|
||||
|
||||
@@ -1059,7 +1059,7 @@
|
||||
"search.try": "輸入命令名稱、此設計中的特徵或參數。要帶值執行命令,請輸入其ID和JSON物件:{example}",
|
||||
"search.valid": "有效",
|
||||
"settings.display_quality_note": "等級越精細,曲面和邊顯示越平滑,但網格化耗時更長。只影響檢視區,不影響設計或匯出。",
|
||||
"settings.frame_rate_note": "在檢視右下角顯示每秒影格數。開啟時檢視會持續重繪。",
|
||||
"settings.frame_rate_note": "在檢視右下角顯示每秒影格數。",
|
||||
"settings.measure_axis_distances": "顯示 X/Y/Z 距離",
|
||||
"settings.measure_axis_distances_help": "開啟測量時,在總距離之外顯示沿世界座標軸方向的分量尺寸。",
|
||||
"settings.msaa_applies_on_restart": "抗鋸齒將在下次啟動 SolidCraft 時生效",
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"ui.agent_chat_files_left_out": "一條訊息最多傳送 {max} 個檔案;未傳送:{files}",
|
||||
"ui.agent_chat_files_loading": "正在等待檔案讀取完成",
|
||||
"ui.agent_chat_files_max": "一條訊息最多附加 {max} 個檔案",
|
||||
"ui.agent_chat_flatpak_none_found": "未找到程式設計代理。預設情況下,Flatpak 沙箱無法存取主資料夾中安裝的代理,也無法存取網路。若要允許透過 nvm 或在 ~/.local/bin 中安裝的 Claude Code 及其 ACP 轉接器,請在終端機執行以下命令,然後重新啟動 SolidCraft。此命令會授予對所列程式和登入檔案的存取權限,以及網路存取權限:",
|
||||
"ui.agent_chat_import_failed": "{name}:匯入失敗:{error}",
|
||||
"ui.agent_chat_intro": "與您已在使用的程式設計代理或您選擇的模型端點聊天。它透過 SolidCraft 的工具處理開啟的設計:變更即時顯示,每條訊息的變更可一步復原。無需詢問即可執行的範圍在「偏好設定 > AI」中設定。",
|
||||
"ui.agent_chat_key_in_env": "已設定金鑰",
|
||||
@@ -1501,6 +1502,7 @@
|
||||
"ui.grid_follows_view_hint": "在側檢視中,將網格顯示在朝向該檢視的豎直平面上,而不是地面上",
|
||||
"ui.ground_shadow": "地面陰影",
|
||||
"ui.groupSelected": "群組所選",
|
||||
"ui.hatch": "剖面線",
|
||||
"ui.heading_add_ins": "增益集",
|
||||
"ui.heading_assemble": "組裝",
|
||||
"ui.heading_box": "長方體",
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"Export JSON…": "导出 JSON…",
|
||||
"Export design library...": "导出设计库...",
|
||||
"Export hidden edges as dashed lines": "以虚线导出隐藏线",
|
||||
"Exported by SolidWorks, which usually saves Y up": "由 SolidWorks 导出,通常以 Y 轴向上保存",
|
||||
"Expression": "表达式",
|
||||
"Extend to meet": "延伸至相交",
|
||||
"Extents": "范围",
|
||||
@@ -609,8 +610,10 @@
|
||||
"cmd.component_activate": "激活组件",
|
||||
"cmd.component_break_link": "断开链接",
|
||||
"cmd.component_capture_position": "捕获位置",
|
||||
"cmd.component_copies": "查找相同副本",
|
||||
"cmd.component_create": "新建组件",
|
||||
"cmd.component_delete": "删除组件",
|
||||
"cmd.component_extract": "提取到文件",
|
||||
"cmd.component_from_bodies": "由实体创建组件",
|
||||
"cmd.component_insert_design": "插入设计",
|
||||
"cmd.component_links": "列出链接",
|
||||
@@ -623,6 +626,7 @@
|
||||
"cmd.component_properties": "组件物理属性",
|
||||
"cmd.component_relink": "重新链接源文件",
|
||||
"cmd.component_rename": "重命名组件",
|
||||
"cmd.component_replace_with_link": "替换为链接",
|
||||
"cmd.component_revert_position": "恢复位置",
|
||||
"cmd.component_unlock": "解锁内容",
|
||||
"cmd.component_update_links": "更新链接",
|
||||
@@ -740,6 +744,7 @@
|
||||
"cmd.motion_plot": "运动研究图表",
|
||||
"cmd.motion_study": "运动研究",
|
||||
"cmd.motion_trace": "运动轨迹",
|
||||
"cmd.motion_trace_edit": "编辑运动轨迹",
|
||||
"cmd.motion_trace_keep": "保留运动轨迹",
|
||||
"cmd.occurrence_copy": "粘贴(实例)",
|
||||
"cmd.occurrence_ground": "固定",
|
||||
@@ -1353,7 +1358,7 @@
|
||||
"search.try": "输入命令名称、此设计中的特征或参数。要带值运行命令,请输入其ID和JSON对象:{example}",
|
||||
"search.valid": "有效",
|
||||
"settings.display_quality_note": "仅影响显示,不影响设计和导出。",
|
||||
"settings.frame_rate_note": "在视图右下角显示每秒帧数。开启时视图会持续重绘。",
|
||||
"settings.frame_rate_note": "在视图右下角显示每秒帧数。",
|
||||
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
|
||||
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
|
||||
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
|
||||
@@ -1362,6 +1367,7 @@
|
||||
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 SolidCraft 无法启动,因此已更改:",
|
||||
"settings.reset_title": "设置已更改",
|
||||
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
|
||||
"sheet.no_body": "没有钣金实体:请使用法兰 > Base 创建,或使用转换为钣金将板材转换为钣金(选择其较大的平面)。",
|
||||
"shortcut.json_error": "参数必须采用 JSON 格式:{error}",
|
||||
"shortcut.no_command": "没有命令 `{id}`;JSON 前的文本必须是命令 ID",
|
||||
"shortcut.pin": "固定到 S 框顶部",
|
||||
@@ -1398,6 +1404,9 @@
|
||||
"thread.designation": "螺纹标记",
|
||||
"thread.iso_metric": "ISO 公制",
|
||||
"thread.select_size": "选择尺寸",
|
||||
"timeline.delete_after_marker_one": "删除历史标记之后的 1 个特征",
|
||||
"timeline.delete_after_marker_other": "删除历史标记之后的 {count} 个特征",
|
||||
"timeline.delete_many": "删除所选的 {count} 个特征",
|
||||
"timeline.group": "新建特征文件夹",
|
||||
"timeline.move_many": "移动时间线项目",
|
||||
"timeline.move_to_group": "将特征移至文件夹",
|
||||
@@ -1476,6 +1485,7 @@
|
||||
"ui.agent_chat_files_left_out": "一条消息最多发送 {max} 个文件;未发送:{files}",
|
||||
"ui.agent_chat_files_loading": "正在等待文件读取完成",
|
||||
"ui.agent_chat_files_max": "一条消息最多附加 {max} 个文件",
|
||||
"ui.agent_chat_flatpak_none_found": "未找到编程代理。默认情况下,Flatpak 沙箱无法访问主目录中安装的代理,也无法访问网络。要允许使用通过 nvm 或 ~/.local/bin 安装的 Claude Code 及其 ACP 适配器,请在终端运行以下命令,然后重新启动 SolidCraft。此命令授予对所列程序和登录文件的访问权限以及网络访问权限:",
|
||||
"ui.agent_chat_import_failed": "{name}:导入失败:{error}",
|
||||
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
|
||||
"ui.agent_chat_key_in_env": "已设置密钥",
|
||||
@@ -1548,8 +1558,10 @@
|
||||
"ui.ai_add": "添加",
|
||||
"ui.ai_add_agent": "添加智能体…",
|
||||
"ui.ai_add_agent_title": "添加智能体",
|
||||
"ui.ai_add_anthropic": "Anthropic 兼容 API",
|
||||
"ui.ai_add_api": "OpenAI 兼容 API",
|
||||
"ui.ai_add_custom": "自定义命令",
|
||||
"ui.ai_add_endpoint": "API 端点",
|
||||
"ui.ai_add_installed": "已安装的智能体",
|
||||
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
|
||||
"ui.ai_added": "已添加",
|
||||
@@ -1578,6 +1590,7 @@
|
||||
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
|
||||
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
|
||||
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
|
||||
"ui.ai_kind": "类型",
|
||||
"ui.ai_mode_all": "全部允许",
|
||||
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
|
||||
"ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。",
|
||||
@@ -1599,6 +1612,7 @@
|
||||
"ui.ai_panel": "面板",
|
||||
"ui.ai_permissions": "权限",
|
||||
"ui.ai_preset": "预设",
|
||||
"ui.ai_protocol": "协议",
|
||||
"ui.ai_remove": "移除",
|
||||
"ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续",
|
||||
"ui.ai_reset": "重置",
|
||||
@@ -1609,6 +1623,8 @@
|
||||
"ui.ai_test": "测试",
|
||||
"ui.ai_test_acp_ok": "已响应:{agent}",
|
||||
"ui.ai_test_acp_tip": "短暂启动智能体并请求一个会话,然后结束它",
|
||||
"ui.ai_test_answered": "已连接:{model} 已响应",
|
||||
"ui.ai_test_anthropic_tip": "向端点发送一条简短消息",
|
||||
"ui.ai_test_api_tip": "向端点查询其模型列表",
|
||||
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
|
||||
"ui.ai_test_ok": "已连接:{count} 个模型",
|
||||
@@ -1766,6 +1782,9 @@
|
||||
"ui.export_keymap_tip": "将按键、S 框收藏和工具栏导出为文件",
|
||||
"ui.extend": "延伸",
|
||||
"ui.extent_type": "延伸类型",
|
||||
"ui.extract_component": "提取到文件…",
|
||||
"ui.extract_design_changed": "选择文件时设计已更改。请重新开始提取到文件。",
|
||||
"ui.extract_done": "该组件现在是独立的设计,并已在此处链接。",
|
||||
"ui.extrude": "拉伸",
|
||||
"ui.factor": "系数",
|
||||
"ui.feature_history": "特征历史",
|
||||
@@ -1797,6 +1816,7 @@
|
||||
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
|
||||
"ui.ground_shadow": "地面阴影",
|
||||
"ui.groupSelected": "将所选编组",
|
||||
"ui.hatch": "剖面线",
|
||||
"ui.heading_add_ins": "加载项",
|
||||
"ui.heading_assemble": "装配",
|
||||
"ui.heading_box": "长方体",
|
||||
@@ -1904,6 +1924,10 @@
|
||||
"ui.length_decimals": "长度小数位",
|
||||
"ui.light": "浅色",
|
||||
"ui.line": "直线",
|
||||
"ui.link_copies": "链接相同副本",
|
||||
"ui.link_copies_keep": "保留",
|
||||
"ui.link_copies_link": "链接",
|
||||
"ui.link_copies_text": "这些组件生成的实体与 {name} 相同。要将它们改为其链接的放置吗?",
|
||||
"ui.lock": "锁定",
|
||||
"ui.loft": "放样",
|
||||
"ui.look_at": "朝向",
|
||||
@@ -2027,6 +2051,7 @@
|
||||
"ui.render": "渲染",
|
||||
"ui.renderer": "渲染器",
|
||||
"ui.repeat": "重复",
|
||||
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
|
||||
"ui.report_a_bug": "报告缺陷",
|
||||
"ui.reset": "重置",
|
||||
"ui.reset_all": "全部重置",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! Built-in physical materials: densities in g/cm³ and sRGB colours.
|
||||
|
||||
/// The default material's name: what a body without a usable material of its own is made of.
|
||||
/// Assigning it clears a body's assignment.
|
||||
pub const DEFAULT_MATERIAL: &str = "Default";
|
||||
/// Density of the default material (g/cm³).
|
||||
pub const DEFAULT_DENSITY: f64 = 1.29;
|
||||
|
||||
/// Physical materials: name and density (g/cm³).
|
||||
pub const MATERIALS: [(&str, f64); 14] = [
|
||||
("Default", DEFAULT_DENSITY),
|
||||
(DEFAULT_MATERIAL, DEFAULT_DENSITY),
|
||||
("Steel", 7.85),
|
||||
("Stainless Steel", 8.0),
|
||||
("Aluminum", 2.70),
|
||||
|
||||
@@ -16,6 +16,8 @@ struct U {
|
||||
ana_c: array<vec4<f32>, 8>,
|
||||
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
|
||||
depth: vec4<f32>, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`)
|
||||
tint: vec4<f32>, // xyz added to a whole hovered body's colour before lighting
|
||||
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
|
||||
};
|
||||
|
||||
fn reversed() -> bool {
|
||||
@@ -253,7 +255,9 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) {
|
||||
// The cut face in its body's colour ("from component" colouring), tinted toward the
|
||||
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
|
||||
// cap colour so it reads as a cut, hatched at 45 degrees on screen; with the hatch off
|
||||
// (`u.cap.a` 0), plain in the body's own colour.
|
||||
if (u.cap.a < 0.5) { return out_color(vec4<f32>(i.c.rgb, 1.0)); }
|
||||
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
return out_color(vec4<f32>(select(base, base * 0.6, h < 0.22), 1.0));
|
||||
@@ -422,6 +426,20 @@ fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
|
||||
return shade(i);
|
||||
}
|
||||
|
||||
/// A whole hovered body, lit over the model from its own plan triangles in a lifted colour.
|
||||
@fragment
|
||||
fn fs_hl_hover(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return lit_color(i, 1.0, vec4<f32>(i.c.rgb + u.tint.rgb, i.c.a));
|
||||
}
|
||||
|
||||
/// A whole selected body, lit over the model from its own plan triangles in the selection colour.
|
||||
@fragment
|
||||
fn fs_hl_selected(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return lit_color(i, 1.0, vec4<f32>(u.sel.rgb, i.c.a));
|
||||
}
|
||||
|
||||
/// Reference planes: shaded like `fs_tri`, but never cut by the section plane.
|
||||
@fragment
|
||||
fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
|
||||
@@ -433,9 +451,15 @@ fn shade(i: TOut) -> vec4<f32> {
|
||||
}
|
||||
|
||||
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
|
||||
return lit_color(i, visibility, i.c);
|
||||
}
|
||||
|
||||
/// [`lit`] in a colour of its own: a whole highlighted body is lit in its colour lifted by the
|
||||
/// hover amount, or in the selection colour, from the model's own plan triangles.
|
||||
fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
|
||||
// A zero normal marks flat colour (selection fills, translucent planes).
|
||||
if (length(i.n) < 0.5) {
|
||||
return out_color(i.c);
|
||||
return out_color(base);
|
||||
}
|
||||
let v = normalize(u.back.xyz);
|
||||
var n = normalize(i.n);
|
||||
@@ -446,7 +470,7 @@ fn lit(i: TOut, visibility: f32) -> vec4<f32> {
|
||||
let h = normalize(key + v);
|
||||
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
|
||||
return out_color(vec4<f32>(min(base.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), base.a));
|
||||
}
|
||||
|
||||
// Lines, decals and ghosts are drawn at their own depth: opaque faces are pushed back by their
|
||||
|
||||
@@ -230,7 +230,7 @@ impl Tokens {
|
||||
],
|
||||
analysis: AnalysisColors::DEFAULT,
|
||||
icons: IconColors { highlight: Color32::WHITE, agent: Color32::from_rgb(168, 85, 247), marker_halo: Color32::from_black_alpha(150) },
|
||||
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE],
|
||||
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197)],
|
||||
}
|
||||
}
|
||||
pub const fn dark() -> Tokens {
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#ffad38",
|
||||
"#eda134",
|
||||
"#855a1d",
|
||||
"#ffad38"
|
||||
"#855a1d"
|
||||
],
|
||||
"manipulator": "#ffad38",
|
||||
"mesh_accent": "#d6922f",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#d8f4ff",
|
||||
"#8edcf5",
|
||||
"#4386ac",
|
||||
"#eef8ff"
|
||||
"#4386ac"
|
||||
],
|
||||
"manipulator": "#66ddff",
|
||||
"mesh_accent": "#78c6e8",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#cba6f7",
|
||||
"#477dc8",
|
||||
"#586382",
|
||||
"#cdd6f4"
|
||||
"#586382"
|
||||
],
|
||||
"manipulator": "#cba6f7",
|
||||
"mesh_accent": "#cba6f7",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#85b9ef",
|
||||
"#4096e9",
|
||||
"#2b71be",
|
||||
"#ffffff"
|
||||
"#2b71be"
|
||||
],
|
||||
"manipulator": "#007aff",
|
||||
"mesh_accent": "#927300",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#4aa3d8",
|
||||
"#2878a9",
|
||||
"#68717d",
|
||||
"#e0e2e5"
|
||||
"#68717d"
|
||||
],
|
||||
"manipulator": "#4aa3d8",
|
||||
"mesh_accent": "#4aa3d8",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#b3ffc4",
|
||||
"#79ed95",
|
||||
"#34854b",
|
||||
"#d8ffdf"
|
||||
"#34854b"
|
||||
],
|
||||
"manipulator": "#4dff79",
|
||||
"mesh_accent": "#59d67b",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#00bfff",
|
||||
"#0070d0",
|
||||
"#555555",
|
||||
"#ffffff"
|
||||
"#555555"
|
||||
],
|
||||
"manipulator": "#00bfff",
|
||||
"mesh_accent": "#00bfff",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#88c0d0",
|
||||
"#5e81ac",
|
||||
"#657991",
|
||||
"#eceff4"
|
||||
"#657991"
|
||||
],
|
||||
"manipulator": "#88c0d0",
|
||||
"mesh_accent": "#88c0d0",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#bd93f9",
|
||||
"#8456b8",
|
||||
"#656581",
|
||||
"#f8f8f2"
|
||||
"#656581"
|
||||
],
|
||||
"manipulator": "#bd93f9",
|
||||
"mesh_accent": "#bd93f9",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#61afef",
|
||||
"#56b6c2",
|
||||
"#c678dd",
|
||||
"#abb2bf"
|
||||
"#c678dd"
|
||||
],
|
||||
"manipulator": "#61afef",
|
||||
"mesh_accent": "#857100",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#ccccff",
|
||||
"#9999cc",
|
||||
"#666699",
|
||||
"#ffffff"
|
||||
"#666699"
|
||||
],
|
||||
"manipulator": "#555599",
|
||||
"mesh_accent": "#666699",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#268bd2",
|
||||
"#346e88",
|
||||
"#b5b6a0",
|
||||
"#586e75"
|
||||
"#b5b6a0"
|
||||
],
|
||||
"manipulator": "#268bd2",
|
||||
"mesh_accent": "#268bd2",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#ffffff",
|
||||
"#c0c0c0",
|
||||
"#808080",
|
||||
"#ffffff"
|
||||
"#808080"
|
||||
],
|
||||
"manipulator": "#000080",
|
||||
"mesh_accent": "#000080",
|
||||
|
||||
@@ -126,8 +126,7 @@
|
||||
"logo": [
|
||||
"#7aa2f7",
|
||||
"#4775ce",
|
||||
"#4f6089",
|
||||
"#c0caf5"
|
||||
"#4f6089"
|
||||
],
|
||||
"manipulator": "#7aa2f7",
|
||||
"mesh_accent": "#7aa2f7",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
use crate::{DEFAULT_MATERIAL, DocError, Document, Result};
|
||||
|
||||
#[path = "config_rules.rs"]
|
||||
mod rules;
|
||||
@@ -96,7 +96,7 @@ impl Document {
|
||||
/// returning None for the default material.
|
||||
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
|
||||
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then_some(name))
|
||||
Ok((name != DEFAULT_MATERIAL).then_some(name))
|
||||
}
|
||||
|
||||
/// Freeze design-unit-sensitive configuration literals when changing display units.
|
||||
@@ -140,7 +140,7 @@ impl Document {
|
||||
}
|
||||
}),
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
|
||||
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| DEFAULT_MATERIAL.into())),
|
||||
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
|
||||
Column::ComponentAppearance(id) => {
|
||||
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
|
||||
|
||||
@@ -2419,6 +2419,12 @@ pub struct Document {
|
||||
pub marker: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub next_id: u64,
|
||||
/// Body names the design used and must not use again: a name a `body.rename` freed (#4957).
|
||||
/// The design's own counter starts over at every evaluation, so without these a design that
|
||||
/// is evaluated again after a rename hands the freed name to the next body, and a script
|
||||
/// that names a body finds another one.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
|
||||
pub reserved_body_names: std::collections::BTreeSet<String>,
|
||||
/// Components below the root, as a tree (by parent).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub components: Vec<Component>,
|
||||
@@ -2665,6 +2671,7 @@ impl Document {
|
||||
features: Vec::new(),
|
||||
marker: None,
|
||||
next_id: 1,
|
||||
reserved_body_names: Default::default(),
|
||||
components: Vec::new(),
|
||||
occurrences: Vec::new(),
|
||||
assembly: Default::default(),
|
||||
@@ -2692,7 +2699,14 @@ 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
|
||||
/// 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).
|
||||
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() {
|
||||
self.reserved_body_names.insert(old.to_string());
|
||||
}
|
||||
if let Some(m) = self.materials.remove(old) {
|
||||
self.materials.insert(new.to_string(), m);
|
||||
}
|
||||
@@ -2842,9 +2856,21 @@ impl Document {
|
||||
st
|
||||
}
|
||||
|
||||
/// Density of a body's material (g/cm³).
|
||||
/// The material a body's mass is computed with, as the library or this design spells it:
|
||||
/// its assigned material when that is known and has a usable density, else
|
||||
/// [`DEFAULT_MATERIAL`]. The assignment as stored (`materials`) is left as it is.
|
||||
pub fn body_material(&self, body: &str) -> String {
|
||||
self.materials
|
||||
.get(body)
|
||||
.and_then(|m| self.material_name(m))
|
||||
.filter(|m| self.material_density(m).is_some())
|
||||
.unwrap_or_else(|| DEFAULT_MATERIAL.into())
|
||||
}
|
||||
|
||||
/// Density of a body's material (g/cm³): its [`Document::body_material`]'s, so the name a
|
||||
/// body reads and the mass it has never disagree.
|
||||
pub fn density(&self, body: &str) -> f64 {
|
||||
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
self.material_density(&self.body_material(body)).unwrap_or(DEFAULT_DENSITY)
|
||||
}
|
||||
|
||||
/// A material defined in this design, by name (case-insensitive).
|
||||
@@ -3452,6 +3478,24 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve circular Quantity expressions before solving. Rebuilding the copies here also
|
||||
/// covers cold document evaluation and configuration changes, without an engine dependency.
|
||||
pub fn apply_pattern_quantities(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
let counts: Vec<_> = sk
|
||||
.patterns
|
||||
.iter()
|
||||
.filter_map(|p| p.count_expression.as_ref().map(|e| (p.id.clone(), e)))
|
||||
.map(|(id, e)| {
|
||||
let n = crate::eval::val(vals, e, Kind::Unitless)?;
|
||||
Ok((id, solidcraft_sketch::SketchPattern::circular_quantity(n)?))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (id, count) in counts {
|
||||
sk.resize_circular_pattern(&id, count)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the points placed by parameters to where their expressions come to (in place). Such
|
||||
/// a point is fixed, so the solve keeps it there.
|
||||
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
@@ -3538,6 +3582,7 @@ impl Document {
|
||||
moved_from: Option<&Sketch>,
|
||||
analysis: solidcraft_sketch::Analysis,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.apply_pattern_quantities(vals, sk)?;
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
@@ -3558,10 +3603,10 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as the default.
|
||||
/// The built-in material library: (name, density), and each material's colour.
|
||||
#[path = "../../../assets/materials/library.rs"]
|
||||
mod materials;
|
||||
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
|
||||
pub use materials::{DEFAULT_DENSITY, DEFAULT_MATERIAL, MATERIALS, material_color};
|
||||
|
||||
/// A physical material defined in a design (`material.create`): its name, its density and,
|
||||
/// optionally, the look it gives bodies without an appearance of their own.
|
||||
|
||||
@@ -17,6 +17,8 @@ use crate::expr::{self, Kind, Value};
|
||||
use crate::lineage::Traced;
|
||||
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel, Result};
|
||||
|
||||
type JointFaces = (Body, kernel::Result<Arc<[kernel::FaceInfo]>>);
|
||||
|
||||
/// A body in the evaluated model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelBody {
|
||||
@@ -31,6 +33,9 @@ pub struct ModelBody {
|
||||
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
|
||||
// meshing any snapshot also warms every unchanged copy of this body.
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
/// Joint face measurements use their own tolerance, independent of display quality.
|
||||
/// Shared by snapshots of this geometry; the body identity guards direct replacement.
|
||||
joint_faces: Arc<OnceLock<JointFaces>>,
|
||||
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
|
||||
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
|
||||
/// The display plan (none when the body has no plan: a mesh body, or one the planner
|
||||
@@ -71,6 +76,7 @@ impl ModelBody {
|
||||
body,
|
||||
feature,
|
||||
mesh: Arc::new(OnceLock::new()),
|
||||
joint_faces: Arc::new(OnceLock::new()),
|
||||
mesh_placement: None,
|
||||
plan: Arc::new(Default::default()),
|
||||
plan_mesh: Arc::new(Default::default()),
|
||||
@@ -86,6 +92,19 @@ impl ModelBody {
|
||||
}
|
||||
}
|
||||
|
||||
/// Face measurements for joint origins, at their original chord tolerance. Pose changes
|
||||
/// and scratch copies share them; a geometry edit makes a new body and cache.
|
||||
pub(crate) fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
|
||||
let measure = || {
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.joint_faces += 1);
|
||||
let tol = (self.body.size() * 2e-3).max(1e-3);
|
||||
self.body.faces(tol).map(Arc::from)
|
||||
};
|
||||
let (body, faces) = self.joint_faces.get_or_init(|| (self.body.clone(), measure()));
|
||||
if body.same(&self.body) { faces.clone().map_err(Into::into) } else { measure().map_err(Into::into) }
|
||||
}
|
||||
|
||||
/// A box around the body without meshing it ([`Body::outline_bounds`]), worked out once.
|
||||
pub fn outline_bounds(&self) -> solidcraft_geom::Aabb3 {
|
||||
match self.outline.get_or_init(|| (self.body.clone(), self.body.outline_bounds())) {
|
||||
@@ -120,6 +139,9 @@ impl ModelBody {
|
||||
// finding their surface, picks naming their face, sketch planes), and a different
|
||||
// triangulation moved those and changed what designs build. A GPU viewport draws the
|
||||
// plan ([`ModelBody::display_plan`]).
|
||||
if let Some(mesh) = self.mesh.get() {
|
||||
return mesh.clone();
|
||||
}
|
||||
match self.mesh_placement.as_deref() {
|
||||
Some(placed) => self.mesh.get_or_init(|| placed.mesh()).clone(),
|
||||
None => placed_mesh::reference_mesh(&self.body, &self.mesh, self.display_tol()),
|
||||
@@ -694,6 +716,10 @@ pub struct ModelState {
|
||||
/// Shared between states: a feature that does not change a sketch keeps it as it was.
|
||||
pub sketches: Vec<Arc<SolvedSketch>>,
|
||||
pub body_counter: usize,
|
||||
/// Body names the design used and must not use again (a name a `body.rename` freed, #4957).
|
||||
/// The counter starts over at every evaluation, so without these a design that is evaluated
|
||||
/// again after a rename hands the freed name to the next body.
|
||||
pub reserved_body_names: std::collections::BTreeSet<String>,
|
||||
/// Sheet metal bodies (by body name) and their flat patterns.
|
||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||
/// Construction planes, axes and points as evaluated (by feature).
|
||||
@@ -729,6 +755,9 @@ impl ModelState {
|
||||
self.sketch_keys.get(&s.feature).hash(&mut h);
|
||||
}
|
||||
self.body_counter.hash(&mut h);
|
||||
for n in &self.reserved_body_names {
|
||||
n.hash(&mut h);
|
||||
}
|
||||
for (feature, points) in &self.blend_edges {
|
||||
feature.hash(&mut h);
|
||||
for p in points {
|
||||
@@ -994,6 +1023,11 @@ pub struct FeatureResult {
|
||||
pub stale: bool,
|
||||
#[serde(skip)]
|
||||
fingerprint: u64,
|
||||
/// Taken from a saved model that did not keep this feature's state (#3220): `state` only
|
||||
/// stands in for it until [`Model::evaluate`] evaluates the feature again, which
|
||||
/// [`Model::adopt`] does before the model is used.
|
||||
#[serde(skip)]
|
||||
unkept: bool,
|
||||
#[serde(skip)]
|
||||
pub state: Arc<ModelState>,
|
||||
}
|
||||
@@ -1739,7 +1773,8 @@ impl Model {
|
||||
let (vals, perr) = evaluation_values(doc);
|
||||
self.param_errors = perr;
|
||||
let mut prev_fp = 0u64;
|
||||
let mut state = self.empty.clone();
|
||||
// The names the design must not use again travel with the state (#4957).
|
||||
let mut state = std::sync::Arc::new(ModelState { reserved_body_names: doc.reserved_body_names.clone(), ..(*self.empty).clone() });
|
||||
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
||||
let mut reuse = !self.no_cache;
|
||||
let mut recomputed = 0;
|
||||
@@ -1762,11 +1797,13 @@ impl Model {
|
||||
let cancelled = watched.step(i);
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
|
||||
prev_fp = fp;
|
||||
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
|
||||
// A state the saved model did not keep is evaluated again from the one before it; the
|
||||
// results after it that the model did keep still stand (#3220).
|
||||
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
|
||||
if reuse
|
||||
&& let Some(old) = self.results.get(i)
|
||||
&& old.id == f.id
|
||||
&& old.fingerprint == fp
|
||||
&& !old.stale
|
||||
&& !unkept
|
||||
&& let Some(old) = self.results.get(i).filter(current)
|
||||
{
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
@@ -1774,7 +1811,7 @@ impl Model {
|
||||
out.push(old.clone());
|
||||
continue;
|
||||
}
|
||||
reuse = false;
|
||||
reuse = unkept && !cancelled;
|
||||
if f.suppressed || rolled_back || cancelled {
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
@@ -1786,6 +1823,7 @@ impl Model {
|
||||
skipped: true,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
unkept: false,
|
||||
state: state.clone(),
|
||||
});
|
||||
continue;
|
||||
@@ -1803,6 +1841,8 @@ impl Model {
|
||||
if c.error.is_none() {
|
||||
state = c.state.clone();
|
||||
}
|
||||
// A kept result after a failed one would not be this evaluation's.
|
||||
reuse &= c.error.is_none();
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -1813,6 +1853,7 @@ impl Model {
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
unkept: false,
|
||||
state: state.clone(),
|
||||
});
|
||||
continue;
|
||||
@@ -1823,12 +1864,16 @@ impl Model {
|
||||
if r.error.is_none() {
|
||||
state = r.state.clone();
|
||||
}
|
||||
out.push(FeatureResult { state: state.clone(), ..r.clone() });
|
||||
reuse &= r.error.is_none();
|
||||
out.push(FeatureResult { state: state.clone(), unkept: false, ..r.clone() });
|
||||
continue;
|
||||
}
|
||||
recomputed += 1;
|
||||
let t0 = now();
|
||||
let mut next = (*state).clone();
|
||||
// The names the design must not use again travel with the state (#4957): a state
|
||||
// taken from an earlier evaluation may have been made before a rename freed one.
|
||||
next.reserved_body_names = doc.reserved_body_names.clone();
|
||||
for b in &mut next.bodies {
|
||||
b.made = crate::lineage::Made::Before;
|
||||
}
|
||||
@@ -1952,6 +1997,7 @@ impl Model {
|
||||
m.put(key, e);
|
||||
}
|
||||
}
|
||||
reuse &= error.is_none();
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -1962,6 +2008,7 @@ impl Model {
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
unkept: false,
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
@@ -2006,6 +2053,8 @@ impl Model {
|
||||
b.name = new.to_string();
|
||||
b.names = b.names.renamed();
|
||||
}
|
||||
// The names the design must not use again travel with the state (#4957).
|
||||
st.reserved_body_names = doc.reserved_body_names.clone();
|
||||
Arc::new(st)
|
||||
})
|
||||
.clone();
|
||||
@@ -2121,8 +2170,10 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
|
||||
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
|
||||
}
|
||||
ProfileSel::Indices { indices } => {
|
||||
// A design file may list a profile more than once; it is still one profile.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut v = Vec::new();
|
||||
for i in indices {
|
||||
for i in indices.iter().filter(|i| seen.insert(**i)) {
|
||||
v.push(ps.get(*i).ok_or_else(|| DocError::Unknown(format!("profile {i} of {}", ss.name)))?.region.clone());
|
||||
}
|
||||
v
|
||||
@@ -2188,7 +2239,8 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
loop {
|
||||
state.body_counter += 1;
|
||||
let n = format!("Body{}", state.body_counter);
|
||||
if state.body(&n).is_none() {
|
||||
// A name a rename freed is not handed out again (#4957).
|
||||
if state.body(&n).is_none() && !state.reserved_body_names.contains(&n) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -2375,6 +2427,33 @@ fn clear_tools_as_one(tools: Vec<Traced>) -> Vec<Traced> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An intersect never deletes a body by accident (#5295): it is refused when nothing of a named
|
||||
/// target would remain, when nothing of any target would remain, or when the tools meet a target
|
||||
/// and the boolean still came out empty. Only an unnamed target the tools miss altogether is
|
||||
/// removed, as an intersect with every body does (#5160).
|
||||
pub(crate) fn empty_intersection(bodies: &[&str]) -> DocError {
|
||||
let body = match bodies {
|
||||
[one] => format!("`{one}`"),
|
||||
[first, rest @ ..] => format!("`{first}` and {} more", rest.len()),
|
||||
[] => "the target body".to_string(),
|
||||
};
|
||||
DocError::Invalid(format!("the intersection is empty: nothing of {body} would remain; check that the profile overlaps the body"))
|
||||
}
|
||||
|
||||
/// The bodies a feature's boolean takes part in, and whether they were named rather than taken
|
||||
/// by default (every body, or the feature's component).
|
||||
pub(crate) fn boolean_scope(state: &ModelState, f: &Feature, targets: &[String]) -> (bool, Vec<String>) {
|
||||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
|
||||
let names = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) })
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
(!all && !effective.is_empty(), names)
|
||||
}
|
||||
|
||||
/// [`apply_op`] with tools whose lineage is known: the bodies it makes or changes keep theirs.
|
||||
fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
let tools = if op == Operation::Intersect { intersection_tool_union(tools)? } else { tools };
|
||||
@@ -2382,6 +2461,7 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
// of its component (and what it made itself): other components are left alone.
|
||||
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
let named = !all && !targets.is_empty();
|
||||
let target_idx: Vec<usize> = if all {
|
||||
(0..state.bodies.len()).collect()
|
||||
} else if targets.is_empty() {
|
||||
@@ -2516,6 +2596,24 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
if op == Operation::Intersect && acc.as_ref().is_none_or(|b| b.body.face_count() == 0) {
|
||||
let Some(mb) = state.bodies.get(ti) else { continue };
|
||||
// A tool that meets the body yet leaves nothing is a failed boolean, not an empty result.
|
||||
let mut met = false;
|
||||
if !named && !mb.body.is_mesh() {
|
||||
for t in &tools {
|
||||
if mb.body.meets(&tool_in(t, into)?.body)? {
|
||||
met = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if named || met {
|
||||
return Err(empty_intersection(&[mb.name.as_str()]));
|
||||
}
|
||||
remove.push(ti);
|
||||
continue;
|
||||
}
|
||||
match acc {
|
||||
Some(b) => {
|
||||
if let Some(mb) = state.bodies.get_mut(ti) {
|
||||
@@ -2525,6 +2623,10 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
None => remove.push(ti),
|
||||
}
|
||||
}
|
||||
if op == Operation::Intersect && target_idx.iter().all(|i| remove.contains(i)) {
|
||||
let names: Vec<&str> = target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| b.name.as_str()).collect();
|
||||
return Err(empty_intersection(&names));
|
||||
}
|
||||
let mut i = 0;
|
||||
state.bodies.retain(|_| {
|
||||
let keep = !remove.contains(&i);
|
||||
@@ -5016,6 +5118,7 @@ fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
|
||||
FeatureKind::Pipe { operation, targets, .. } => (*operation, crate::plain(targets)),
|
||||
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, crate::plain(targets)),
|
||||
FeatureKind::Coil { operation, targets, .. } => (*operation, crate::plain(targets)),
|
||||
FeatureKind::Rib { .. } => (Operation::Join, Vec::new()),
|
||||
_ => (Operation::NewBody, Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -5375,6 +5478,16 @@ fn replay(
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let FeatureKind::Rib { .. } = &src.kind {
|
||||
// A rib is built again at each copy of its sketch, against the bodies there (#4593):
|
||||
// its walls run until they meet the body, which may not lie the same way there.
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
for body in more::rib_tools(vals, st, src, Some(m))? {
|
||||
// Each copy's faces are named as the rib's, with the instance.
|
||||
let t = Traced::unnamed(body);
|
||||
copies.push(Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body });
|
||||
}
|
||||
}
|
||||
} 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)?;
|
||||
|
||||
@@ -13,15 +13,16 @@ use solidcraft_sketch::Sketch;
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Work counted on this thread: bodies moved into a component's frame, STEP texts digested.
|
||||
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces measured.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Counts {
|
||||
pub moved: usize,
|
||||
pub digested: usize,
|
||||
pub joint_faces: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0 }) };
|
||||
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0 }) };
|
||||
}
|
||||
|
||||
pub(super) fn count(f: impl FnOnce(&mut Counts)) {
|
||||
@@ -39,6 +40,103 @@ fn counted(f: impl FnOnce()) -> Counts {
|
||||
COUNTS.with(Cell::get)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
|
||||
use crate::joints::{Joint, JointKind, JointOrigin, Snap, XRef};
|
||||
let mut doc = Document::new("joint face work");
|
||||
let part = doc.add_component(Some("Crank"), 0).unwrap();
|
||||
let occurrence = doc.occurrences.iter().find(|o| o.component == part).unwrap().id;
|
||||
let id = doc
|
||||
.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: point_expr(Vec3::ZERO),
|
||||
axis: Dir(Vec3::Z),
|
||||
radius: "2".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(id).unwrap().component = part;
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let pick = Vec3::new(0.0, 0.0, 10.0);
|
||||
let face = joints::face_name_at(&doc, &state, part, pick, false).unwrap();
|
||||
doc.assembly.joints.push(Joint {
|
||||
id: 100,
|
||||
name: "Pivot".into(),
|
||||
kind: JointKind::Revolute,
|
||||
a: JointOrigin { occurrence: 0, snap: Snap::Frame { origin: pick, z: Vec3::Z, x: Some(Vec3::X) } },
|
||||
b: JointOrigin { occurrence, snap: Snap::FaceCenter { pick, face: None, x: Some(XRef::Dir(Vec3::X)) } },
|
||||
values: vec![0.0],
|
||||
limits: Vec::new(),
|
||||
flip: true,
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
});
|
||||
// One body supplies both old nearest-face snaps and persistent named-face snaps.
|
||||
// A scratch model shares its measurements, even though each sample changes the pose.
|
||||
let snapshot = model.clone();
|
||||
let work = counted(|| {
|
||||
for name in [None, Some(face)] {
|
||||
doc.assembly.joints[0].b.snap = Snap::FaceCenter { pick, face: name, x: Some(XRef::Dir(Vec3::X)) };
|
||||
for k in 0..145 {
|
||||
let angle = (k as f64 * 2.5).to_radians();
|
||||
doc.assembly.joints[0].values[0] = angle;
|
||||
let solution = joints::solve(&doc, &snapshot.state());
|
||||
assert!(solution.errors.is_empty() && solution.conflicts.is_empty(), "{solution:?}");
|
||||
let point = apply_point(&solution.world[&occurrence], Vec3::new(5.0, 0.0, 10.0));
|
||||
assert!(point.dist(Vec3::new(5.0 * angle.cos(), 5.0 * angle.sin(), 10.0)) < 1e-9);
|
||||
doc.apply_joint_solution(&solution);
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(work.joint_faces, 1, "pose-only samples must not remeasure unchanged faces: {work:?}");
|
||||
|
||||
// Editing geometry creates a fresh measurement without changing a retained snapshot.
|
||||
let FeatureKind::Cylinder { height, .. } = &mut doc.feature_mut(id).unwrap().kind else { panic!("cylinder") };
|
||||
*height = "12".into();
|
||||
model.evaluate(&doc);
|
||||
let origin = &doc.assembly.joints[0].b;
|
||||
let work = counted(|| {
|
||||
let changed = joints::resolve_origin(&doc, &model.state(), origin).unwrap();
|
||||
let retained = joints::resolve_origin(&doc, &snapshot.state(), origin).unwrap();
|
||||
assert!((changed[3][2] - 12.0).abs() < 1e-9);
|
||||
assert!((retained[3][2] - 10.0).abs() < 1e-9);
|
||||
});
|
||||
assert_eq!(work.joint_faces, 1, "only the edited body must be measured: {work:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joint_face_measurements_never_follow_a_replaced_body_or_stale_offset() {
|
||||
use crate::joints::{JointOrigin, Snap};
|
||||
let mut doc = Document::new("replaced joint geometry");
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 10.0).unwrap(), 0));
|
||||
let origin = JointOrigin { occurrence: 0, snap: Snap::FaceCenter { pick: Vec3::new(0.0, 0.0, 10.0), face: None, x: None } };
|
||||
let work = counted(|| {
|
||||
joints::resolve_origin(&doc, &state, &origin).unwrap();
|
||||
});
|
||||
assert_eq!(work.joint_faces, 1);
|
||||
let retained = state.clone();
|
||||
// Body is public; a clone's old measurement must never mask a direct replacement.
|
||||
state.bodies[0].body = kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 12.0).unwrap();
|
||||
let changed = joints::resolve_origin(&doc, &state, &origin).unwrap();
|
||||
assert!((changed[3][2] - 12.0).abs() < 1e-9);
|
||||
let work = counted(|| {
|
||||
let old = joints::resolve_origin(&doc, &retained, &origin).unwrap();
|
||||
assert!((old[3][2] - 10.0).abs() < 1e-9);
|
||||
doc.body_offsets.insert("Pin".into(), rigid(Vec3::new(0.0, 0.0, 1.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
let moved = joints::resolve_origin(&doc, &retained, &origin).unwrap();
|
||||
assert!((moved[3][2] - 11.0).abs() < 1e-9);
|
||||
});
|
||||
assert_eq!(work.joint_faces, 0, "offsets are applied after the shared face measurements");
|
||||
}
|
||||
|
||||
/// `parts` imported files of two boxes each, every one in its own component placed away from
|
||||
/// the origin (as inserted parts are).
|
||||
fn placed_imports(parts: usize) -> Document {
|
||||
|
||||
@@ -69,27 +69,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } => {
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
if !(t > 0.0) {
|
||||
return Err(DocError::Invalid("the rib thickness must be positive".into()));
|
||||
}
|
||||
let depth = match depth {
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?)
|
||||
.filter(|d| *d > 0.0)
|
||||
.ok_or_else(|| DocError::Invalid("the rib depth must be positive".into()))
|
||||
.map(Some)?,
|
||||
None => None,
|
||||
};
|
||||
let groups: Vec<Vec<String>> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] };
|
||||
if groups.is_empty() || curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for g in &groups {
|
||||
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
|
||||
}
|
||||
FeatureKind::Rib { .. } => {
|
||||
let tools = rib_tools(vals, st, f, None)?;
|
||||
apply_op(st, f, tools, Operation::Join, &[])
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, body } => {
|
||||
@@ -176,6 +157,41 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
}
|
||||
|
||||
/// The walls of rib `f` (one per curve for a web), each run until it meets the bodies. `at`
|
||||
/// moves the rib's sketch first: a pattern's or mirror's copy of the rib is built again where
|
||||
/// the copy is, against the bodies there (#4593), as a hole is drilled again at each copy.
|
||||
pub(super) fn rib_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature, at: Option<&crate::Mat>) -> Result<Vec<Body>> {
|
||||
let FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } = &f.kind else {
|
||||
return Err(DocError::Invalid(format!("{} is not a rib", f.name)));
|
||||
};
|
||||
let mut ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
if let Some(m) = at {
|
||||
let p = ss.plane;
|
||||
ss.plane = Plane::new(apply_point(m, p.origin), crate::apply_vector(m, p.x), crate::apply_vector(m, p.y))
|
||||
.ok_or_else(|| DocError::Invalid("the rib's sketch plane at the copy".into()))?;
|
||||
}
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
if !(t > 0.0) {
|
||||
return Err(DocError::Invalid("the rib thickness must be positive".into()));
|
||||
}
|
||||
let depth = match depth {
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?)
|
||||
.filter(|d| *d > 0.0)
|
||||
.ok_or_else(|| DocError::Invalid("the rib depth must be positive".into()))
|
||||
.map(Some)?,
|
||||
None => None,
|
||||
};
|
||||
let groups: Vec<Vec<String>> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] };
|
||||
if groups.is_empty() || curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for g in &groups {
|
||||
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs
|
||||
/// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end
|
||||
/// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly.
|
||||
@@ -728,7 +744,9 @@ pub(super) fn sheet_eval(
|
||||
None => p.0,
|
||||
});
|
||||
}
|
||||
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, face.0, &picks)?;
|
||||
let placement = doc.component_transform(doc.model_body_component(&mb));
|
||||
let body_to_world = doc.model_body_offset(&mb).map(|offset| crate::mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, face.0, &picks, &body_to_world)?;
|
||||
for note in notes {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
@@ -1327,6 +1345,9 @@ fn sheet_prism_cut(
|
||||
} else {
|
||||
let material = sh.flat_material()?;
|
||||
if material.is_empty() {
|
||||
if operation == Operation::Intersect && super::boolean_scope(st, f, targets).0 {
|
||||
return Err(super::empty_intersection(&[sh.body.as_str()]));
|
||||
}
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
@@ -1338,6 +1359,14 @@ fn sheet_prism_cut(
|
||||
}
|
||||
}
|
||||
}
|
||||
if operation == Operation::Intersect {
|
||||
// Flat material is exact: an empty one is a true miss, removed only while some target remains.
|
||||
let (_, scope) = super::boolean_scope(st, f, targets);
|
||||
let emptied: Vec<&str> = plans.iter().filter(|(_, r)| r.is_none()).map(|(name, _)| name.as_str()).collect();
|
||||
if !emptied.is_empty() && scope.iter().all(|b| emptied.contains(&b.as_str())) {
|
||||
return Err(super::empty_intersection(&emptied));
|
||||
}
|
||||
}
|
||||
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
|
||||
if !notes.is_empty() {
|
||||
append_warning(warning, notes.join("; "));
|
||||
@@ -1714,6 +1743,21 @@ fn offset_left(p: &[Vec2], d: f64) -> Vec<Vec2> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The smallest size a plastic feature's lengths may have, in millimetres. Thinner walls and
|
||||
/// shorter arms than this are below what the solid booleans can resolve.
|
||||
const PLASTIC_MIN_SIZE: f64 = 0.01;
|
||||
|
||||
/// `v`, the feature's `what`, when it is at least [`PLASTIC_MIN_SIZE`].
|
||||
fn plastic_size(feature: &str, what: &str, v: f64) -> Result<f64> {
|
||||
if v.is_finite() && v >= PLASTIC_MIN_SIZE {
|
||||
Ok(v)
|
||||
} else {
|
||||
// Tiny values in exponent form: 1e-320 written out is hundreds of zeros.
|
||||
let got = if v != 0.0 && v.abs() < 1e-3 { format!("{v:e}") } else { format!("{v}") };
|
||||
Err(DocError::Invalid(format!("the {feature}'s {what} must be at least {PLASTIC_MIN_SIZE} mm (got {got} mm)")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
let len = |e: &str| val(vals, e, Kind::Length);
|
||||
// The plastic rule of the body a feature goes on gives its defaults (draft, clearance).
|
||||
@@ -1738,10 +1782,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
body,
|
||||
} => {
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let (r0, h) = (len(diameter)? / 2.0, len(height)?);
|
||||
if !(r0 > 0.0 && h > 0.0) {
|
||||
return Err(DocError::Invalid("the boss diameter and height must be positive".into()));
|
||||
}
|
||||
let r0 = plastic_size("boss", "diameter", len(diameter)?)? / 2.0;
|
||||
let h = plastic_size("boss", "height", len(height)?)?;
|
||||
let dr = match draft {
|
||||
Some(e) => val(vals, e, Kind::Angle)?,
|
||||
None => rule(&target)?.map_or(0.0, |r| r.draft),
|
||||
@@ -1784,8 +1826,10 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
None => 0.0,
|
||||
};
|
||||
let hr = h - off;
|
||||
if n > 0.0 && !(t > 0.0 && l > 0.0 && hr > 0.0) {
|
||||
return Err(DocError::Invalid("ribs need a positive thickness, length and height".into()));
|
||||
if n > 0.0 {
|
||||
plastic_size("boss", "rib_thickness", t)?;
|
||||
plastic_size("boss", "rib_length", l)?;
|
||||
plastic_size("boss", "rib height (height less rib_offset)", hr)?;
|
||||
}
|
||||
for k in 0..n as usize {
|
||||
let a = std::f64::consts::TAU * k as f64 / n;
|
||||
@@ -1816,9 +1860,9 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
}
|
||||
if let Some(hd) = hole_diameter {
|
||||
let rh = len(hd)? / 2.0;
|
||||
let rh = plastic_size("boss", "hole_diameter", len(hd)?)? / 2.0;
|
||||
let depth = match hole_depth {
|
||||
Some(e) => len(e)?,
|
||||
Some(e) => plastic_size("boss", "hole_depth", len(e)?)?,
|
||||
None => h,
|
||||
};
|
||||
if !(rh > 0.0 && rh < r_top && depth > 0.0) {
|
||||
@@ -1835,14 +1879,15 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let target = body_under(st, body, face.0)?;
|
||||
let mb = st.body(&target).cloned().ok_or_else(|| DocError::Invalid("body".into()))?;
|
||||
let n = planar_normal_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
|
||||
let (w, h) = (len(width)?, len(height)?);
|
||||
let what = if *groove { "groove" } else { "lip" };
|
||||
let (w, h) = (plastic_size(what, "width", len(width)?)?, plastic_size(what, "height", len(height)?)?);
|
||||
let g = match gap {
|
||||
Some(e) => len(e)?,
|
||||
None if *groove => rule(&target)?.map_or(0.0, |r| r.clearance),
|
||||
None => 0.0,
|
||||
};
|
||||
if !(w > 0.0 && h > 0.0 && g >= 0.0) {
|
||||
return Err(DocError::Invalid("the lip width and height must be positive".into()));
|
||||
if !(g >= 0.0) {
|
||||
return Err(DocError::Invalid("the groove's gap must not be negative".into()));
|
||||
}
|
||||
let plane = Plane::from_normal(face.0, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
@@ -1920,9 +1965,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let (l, t, w, cd, cl) = (len(length)?, len(thickness)?, len(width)?, len(catch_depth)?, len(catch_length)?);
|
||||
if !(l > 0.0 && t > 0.0 && w > 0.0 && cd > 0.0 && cl > 0.0 && cl < l) {
|
||||
return Err(DocError::Invalid("the snap fit's sizes must be positive and the catch shorter than the arm".into()));
|
||||
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)?);
|
||||
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);
|
||||
@@ -1943,15 +1990,16 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let (w, h) = (len(width)?, len(height)?);
|
||||
let l = length.as_deref().map(len).transpose()?;
|
||||
let size = |what: &str, e: &str| plastic_size("rest", what, len(e)?);
|
||||
let (w, h) = (size("width", width)?, size("height", height)?);
|
||||
let l = length.as_deref().map(|e| size("length", e)).transpose()?;
|
||||
let dr = match draft {
|
||||
Some(e) => val(vals, e, Kind::Angle)?,
|
||||
None => rule(&target)?.map_or(0.0, |r| r.draft),
|
||||
};
|
||||
let t = thickness.as_deref().map(len).transpose()?;
|
||||
if !(w > 0.0 && h > 0.0 && l.is_none_or(|l| l > 0.0) && t.is_none_or(|t| t > 0.0 && 2.0 * t < w.min(l.unwrap_or(w)))) {
|
||||
return Err(DocError::Invalid("the rest's sizes must be positive and its wall thinner than half its width".into()));
|
||||
let t = thickness.as_deref().map(|e| size("thickness", e)).transpose()?;
|
||||
if t.is_some_and(|t| 2.0 * t >= w.min(l.unwrap_or(w))) {
|
||||
return Err(DocError::Invalid("the rest's wall must be thinner than half its width".into()));
|
||||
}
|
||||
if !(dr.abs() < 1.0) {
|
||||
return Err(DocError::Invalid("the rest's draft must be under 57°".into()));
|
||||
|
||||
@@ -341,6 +341,12 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
let mut out = Vec::new();
|
||||
// Parentheses open at this point: a comma outside them can only be a decimal comma.
|
||||
let mut depth = 0usize;
|
||||
// A single leading `=` marks the text as a formula (`=d3`, #4681) and is not part of it.
|
||||
if let Some(k) = cs.iter().position(|(_, c)| !c.is_whitespace())
|
||||
&& cs.get(k).is_some_and(|(_, c)| *c == '=')
|
||||
{
|
||||
i = k + 1;
|
||||
}
|
||||
while let Some(&(_, c)) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
@@ -564,7 +570,9 @@ fn is_name_shaped(s: &str) -> bool {
|
||||
pub fn definition(s: &str) -> Option<(&str, &str)> {
|
||||
let (name, rhs) = s.split_once('=')?;
|
||||
let name = name.trim();
|
||||
is_name_shaped(name).then(|| (name, rhs.trim()))
|
||||
let rhs = rhs.trim();
|
||||
// `w == 3` is not a definition: a second `=` would read as a formula marker (#4681).
|
||||
(is_name_shaped(name) && !rhs.starts_with('=')).then_some((name, rhs))
|
||||
}
|
||||
|
||||
/// The length unit of a design with these units: `mm` unless they name another length unit.
|
||||
@@ -1252,8 +1260,6 @@ mod tests {
|
||||
("szerokość = 20", "szerokość", "20"),
|
||||
// Reserved names split too: the parameter check names the reason.
|
||||
("mm = 5", "mm", "5"),
|
||||
// A second `=` stays in the expression, which then fails to evaluate.
|
||||
("w == 3", "w", "= 3"),
|
||||
("w = a = 3", "w", "a = 3"),
|
||||
// An empty expression is the caller's error to report.
|
||||
("w =", "w", ""),
|
||||
@@ -1262,7 +1268,7 @@ mod tests {
|
||||
for (text, name, rhs) in splits {
|
||||
assert_eq!(definition(text), Some((name, rhs)), "{text:?}");
|
||||
}
|
||||
for text in ["20", "20 mm", "width / 2", "= 20", "=", "", "2w = 3", "a b = 3", "a-b = 3", "w.x = 3", "(w) = 3", "-w = 3"] {
|
||||
for text in ["20", "20 mm", "width / 2", "= 20", "=", "", "2w = 3", "a b = 3", "a-b = 3", "w.x = 3", "(w) = 3", "-w = 3", "w == 3"] {
|
||||
assert_eq!(definition(text), None, "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +578,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
validate_body_transfers(&v)?;
|
||||
FORMATS.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
check_typed_numbers(&d)?;
|
||||
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
|
||||
for (feature, wire) in legacy_wires {
|
||||
legacy_by_feature.entry(feature).or_default().push(wire);
|
||||
@@ -1046,6 +1047,16 @@ fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The range check again, on the typed design: an integer such as `9223372036854775808` passes
|
||||
/// [`check_numbers`] in the file (ids and counters are integers and may be large), but once it
|
||||
/// lands in a coordinate or size it is a float, and floats are written as floats. Integer fields
|
||||
/// (ids, counters, indexes) stay integers and are never limited, so the field's type decides
|
||||
/// what is checked, not its name.
|
||||
fn check_typed_numbers(d: &Document) -> Result<()> {
|
||||
let typed = serde_json::to_value(d).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
check_design_numbers(&typed)
|
||||
}
|
||||
|
||||
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
|
||||
/// file is refused instead of overflowing the geometry later).
|
||||
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
|
||||
@@ -2095,6 +2106,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An integer-valued coordinate is range-checked like a float one, while ids and counters
|
||||
/// may still be large integers (solidcraft#5021).
|
||||
#[test]
|
||||
fn integer_valued_coordinates_are_range_checked_and_large_ids_still_load() {
|
||||
let mut sketch = solidcraft_sketch::Sketch::new();
|
||||
sketch.add_point(solidcraft_geom::Vec2::new(40.0, 0.0), None).unwrap();
|
||||
let mut d = Document::new("range");
|
||||
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let last = |v: &Value| v["features"][0]["sketch"]["points"].as_array().map_or(0, |a| a.len() - 1);
|
||||
for huge in ["9223372036854775808", "4294967295", "2000000000", "-2000000000"] {
|
||||
let json = edited(&d, |v| {
|
||||
let i = last(v);
|
||||
v["features"][0]["sketch"]["points"][i]["pos"][1] = serde_json::from_str(huge).unwrap();
|
||||
});
|
||||
let error = read(&json).unwrap_err().to_string();
|
||||
assert!(error.contains("damaged design") && error.contains("out of range"), "{huge}: {error}");
|
||||
}
|
||||
// In range, an integer-valued coordinate is fine.
|
||||
let json = edited(&d, |v| {
|
||||
let i = last(v);
|
||||
v["features"][0]["sketch"]["points"][i]["pos"][1] = 1_000_000_000u64.into();
|
||||
});
|
||||
assert!(read(&json).is_ok());
|
||||
// Ids and counters are integers and may be past the coordinate limit.
|
||||
let json = edited(&d, |v| {
|
||||
v["features"][0]["id"] = 5_000_000_000u64.into();
|
||||
v["next_id"] = 5_000_000_001u64.into();
|
||||
});
|
||||
let back = read(&json).unwrap();
|
||||
assert_eq!(back.features[0].id, 5_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_id_space_is_an_error_not_a_panic() {
|
||||
let d = two_components_one_occurrence();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Associative motion traces retain their occurrence path, point reference, joint bounds or
|
||||
//! study identity, sample count and assembly branch seeds, alongside the generated sketch.
|
||||
//! Older designs default to no associations: their saved curves remain fixed. Readers from
|
||||
//! the last release refuse the newer format number instead of silently dropping associations.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The design saves the body names a `body.rename` freed (`Body1` after the body it named was
|
||||
//! renamed), so a name the design used is not used again.
|
||||
//!
|
||||
//! A design saved before this has no such names: it keeps the names it has, and the next body
|
||||
//! it makes may take a name an earlier rename freed.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Circular sketch patterns may retain a Quantity expression alongside their evaluated count.
|
||||
//! Older designs have no expression and keep their fixed counts unchanged. The previous
|
||||
//! release must request an update rather than discard the expression on save.
|
||||
@@ -572,6 +572,8 @@ pub struct Assembly {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub studies: Vec<crate::motion::MotionStudy>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub traces: Vec<crate::motion::MotionTrace>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exploded: Vec<crate::motion::ExplodedView>,
|
||||
}
|
||||
|
||||
@@ -597,6 +599,15 @@ impl Assembly {
|
||||
}
|
||||
}
|
||||
}
|
||||
for trace in &self.traces {
|
||||
if let crate::motion::TraceMotion::Joint { joint, from, to, .. } = &trace.motion {
|
||||
for (label, value) in [("from", from), ("to", to)] {
|
||||
if let serde_json::Value::String(e) = value {
|
||||
out.push((*joint, format!("trace {} {label}", trace.sketch), e.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -616,6 +627,15 @@ impl Assembly {
|
||||
}
|
||||
}
|
||||
}
|
||||
for trace in &mut self.traces {
|
||||
if let crate::motion::TraceMotion::Joint { from, to, .. } = &mut trace.motion {
|
||||
for value in [from, to] {
|
||||
if let serde_json::Value::String(e) = value {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -627,6 +647,7 @@ impl Assembly {
|
||||
&& !self.contact_enabled
|
||||
&& !self.contact_all
|
||||
&& self.studies.is_empty()
|
||||
&& self.traces.is_empty()
|
||||
&& self.exploded.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -731,8 +752,7 @@ fn nearest_plane(doc: &Document, st: &ModelState, o: &JointOrigin, pick: Vec3) -
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
for b in component_bodies(doc, st, comp) {
|
||||
let offset = doc.model_body_offset(b);
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
for f in b.body.faces(tol)? {
|
||||
for f in b.joint_faces()?.iter() {
|
||||
let Some(n) = f.plane_normal else { continue };
|
||||
let (c, n) = match offset {
|
||||
Some(m) => (apply_point(m, f.centroid), apply_vector(m, n)),
|
||||
@@ -836,6 +856,15 @@ pub fn clamp(j: &Joint, q: &mut [f64]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Work requested after placing occurrences and checking their joint constraints.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Analysis {
|
||||
/// Placement, loop closure, conflicts and errors only; `Solution::dof` is empty.
|
||||
Placement,
|
||||
/// Also count each occurrence's remaining motion, including nearby singular poses.
|
||||
DegreesOfFreedom,
|
||||
}
|
||||
|
||||
/// The result of placing occurrences by their joints.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Solution {
|
||||
@@ -852,7 +881,7 @@ pub struct Solution {
|
||||
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed): how many independent
|
||||
/// values still move it, from the fixed occurrences, with loops kept closed and a value a
|
||||
/// motion link sets moving with the values its link chain starts from (a driven gear has its
|
||||
/// driving gear's one, a screw's travel its turn's).
|
||||
/// driving gear's one, a screw's travel its turn's). Empty for [`Analysis::Placement`].
|
||||
pub dof: BTreeMap<u64, usize>,
|
||||
/// Occurrences fixed in place (grounded, or the root's).
|
||||
pub fixed: Vec<u64>,
|
||||
@@ -943,22 +972,36 @@ fn flip_of(j: &Joint) -> Mat {
|
||||
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
|
||||
}
|
||||
|
||||
/// Component-space frames stay fixed throughout one solve. Cache their inverses without
|
||||
/// regrouping products: reversed joints still invert the value-dependent link as before.
|
||||
struct JointFrames {
|
||||
a: Mat,
|
||||
b: Mat,
|
||||
inverse_a: Option<Mat>,
|
||||
inverse_b: Option<Mat>,
|
||||
}
|
||||
|
||||
impl JointFrames {
|
||||
fn new(a: Mat, b: Mat) -> Self {
|
||||
Self { a, b, inverse_a: mat_inverse(&a), inverse_b: mat_inverse(&b) }
|
||||
}
|
||||
|
||||
fn through(&self, joint: &Joint, values: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
|
||||
let link = mat_mul(&link_of(joint, values), self.inverse_b.as_ref()?);
|
||||
if from_a { Some(mat_mul(&mat_mul(t, &self.a), &link)) } else { Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), self.inverse_a.as_ref()?)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// B's world transform from A's (or A's from B's, going the other way) for the joint's values.
|
||||
fn through(j: &Joint, (fa, fb): &(Mat, Mat), q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
|
||||
let link = mat_mul(&link_of(j, q), &mat_inverse(fb)?);
|
||||
if from_a {
|
||||
Some(mat_mul(&mat_mul(t, fa), &link))
|
||||
} else {
|
||||
// T_A = T_B · link⁻¹ · F_a⁻¹
|
||||
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
|
||||
}
|
||||
JointFrames::new(*fa, *fb).through(j, q, from_a, t)
|
||||
}
|
||||
|
||||
/// The spanning tree the solver places occurrences along, out from the fixed ones.
|
||||
struct Tree<'a> {
|
||||
joints: BTreeMap<u64, &'a Joint>,
|
||||
/// Joint frames in component coordinates (A's, B's).
|
||||
frames: BTreeMap<u64, (Mat, Mat)>,
|
||||
frames: BTreeMap<u64, JointFrames>,
|
||||
fixed: BTreeMap<u64, Mat>,
|
||||
/// Placed occurrence → (the occurrence it is placed from, the joint, from the joint's A side).
|
||||
parent: BTreeMap<u64, (u64, u64, bool)>,
|
||||
@@ -968,8 +1011,10 @@ struct Tree<'a> {
|
||||
|
||||
impl Tree<'_> {
|
||||
fn step(&self, joint: u64, from_a: bool, t: &Mat, vals: &Values) -> Option<Mat> {
|
||||
#[cfg(test)]
|
||||
tests::PLACEMENT_STEPS.with(|steps| steps.set(steps.get() + 1));
|
||||
let q = vals.get(&joint).map(Vec::as_slice).unwrap_or(&[]);
|
||||
through(self.joints.get(&joint)?, self.frames.get(&joint)?, q, from_a, t)
|
||||
self.frames.get(&joint)?.through(self.joints.get(&joint)?, q, from_a, t)
|
||||
}
|
||||
|
||||
/// Where every placed occurrence is for these values.
|
||||
@@ -1008,6 +1053,75 @@ impl Tree<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage shared by one residual callback's evaluations, scoped to its immutable tree.
|
||||
/// Keep map nodes and path capacity, but invalidate every placement before reading new values.
|
||||
struct PlacementWorkspace<'tree, 'doc> {
|
||||
tree: &'tree Tree<'doc>,
|
||||
placed: BTreeMap<u64, Option<Mat>>,
|
||||
path: Vec<(u64, u64, bool)>,
|
||||
}
|
||||
|
||||
impl<'tree, 'doc> PlacementWorkspace<'tree, 'doc> {
|
||||
fn new(tree: &'tree Tree<'doc>) -> Self {
|
||||
Self { tree, placed: BTreeMap::new(), path: Vec::new() }
|
||||
}
|
||||
|
||||
fn at<'values>(&'values mut self, values: &'values Values) -> Placements<'values, 'tree, 'doc> {
|
||||
for placed in self.placed.values_mut() {
|
||||
*placed = None;
|
||||
}
|
||||
Placements { workspace: self, values }
|
||||
}
|
||||
}
|
||||
|
||||
/// Only this guard can read placements. Its values stay immutable until it is dropped;
|
||||
/// obtaining the next guard clears validity while retaining the allocated storage.
|
||||
struct Placements<'values, 'tree, 'doc> {
|
||||
workspace: &'values mut PlacementWorkspace<'tree, 'doc>,
|
||||
values: &'values Values,
|
||||
}
|
||||
|
||||
impl Placements<'_, '_, '_> {
|
||||
fn world(&mut self, mut occurrence: u64) -> Option<Mat> {
|
||||
self.workspace.path.clear();
|
||||
let mut t = loop {
|
||||
if let Some(t) = self.workspace.placed.get(&occurrence).and_then(Option::as_ref).or_else(|| self.workspace.tree.fixed.get(&occurrence)) {
|
||||
break *t;
|
||||
}
|
||||
if self.workspace.path.len() >= self.workspace.tree.order.len() {
|
||||
return None;
|
||||
}
|
||||
let &(from, joint, from_a) = self.workspace.tree.parent.get(&occurrence)?;
|
||||
self.workspace.path.push((occurrence, joint, from_a));
|
||||
occurrence = from;
|
||||
};
|
||||
while let Some((occurrence, joint, from_a)) = self.workspace.path.pop() {
|
||||
t = self.workspace.tree.step(joint, from_a, &t, self.values)?;
|
||||
#[cfg(test)]
|
||||
if !self.workspace.placed.contains_key(&occurrence) {
|
||||
tests::PLACEMENT_INSERTS.with(|inserts| inserts.set(inserts.get() + 1));
|
||||
}
|
||||
self.workspace.placed.insert(occurrence, Some(t));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
|
||||
fn closure_gaps(&mut self, closing: &[u64], scale: f64, out: &mut Vec<f64>) -> Option<()> {
|
||||
for id in closing {
|
||||
let (joint, frames) = (self.workspace.tree.joints.get(id)?, self.workspace.tree.frames.get(id)?);
|
||||
let tb = self.world(joint.b.occurrence)?;
|
||||
let ta = self.world(joint.a.occurrence)?;
|
||||
let want = self.workspace.tree.step(*id, true, &ta, self.values)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let at = apply_point(&frames.b, p);
|
||||
let d = apply_point(&want, at) - apply_point(&tb, at);
|
||||
out.extend([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Values the loop solver moves at most in one group of loops, and loop-closing joints it takes
|
||||
/// on at most: larger ones are only checked.
|
||||
const MAX_LOOP_VALUES: usize = 64;
|
||||
@@ -1212,8 +1326,8 @@ fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
|
||||
let joints: Aabb3 = around
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, vals)?, fa), Vec3::ZERO))
|
||||
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, vals)?, &frames.a), Vec3::ZERO))
|
||||
})
|
||||
.collect();
|
||||
if joints.is_empty() { 1.0 } else { joints.diagonal().clamp(1.0, 1e6) }
|
||||
@@ -1221,18 +1335,8 @@ fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
|
||||
|
||||
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
|
||||
/// where the joint wants them against where the tree puts them (appended to `out`).
|
||||
fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
|
||||
for id in closing {
|
||||
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
let tb = tree.world(j.b.occurrence, vals)?;
|
||||
let want = tree.step(*id, true, &tree.world(j.a.occurrence, vals)?, vals)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let at = apply_point(fb, p);
|
||||
let d = apply_point(&want, at) - apply_point(&tb, at);
|
||||
out.extend([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
fn closure_gaps(workspace: &mut PlacementWorkspace, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
|
||||
workspace.at(vals).closure_gaps(closing, scale, out)
|
||||
}
|
||||
|
||||
/// How far a closing joint may be off before it is a conflict: its frames apart by more than
|
||||
@@ -1294,6 +1398,7 @@ fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<
|
||||
(v + d * if k % 2 == 0 { w } else { -w }).max(*lo).min(*hi)
|
||||
})
|
||||
.collect();
|
||||
let mut workspace = PlacementWorkspace::new(tree);
|
||||
let mut work = near.clone();
|
||||
let mut gaps = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
spent += 1;
|
||||
@@ -1307,7 +1412,7 @@ fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<
|
||||
}
|
||||
apply_links(asm, &mut work);
|
||||
let mut out = Vec::with_capacity(closing.len() * 9);
|
||||
closure_gaps(tree, closing, &work, scale, &mut out)?;
|
||||
closure_gaps(&mut workspace, closing, &work, scale, &mut out)?;
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
if close_within(&mut gaps, &mut x, &bounds, (1e-10 * scale).min(1e-7), loose_gap(scale)).is_some()
|
||||
@@ -1422,6 +1527,7 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
|
||||
let (n, gaps) = (values.len(), closers.len() * 9);
|
||||
// How many ways the occurrence moves at a pose, with the loops kept closed.
|
||||
let mut free_at = |at: &Values| -> Option<usize> {
|
||||
let mut workspace = PlacementWorkspace::new(tree);
|
||||
let mut work = at.clone();
|
||||
// The loops' gaps, then three points of the occurrence `scale` apart.
|
||||
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
@@ -1433,8 +1539,9 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
|
||||
}
|
||||
apply_links(asm, &mut work);
|
||||
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
|
||||
closure_gaps(tree, &closers, &work, scale, &mut out)?;
|
||||
let t = tree.world(*occ, &work)?;
|
||||
let mut placed = workspace.at(&work);
|
||||
placed.closure_gaps(&closers, scale, &mut out)?;
|
||||
let t = placed.world(*occ)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let q = apply_point(&t, p);
|
||||
out.extend([q.x, q.y, q.z]);
|
||||
@@ -1530,6 +1637,7 @@ fn close_group(
|
||||
if !moves.is_empty() {
|
||||
first = first.min(1.0 / stride(&effective(&x0, 0.0), &effective(&x0, 1.0)));
|
||||
}
|
||||
let mut workspace = PlacementWorkspace::new(tree);
|
||||
let mut work = vals.clone();
|
||||
// How far each closing joint is from meeting with the held values `t` of the way along:
|
||||
// three points of B's joint frame, where the joint wants them against where the tree puts them.
|
||||
@@ -1540,7 +1648,7 @@ fn close_group(
|
||||
}
|
||||
place(&mut work, x, t);
|
||||
let mut out = Vec::with_capacity(closing.len() * 9);
|
||||
closure_gaps(tree, closing, &work, scale, &mut out)?;
|
||||
closure_gaps(&mut workspace, closing, &work, scale, &mut out)?;
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
// Already closed, with no held value moving. (A moving one is followed all the way: a
|
||||
@@ -1690,6 +1798,13 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
/// of the other joints around the loop. The `held` values (the ones being driven) keep theirs,
|
||||
/// moving there from the values given (where the loops last closed) so the others follow them.
|
||||
pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
solve_holding_with_analysis(doc, st, held, Analysis::DegreesOfFreedom)
|
||||
}
|
||||
|
||||
/// Place occurrences and check constraints with the requested diagnostic work. Placement-only
|
||||
/// solves still close and check every loop, respect held values and limits, and report errors;
|
||||
/// they leave `Solution::dof` empty rather than returning the spanning tree's approximate count.
|
||||
pub fn solve_holding_with_analysis(doc: &Document, st: &ModelState, held: &Held, analysis: Analysis) -> Solution {
|
||||
let asm = &doc.assembly;
|
||||
let mut sol = Solution::default();
|
||||
let mut vals = joint_values(asm);
|
||||
@@ -1704,9 +1819,9 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
for id in &closing {
|
||||
let Some(j) = tree.joints.get(id) else { continue };
|
||||
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
|
||||
let (Some(want), Some((_, fb))) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
|
||||
let (Some(want), Some(frames)) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
|
||||
// Measured at the joint, where the loop solver closes it.
|
||||
let (dt, da) = mismatch(&mat_mul(&want, fb), &mat_mul(tb, fb));
|
||||
let (dt, da) = mismatch(&mat_mul(&want, &frames.b), &mat_mul(tb, &frames.b));
|
||||
if dt > CONFLICT_MM || da > CONFLICT_RAD {
|
||||
sol.conflicts.push((
|
||||
j.id,
|
||||
@@ -1719,11 +1834,15 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
|
||||
}
|
||||
}
|
||||
if !closing.is_empty() {
|
||||
loop_dof(&tree, &closing, asm, &vals, &mut dof);
|
||||
}
|
||||
for o in &doc.occurrences {
|
||||
dof.entry(o.id).or_insert(6);
|
||||
if analysis == Analysis::DegreesOfFreedom {
|
||||
if !closing.is_empty() {
|
||||
loop_dof(&tree, &closing, asm, &vals, &mut dof);
|
||||
}
|
||||
for o in &doc.occurrences {
|
||||
dof.entry(o.id).or_insert(6);
|
||||
}
|
||||
} else {
|
||||
dof.clear();
|
||||
}
|
||||
placed.remove(&0);
|
||||
sol.world = placed;
|
||||
@@ -1749,7 +1868,7 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
|
||||
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
|
||||
// Joint frames in component coordinates (errors leave the joint out). An origin several joints
|
||||
// share (a panel face every part sits on) is found once.
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
let mut frames: BTreeMap<u64, JointFrames> = BTreeMap::new();
|
||||
let mut found: BTreeMap<(u64, String), std::result::Result<Mat, String>> = BTreeMap::new();
|
||||
let mut resolve = |o: &JointOrigin| -> Result<Mat> {
|
||||
let Ok(comp) = component_of(doc, o.occurrence) else { return resolve_origin(doc, st, o) };
|
||||
@@ -1770,7 +1889,7 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
|
||||
}
|
||||
match (resolve(&j.a), resolve(&j.b)) {
|
||||
(Ok(fa), Ok(fb)) => {
|
||||
frames.insert(j.id, (fa, fb));
|
||||
frames.insert(j.id, JointFrames::new(fa, fb));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => sol.errors.push((j.id, j.name.clone(), e.to_string())),
|
||||
}
|
||||
@@ -1953,6 +2072,138 @@ pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
thread_local! {
|
||||
pub(super) static PLACEMENT_STEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
pub(super) static PLACEMENT_INSERTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub(super) fn placement_tree(asm: &Assembly) -> Tree<'_> {
|
||||
Tree {
|
||||
joints: asm.joints.iter().map(|j| (j.id, j)).collect(),
|
||||
frames: asm.joints.iter().map(|j| (j.id, JointFrames::new(mat_mul(&shift(3.0, 4.0, 5.0), &rot_y(0.3)), rot_x(-0.2)))).collect(),
|
||||
fixed: BTreeMap::from([(0, IDENTITY)]),
|
||||
parent: BTreeMap::from([(1, (0, 1, true)), (2, (1, 2, false)), (3, (2, 3, true)), (4, (1, 4, true)), (99, (0, 5, true))]),
|
||||
order: vec![1, 2, 3, 4, 99],
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared ancestors are evaluated once, without visiting an unrelated assembly branch.
|
||||
/// The same arithmetic must be used after values change and on a reversed tree edge.
|
||||
#[test]
|
||||
fn residual_placements_reuse_ancestors_only_for_the_current_values() {
|
||||
let asm = chain(6, 0.2);
|
||||
let tree = placement_tree(&asm);
|
||||
let mut values = joint_values(&asm);
|
||||
let expected: BTreeMap<u64, Mat> = [1, 2, 3, 4].into_iter().map(|id| (id, tree.world(id, &values).unwrap())).collect();
|
||||
let mut workspace = PlacementWorkspace::new(&tree);
|
||||
PLACEMENT_STEPS.with(|steps| steps.set(0));
|
||||
{
|
||||
let mut placed = workspace.at(&values);
|
||||
for id in [3, 4, 3, 2, 4] {
|
||||
assert_eq!(placed.world(id), expected.get(&id).copied());
|
||||
}
|
||||
assert_eq!(placed.world(0), Some(IDENTITY));
|
||||
assert_eq!(placed.world(1234), None);
|
||||
}
|
||||
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 4, "one step per needed occurrence; no unrelated branch");
|
||||
values.get_mut(&1).unwrap()[0] += 0.1;
|
||||
apply_links(&asm, &mut values);
|
||||
let changed = tree.world(3, &values).unwrap();
|
||||
assert_ne!(Some(&changed), expected.get(&3));
|
||||
let mut placed = workspace.at(&values);
|
||||
assert_eq!(placed.world(3), Some(changed), "a new residual evaluation cannot reuse the previous values' placements");
|
||||
}
|
||||
|
||||
/// Cached static inverses retain the original multiplication order for every joint kind,
|
||||
/// flip and traversal direction. A non-invertible frame remains a failed step.
|
||||
#[test]
|
||||
fn prepared_joint_frames_match_fresh_inverses() {
|
||||
fn reference(j: &Joint, fa: &Mat, fb: &Mat, q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
|
||||
let link = mat_mul(&link_of(j, q), &mat_inverse(fb)?);
|
||||
if from_a { Some(mat_mul(&mat_mul(t, fa), &link)) } else { Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?)) }
|
||||
}
|
||||
let mut joint = chain(1, 0.0).joints.remove(0);
|
||||
let fa = mat_mul(&shift(3.0, -7.0, 11.0), &rot_y(0.3));
|
||||
let fb = mat_mul(&shift(-2.0, 5.0, 13.0), &rot_x(-0.2));
|
||||
let t = mat_mul(&shift(17.0, 19.0, -23.0), &rot_z(0.7));
|
||||
let singular = [[0.0; 4]; 4];
|
||||
for (a, b) in [(fa, fb), (singular, fb), (fa, singular)] {
|
||||
let frames = JointFrames::new(a, b);
|
||||
for kind in [
|
||||
JointKind::Rigid,
|
||||
JointKind::Revolute,
|
||||
JointKind::Slider,
|
||||
JointKind::Cylindrical,
|
||||
JointKind::PinSlot,
|
||||
JointKind::Planar,
|
||||
JointKind::Ball,
|
||||
JointKind::Parallel,
|
||||
JointKind::Perpendicular,
|
||||
JointKind::Angle,
|
||||
JointKind::PointOnPlane,
|
||||
JointKind::PointOnLine,
|
||||
JointKind::LineOnPlane,
|
||||
] {
|
||||
joint.kind = kind;
|
||||
joint.offset = 2.7;
|
||||
joint.angle = -0.41;
|
||||
for flip in [false, true] {
|
||||
joint.flip = flip;
|
||||
for angle in [0.0, 1e-6, -0.37, std::f64::consts::PI] {
|
||||
let q: Vec<f64> = (0..kind.dofs().len()).map(|i| angle * (i + 1) as f64).collect();
|
||||
for forward in [false, true] {
|
||||
assert_eq!(
|
||||
frames.through(&joint, &q, forward, &t),
|
||||
reference(&joint, &a, &b, &q, forward, &t),
|
||||
"{kind:?}, {flip}, {angle}, {forward}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The loop solver's residual entry point reuses shared ancestors for each perturbation;
|
||||
/// row order, duplicate rows, append semantics and missing-origin failure stay unchanged.
|
||||
#[test]
|
||||
fn closure_residuals_reuse_shared_ancestors_for_each_perturbation() {
|
||||
let mut asm = chain(6, 0.2);
|
||||
asm.joints[5].a.occurrence = 3;
|
||||
asm.joints[5].b.occurrence = 4;
|
||||
let tree = placement_tree(&asm);
|
||||
let mut workspace = PlacementWorkspace::new(&tree);
|
||||
let mut cold = true;
|
||||
for start in [0.2, 0.2 + 1e-6, 0.2 - 1e-6, 1.3] {
|
||||
let mut values = joint_values(&asm);
|
||||
values.get_mut(&1).unwrap()[0] = start;
|
||||
apply_links(&asm, &mut values);
|
||||
let mut expected = vec![42.0];
|
||||
for id in [6, 6] {
|
||||
let joint = tree.joints.get(&id).unwrap();
|
||||
let frames = tree.frames.get(&id).unwrap();
|
||||
let a = tree.world(joint.a.occurrence, &values).unwrap();
|
||||
let b = tree.world(joint.b.occurrence, &values).unwrap();
|
||||
let want = through(joint, &(frames.a, frames.b), values.get(&id).unwrap(), true, &a).unwrap();
|
||||
for point in [Vec3::ZERO, Vec3::X * 37.0, Vec3::Y * 37.0] {
|
||||
let at = apply_point(&frames.b, point);
|
||||
let d = apply_point(&want, at) - apply_point(&b, at);
|
||||
expected.extend([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
PLACEMENT_STEPS.with(|steps| steps.set(0));
|
||||
PLACEMENT_INSERTS.with(|inserts| inserts.set(0));
|
||||
let mut actual = vec![42.0];
|
||||
assert_eq!(closure_gaps(&mut workspace, &[6, 6], &values, 37.0, &mut actual), Some(()));
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(PLACEMENT_INSERTS.with(|inserts| inserts.get()), if cold { 4 } else { 0 }, "reuse cache storage between perturbations");
|
||||
cold = false;
|
||||
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 6, "four tree placements and two closing-joint steps");
|
||||
assert_eq!(closure_gaps(&mut workspace, &[u64::MAX], &values, 37.0, &mut actual), None);
|
||||
assert_eq!(actual, expected, "an unresolved joint does not insert a zero residual");
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: &Mat, b: &Mat) -> bool {
|
||||
let (t, r) = mismatch(a, b);
|
||||
t < 1e-9 && r < 1e-9
|
||||
@@ -1960,7 +2211,7 @@ mod tests {
|
||||
|
||||
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
|
||||
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
|
||||
fn chain(n: u64, start: f64) -> Assembly {
|
||||
pub(super) fn chain(n: u64, start: f64) -> Assembly {
|
||||
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None, x: None } };
|
||||
let joints = (1..=n)
|
||||
.map(|id| Joint {
|
||||
|
||||
@@ -64,8 +64,7 @@ impl NamedFace<'_> {
|
||||
|
||||
/// The face's centre and outward normal (the component's coordinates), when it is planar.
|
||||
pub fn plane(&self) -> Result<Option<(Vec3, Vec3)>> {
|
||||
let tol = (self.body.body.size() * 2e-3).max(1e-3);
|
||||
let faces = self.body.body.faces(tol)?;
|
||||
let faces = self.body.joint_faces()?;
|
||||
Ok(faces.get(self.face).and_then(|f| f.plane_normal.map(|n| (apply_point(&self.offset, f.centroid), apply_vector(&self.offset, n)))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use solidcraft_geom::Vec3;
|
||||
use solidcraft_sketch::linalg;
|
||||
|
||||
use super::{
|
||||
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, closure_gaps, jacobian,
|
||||
joint_values, largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
|
||||
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,
|
||||
};
|
||||
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
|
||||
|
||||
@@ -133,8 +133,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
|
||||
let lever = path
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, &vals)?, fa), Vec3::ZERO).dist(point?))
|
||||
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, &vals)?, &frames.a), Vec3::ZERO).dist(point?))
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
let span = point.map_or(scale, |p| scale.max(p.dist(r.to)).max(lever));
|
||||
@@ -155,7 +155,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
|
||||
let mt = if r.axis.is_some() { 6 } else { 3 };
|
||||
let spent = Cell::new(0usize);
|
||||
let mut work = vals.clone();
|
||||
let mut f = |x: &[f64]| misses(tree, asm, r, scale, &vars, &closers, x, &mut work, &spent);
|
||||
let mut workspace = super::PlacementWorkspace::new(tree);
|
||||
let mut f = |x: &[f64]| misses(&mut workspace, asm, r, scale, &vars, &closers, x, &mut work, &spent);
|
||||
let mut x = x0.clone();
|
||||
let miss = |res: &[f64]| res.iter().take(mt).map(|v| v * v).sum::<f64>().sqrt();
|
||||
let before = f(&x0).map_or(f64::INFINITY, |r| miss(&r));
|
||||
@@ -216,7 +217,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
|
||||
if let Some(first) = closers.first() {
|
||||
let k = drive.len();
|
||||
let mut alone = vals.clone();
|
||||
let mut g = |x: &[f64]| misses(tree, asm, r, scale, &drive, &[], x, &mut alone, &spent);
|
||||
let mut alone_workspace = super::PlacementWorkspace::new(tree);
|
||||
let mut g = |x: &[f64]| misses(&mut alone_workspace, asm, r, scale, &drive, &[], x, &mut alone, &spent);
|
||||
let (mut xo, bo, co) = (x0.get(..k).unwrap_or_default().to_vec(), bounds.get(..k).unwrap_or_default(), cap.get(..k).unwrap_or_default());
|
||||
// The loops left open, nothing to pace.
|
||||
if let Some(ro) = approach(&mut g, &|_, _| 0.0, mt, &mut xo, bo, co, tol, loose_gap(scale))
|
||||
@@ -249,7 +251,7 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
|
||||
/// The target's misses with the values `vars` at `x`: the point's (mm), then the axis's (as mm
|
||||
/// across the chain's `scale`), then the gaps of the loops `closers`, weighted.
|
||||
fn misses(
|
||||
tree: &Tree,
|
||||
workspace: &mut super::PlacementWorkspace,
|
||||
asm: &Assembly,
|
||||
r: &Reach,
|
||||
scale: f64,
|
||||
@@ -269,9 +271,18 @@ fn misses(
|
||||
}
|
||||
}
|
||||
apply_links(asm, work);
|
||||
let mut out = target(tree, r, scale, work)?;
|
||||
let n = out.len();
|
||||
closure_gaps(tree, closers, work, scale, &mut out)?;
|
||||
let tree = workspace.tree;
|
||||
// An open chain has only one target placement to read. Loops share their ancestors with
|
||||
// that target, but a single placement needs no residual-local map.
|
||||
let mut out = if closers.is_empty() {
|
||||
target(tree, r, scale, work)?
|
||||
} else {
|
||||
let mut placed = workspace.at(work);
|
||||
let mut out = target_at(&placed.world(r.occurrence)?, r, scale);
|
||||
placed.closure_gaps(closers, scale, &mut out)?;
|
||||
out
|
||||
};
|
||||
let n = if r.axis.is_some() { 6 } else { 3 };
|
||||
for v in out.iter_mut().skip(n) {
|
||||
*v *= LOOP_WEIGHT;
|
||||
}
|
||||
@@ -280,14 +291,17 @@ fn misses(
|
||||
|
||||
/// The target's misses for these values: the point's (mm), then the axis's (as mm across `scale`).
|
||||
fn target(tree: &Tree, r: &Reach, scale: f64, vals: &Values) -> Option<Vec<f64>> {
|
||||
let t = tree.world(r.occurrence, vals)?;
|
||||
let d = apply_point(&t, r.point) - r.to;
|
||||
Some(target_at(&tree.world(r.occurrence, vals)?, r, scale))
|
||||
}
|
||||
|
||||
fn target_at(t: &crate::Mat, r: &Reach, scale: f64) -> Vec<f64> {
|
||||
let d = apply_point(t, r.point) - r.to;
|
||||
let mut out = vec![d.x, d.y, d.z];
|
||||
if let Some((a, z)) = r.axis {
|
||||
let w = (apply_vector(&t, a) - z) * scale;
|
||||
let w = (apply_vector(t, a) - z) * scale;
|
||||
out.extend([w.x, w.y, w.z]);
|
||||
}
|
||||
Some(out)
|
||||
out
|
||||
}
|
||||
|
||||
/// How many steps apart the values `vars` at `a` and at `b` put the `paced` values (each with its
|
||||
@@ -447,3 +461,55 @@ fn approach(
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests::{PLACEMENT_INSERTS, PLACEMENT_STEPS, chain, placement_tree};
|
||||
use super::*;
|
||||
|
||||
/// Exercise the IK callback itself: removing sharing at this call site must fail even if
|
||||
/// the reusable placement helper still passes its own tests.
|
||||
#[test]
|
||||
fn ik_residual_reuses_target_and_loop_ancestors_at_the_call_site() {
|
||||
let mut asm = chain(6, 0.2);
|
||||
asm.joints[5].a.occurrence = 3;
|
||||
asm.joints[5].b.occurrence = 4;
|
||||
let tree = placement_tree(&asm);
|
||||
let base = joint_values(&asm);
|
||||
let mut workspace = super::super::PlacementWorkspace::new(&tree);
|
||||
let mut cold = true;
|
||||
for axis in [None, Some((Vec3::Y, Vec3::Z))] {
|
||||
let r = Reach { occurrence: 3, point: Vec3::new(1.0, 2.0, 3.0), to: Vec3::new(7.0, 11.0, 13.0), axis, joints: None };
|
||||
for x in [0.2, 0.2 + 1e-6, 0.2 - 1e-6, 1.3] {
|
||||
let mut reference = base.clone();
|
||||
reference.get_mut(&1).unwrap()[0] = x;
|
||||
apply_links(&asm, &mut reference);
|
||||
let mut expected = target(&tree, &r, 37.0, &reference).unwrap();
|
||||
let n = expected.len();
|
||||
super::super::closure_gaps(&mut super::super::PlacementWorkspace::new(&tree), &[6], &reference, 37.0, &mut expected).unwrap();
|
||||
for v in expected.iter_mut().skip(n) {
|
||||
*v *= LOOP_WEIGHT;
|
||||
}
|
||||
let mut work = base.clone();
|
||||
let spent = Cell::new(0);
|
||||
PLACEMENT_STEPS.with(|steps| steps.set(0));
|
||||
PLACEMENT_INSERTS.with(|inserts| inserts.set(0));
|
||||
let actual = misses(&mut workspace, &asm, &r, 37.0, &[(1, 0)], &[6], &[x], &mut work, &spent);
|
||||
assert_eq!(actual, Some(expected));
|
||||
assert_eq!(
|
||||
PLACEMENT_INSERTS.with(|inserts| inserts.get()),
|
||||
if cold { 4 } else { 0 },
|
||||
"IK must retain cache storage across evaluations"
|
||||
);
|
||||
cold = false;
|
||||
assert_eq!(spent.get(), 1, "reuse must not skip a finite-difference evaluation or bypass its work cap");
|
||||
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 5, "four tree placements shared by the target and loop, plus the closing step");
|
||||
}
|
||||
let mut work = base.clone();
|
||||
let spent = Cell::new(MAX_LOOP_WORK);
|
||||
PLACEMENT_STEPS.with(|steps| steps.set(0));
|
||||
assert!(misses(&mut workspace, &asm, &r, 37.0, &[(1, 0)], &[6], &[0.2], &mut work, &spent).is_none());
|
||||
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! hold (one that later features pass through unchanged) is recorded once, as are its face names
|
||||
//! and the solved sketches the states share. Face names are kept as worked out, not as the
|
||||
//! history they were worked out from: a model taken from a file names every face as the model
|
||||
//! that was saved did.
|
||||
//! that was saved did. A file without room for every state keeps fewer
|
||||
//! ([`SavedModel::thinned`]); opening evaluates the features whose state it left out again.
|
||||
|
||||
use super::*;
|
||||
use crate::history_names::{FaceInfo, Names};
|
||||
@@ -181,6 +182,7 @@ impl Writer {
|
||||
let sketches = st.sketches.iter().map(|s| self.sketch(s)).collect::<Option<Vec<_>>>()?;
|
||||
let r = json!({
|
||||
"bodies": runs(&bodies), "sketches": runs(&sketches), "body_counter": st.body_counter,
|
||||
"reserved_body_names": st.reserved_body_names.iter().collect::<Vec<_>>(),
|
||||
"sheets": serde_json::to_value(&st.sheets).ok()?, "construct": serde_json::to_value(&st.construct).ok()?,
|
||||
"threads": serde_json::to_value(&st.threads).ok()?,
|
||||
"sketch_keys": st.sketch_keys.iter().map(|(f, k)| json!([f, hex(*k)])).collect::<Vec<_>>(),
|
||||
@@ -320,6 +322,11 @@ impl SavedModel {
|
||||
bodies: ids("bodies", model_bodies.len())?.into_iter().filter_map(|i| model_bodies.get(i).cloned()).collect(),
|
||||
sketches: ids("sketches", sketches.len())?.into_iter().filter_map(|i| sketches.get(i).cloned()).collect(),
|
||||
body_counter: index(s.get("body_counter"), usize::MAX)?,
|
||||
reserved_body_names: s
|
||||
.get("reserved_body_names")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default(),
|
||||
sheets: serde_json::from_value(field("sheets")?).map_err(invalid)?,
|
||||
construct: serde_json::from_value(field("construct")?).map_err(invalid)?,
|
||||
threads: serde_json::from_value(field("threads")?).map_err(invalid)?,
|
||||
@@ -353,12 +360,163 @@ impl SavedModel {
|
||||
None => Ok(Arc::new(ModelState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// This model with fewer states, for a design file without room for all of them (#3220): the
|
||||
/// final state (what the design shows, kept whatever it costs), then the others, the slowest
|
||||
/// feature's first (the ones opening would take longest to evaluate again; of equal ones the
|
||||
/// later first), each while what it adds still fits in `budget` bytes: the bodies no state kept
|
||||
/// so far holds, and the records of the state, those bodies, their names and sketches.
|
||||
/// `pieces[i]` is what keeping body `i` stores, as ids and bytes: a piece that several bodies
|
||||
/// or states need counts once. A feature whose state is left out keeps none, and opening
|
||||
/// evaluates it again ([`Model::adopt`]). With the model, the index here of each body it keeps,
|
||||
/// and the bytes it counted: over `budget` when the final state alone is. `None` only for
|
||||
/// records it cannot read.
|
||||
pub fn thinned(&self, pieces: &[Vec<(usize, usize)>], budget: usize) -> Option<(SavedModel, Vec<usize>, usize)> {
|
||||
let list = |key: &str| self.records.get(key).and_then(Json::as_array);
|
||||
let (features, states, model_bodies) = (list("features")?, list("states")?, list("model_bodies")?);
|
||||
let (names, sketches) = (list("names")?, list("sketches")?);
|
||||
let at = |v: Option<&Json>, len: usize| v.and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < len);
|
||||
// The indices a state's runs list.
|
||||
let held = |s: &Json, key: &str, len: usize| -> Option<Vec<usize>> {
|
||||
let mut out = Vec::new();
|
||||
for run in s.get(key)?.as_array()? {
|
||||
let first = at(run.get(0), len)?;
|
||||
out.extend(first..first.checked_add(at(run.get(1), len.saturating_add(1))?).filter(|end| *end <= len)?);
|
||||
}
|
||||
Some(out)
|
||||
};
|
||||
let state_bodies = states.iter().map(|s| held(s, "bodies", model_bodies.len())).collect::<Option<Vec<_>>>()?;
|
||||
let state_sketches = states.iter().map(|s| held(s, "sketches", sketches.len())).collect::<Option<Vec<_>>>()?;
|
||||
let body_of = model_bodies.iter().map(|r| at(r.get("body"), self.bodies.len())).collect::<Option<Vec<_>>>()?;
|
||||
let names_of = model_bodies.iter().map(|r| at(r.get("names"), names.len())).collect::<Option<Vec<_>>>()?;
|
||||
// What each record takes in the file.
|
||||
let bytes = |list: &[Json]| list.iter().map(|r| serde_json::to_vec(r).map_or(usize::MAX, |b| b.len().saturating_add(1))).collect::<Vec<_>>();
|
||||
let (state_bytes, body_bytes) = (bytes(states.as_slice()), bytes(model_bodies.as_slice()));
|
||||
let (name_bytes, sketch_bytes) = (bytes(names.as_slice()), bytes(sketches.as_slice()));
|
||||
// What each state's feature took to evaluate, and the final state.
|
||||
let mut ms = vec![0.0f64; states.len()];
|
||||
let mut last = None;
|
||||
for f in features.iter().filter(|f| f.get("skipped").and_then(Json::as_bool) == Some(false)) {
|
||||
let s = at(f.get("state"), states.len())?;
|
||||
if let Some(t) = ms.get_mut(s) {
|
||||
*t += f.get("ms").and_then(Json::as_f64).filter(|t| t.is_finite() && *t > 0.0).unwrap_or(0.0);
|
||||
}
|
||||
last = Some(s);
|
||||
}
|
||||
let last = last?;
|
||||
let time = |s: &usize| ms.get(*s).copied().unwrap_or(0.0);
|
||||
let mut order: Vec<usize> = (0..states.len()).filter(|s| *s != last).collect();
|
||||
order.sort_by(|a, b| time(b).total_cmp(&time(a)).then(b.cmp(a)));
|
||||
let mut keep = vec![false; states.len()];
|
||||
// The records and pieces kept so far, and their bytes.
|
||||
let mut kept_bodies = vec![false; model_bodies.len()];
|
||||
let (mut names_used, mut sketches_used) = (vec![false; names.len()], vec![false; sketches.len()]);
|
||||
let mut stored = std::collections::HashSet::new();
|
||||
let mut used = 0usize;
|
||||
for s in std::iter::once(last).chain(order) {
|
||||
let (mut adds, mut new_names, mut new_sketches) = (HashMap::new(), std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
let mut records = *state_bytes.get(s)?;
|
||||
for &m in state_bodies.get(s)? {
|
||||
if kept_bodies.get(m) == Some(&false) {
|
||||
records = records.saturating_add(*body_bytes.get(m)?);
|
||||
let n = *names_of.get(m)?;
|
||||
if names_used.get(n) == Some(&false) && new_names.insert(n) {
|
||||
records = records.saturating_add(*name_bytes.get(n)?);
|
||||
}
|
||||
for &(piece, bytes) in pieces.get(*body_of.get(m)?)? {
|
||||
if !stored.contains(&piece) {
|
||||
adds.insert(piece, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for &k in state_sketches.get(s)? {
|
||||
if sketches_used.get(k) == Some(&false) && new_sketches.insert(k) {
|
||||
records = records.saturating_add(*sketch_bytes.get(k)?);
|
||||
}
|
||||
}
|
||||
let total = adds.values().fold(used.saturating_add(records), |t, b| t.saturating_add(*b));
|
||||
if total > budget && s != last {
|
||||
continue;
|
||||
}
|
||||
used = total;
|
||||
stored.extend(adds.into_keys());
|
||||
for &m in state_bodies.get(s)? {
|
||||
*kept_bodies.get_mut(m)? = true;
|
||||
}
|
||||
for n in new_names {
|
||||
*names_used.get_mut(n)? = true;
|
||||
}
|
||||
for k in new_sketches {
|
||||
*sketches_used.get_mut(k)? = true;
|
||||
}
|
||||
*keep.get_mut(s)? = true;
|
||||
}
|
||||
// What the states kept refer to, numbered again in the order it had.
|
||||
let mut kernel_used = vec![false; self.bodies.len()];
|
||||
for m in (0..model_bodies.len()).filter(|m| kept_bodies.get(*m) == Some(&true)) {
|
||||
*kernel_used.get_mut(*body_of.get(m)?)? = true;
|
||||
}
|
||||
let renumber = |used: &[bool]| -> Vec<Option<usize>> {
|
||||
let mut next = 0;
|
||||
used.iter()
|
||||
.map(|&u| {
|
||||
u.then(|| {
|
||||
next += 1;
|
||||
next - 1
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (new_body, new_kernel, new_names) = (renumber(&kept_bodies), renumber(&kernel_used), renumber(&names_used));
|
||||
let (new_sketch, new_state) = (renumber(&sketches_used), renumber(&keep));
|
||||
let new = |map: &[Option<usize>], i: usize| map.get(i).copied().flatten();
|
||||
let kept_of = |list: &[Json], used: &[bool]| list.iter().zip(used).filter(|(_, u)| **u).map(|(v, _)| v.clone()).collect::<Vec<_>>();
|
||||
let mut out_states = Vec::new();
|
||||
for (s, st) in states.iter().enumerate().filter(|(s, _)| keep.get(*s) == Some(&true)) {
|
||||
let bodies = state_bodies.get(s)?.iter().map(|m| new(&new_body, *m)).collect::<Option<Vec<_>>>()?;
|
||||
let sketches = state_sketches.get(s)?.iter().map(|i| new(&new_sketch, *i)).collect::<Option<Vec<_>>>()?;
|
||||
let mut st = st.clone();
|
||||
let o = st.as_object_mut()?;
|
||||
o.insert("bodies".into(), json!(runs(&bodies)));
|
||||
o.insert("sketches".into(), json!(runs(&sketches)));
|
||||
out_states.push(st);
|
||||
}
|
||||
let mut out_bodies = Vec::new();
|
||||
for (m, r) in model_bodies.iter().enumerate().filter(|(m, _)| kept_bodies.get(*m) == Some(&true)) {
|
||||
let (body, named) = (new(&new_kernel, *body_of.get(m)?)?, new(&new_names, at(r.get("names"), names.len())?)?);
|
||||
let mut r = r.clone();
|
||||
let o = r.as_object_mut()?;
|
||||
o.insert("body".into(), json!(body));
|
||||
o.insert("names".into(), json!(named));
|
||||
out_bodies.push(r);
|
||||
}
|
||||
let mut out_features = Vec::with_capacity(features.len());
|
||||
for f in features {
|
||||
let state = new(&new_state, at(f.get("state"), states.len())?);
|
||||
let mut f = f.clone();
|
||||
let o = f.as_object_mut()?;
|
||||
match state {
|
||||
Some(s) => o.insert("state".into(), json!(s)),
|
||||
None => o.remove("state"),
|
||||
};
|
||||
out_features.push(f);
|
||||
}
|
||||
let records = json!({
|
||||
"features": out_features, "states": out_states, "model_bodies": out_bodies, "names": kept_of(names.as_slice(), &names_used),
|
||||
"sketches": kept_of(sketches.as_slice(), &sketches_used),
|
||||
});
|
||||
let kept: Vec<usize> = (0..self.bodies.len()).filter(|i| kernel_used.get(*i) == Some(&true)).collect();
|
||||
let bodies = kept.iter().filter_map(|i| self.bodies.get(*i).cloned()).collect();
|
||||
Some((SavedModel { records, bodies }, kept, used))
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// The model a design file kept for `doc` ([`Model::saved`]), taken as `doc`'s evaluation:
|
||||
/// every feature's result as saved, nothing evaluated. Refused (an error) unless every
|
||||
/// feature's fingerprint is the one `doc` gives it now, and every record is well formed.
|
||||
/// every feature's result as saved, nothing evaluated but the features whose state the file
|
||||
/// did not keep ([`SavedModel::thinned`]). Refused (an error) unless every feature's
|
||||
/// fingerprint is the one `doc` gives it now, and every record is well formed.
|
||||
pub fn adopt(doc: &Document, saved: &SavedModel) -> Result<Model> {
|
||||
let states = saved.states()?;
|
||||
let records = &saved.records;
|
||||
@@ -375,16 +533,24 @@ impl Model {
|
||||
let step_digests = StepDigests::of(doc, &StepDigests::default());
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let ctx = context_digest(doc, &vals);
|
||||
let mut cache = EvalCache::default();
|
||||
let mut before = Arc::new(ModelState::default());
|
||||
// Whether `before` is the state before the feature: one the file left out only stands in.
|
||||
let mut exact = true;
|
||||
let mut prev = 0;
|
||||
let mut results = Vec::with_capacity(features.len());
|
||||
for (i, (f, r)) in doc.features.iter().zip(features).enumerate() {
|
||||
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before));
|
||||
if r.get("id").and_then(Json::as_u64) != Some(f.id) || unhex(r.get("fingerprint").unwrap_or(&Json::Null))? != prev {
|
||||
let saved = unhex(r.get("fingerprint").unwrap_or(&Json::Null))?;
|
||||
// A fingerprint reads the state before its feature (where the bodies it takes lie).
|
||||
// After a state the file left out, the saved one is taken: `evaluate` works that state
|
||||
// out, checks the fingerprints after it, and evaluates again from one that differs.
|
||||
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before)) } else { saved };
|
||||
if r.get("id").and_then(Json::as_u64) != Some(f.id) || saved != prev {
|
||||
return Err(bad("saved model: the document changed"));
|
||||
}
|
||||
let ms = r.get("ms").and_then(Json::as_f64).filter(|ms| ms.is_finite()).ok_or_else(|| bad("saved model: time"))?;
|
||||
// A file without room for every state keeps some features without one (#3220).
|
||||
let state =
|
||||
r.get("state").map(|s| states.get(index(Some(s), states.len())?).cloned().ok_or_else(|| bad("saved model: state"))).transpose()?;
|
||||
let result = FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -398,27 +564,48 @@ impl Model {
|
||||
skipped: r.get("skipped").and_then(Json::as_bool).ok_or_else(|| bad("saved model: skipped"))?,
|
||||
stale: false,
|
||||
fingerprint: prev,
|
||||
state: states.get(index(r.get("state"), states.len())?).cloned().ok_or_else(|| bad("saved model: state"))?,
|
||||
unkept: state.is_none(),
|
||||
state: state.unwrap_or_else(|| before.clone()),
|
||||
};
|
||||
// Each result where an evaluation would have put it, so an edit undone (or set back)
|
||||
// finds it again rather than evaluating the feature anew.
|
||||
if !result.skipped {
|
||||
let local = fingerprint(0, f, &vals, false, &step_digests) ^ placement_digest(doc, f, Some(&before));
|
||||
cache.put(
|
||||
cache_key(f, prev, local, ctx, &before),
|
||||
CachedEval {
|
||||
error: None,
|
||||
warning: result.warning.clone(),
|
||||
boolean_diagnostics: result.boolean_diagnostics.clone(),
|
||||
ms,
|
||||
bytes: result.state.retained_bytes(),
|
||||
state: result.state.clone(),
|
||||
},
|
||||
);
|
||||
exact = !result.unkept;
|
||||
before = result.state.clone();
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
Ok(Model { results, param_errors, step_digests, cache: Arc::new(std::sync::Mutex::new(cache)), ..Model::default() })
|
||||
let mut model = Model { results, param_errors, step_digests, ..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
|
||||
// before it: nothing else in the model is missing then.
|
||||
model.evaluate(doc);
|
||||
log::info!(
|
||||
"the saved model kept the states of {} of {} features: {} evaluated again",
|
||||
model.results.len().saturating_sub(unkept),
|
||||
model.results.len(),
|
||||
model.last_recomputed
|
||||
);
|
||||
}
|
||||
// Each result where an evaluation would have put it, so an edit undone (or set back)
|
||||
// finds it again rather than evaluating the feature anew.
|
||||
let mut before = Arc::new(ModelState::default());
|
||||
if let Ok(mut cache) = model.cache.lock() {
|
||||
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
|
||||
let local = fingerprint(0, f, &vals, false, &model.step_digests) ^ placement_digest(doc, f, Some(&before));
|
||||
cache.put(
|
||||
cache_key(f, r.fingerprint, local, ctx, &before),
|
||||
CachedEval {
|
||||
error: None,
|
||||
warning: r.warning.clone(),
|
||||
boolean_diagnostics: r.boolean_diagnostics.clone(),
|
||||
ms: r.ms,
|
||||
bytes: r.state.retained_bytes(),
|
||||
state: r.state.clone(),
|
||||
},
|
||||
);
|
||||
before = r.state.clone();
|
||||
}
|
||||
}
|
||||
Ok(model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
/// The motion retained by an associative trace. Joint bounds keep their expressions, in
|
||||
/// command units (degrees or millimetres); studies are referenced by their stable identity.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TraceMotion {
|
||||
Joint { joint: u64, index: usize, from: serde_json::Value, to: serde_json::Value },
|
||||
Study { study: u64 },
|
||||
}
|
||||
|
||||
/// A generated root sketch curve and the inputs needed to regenerate it. The seed values
|
||||
/// select the original assembly branch: playing or dragging the mechanism does not redefine
|
||||
/// its trace. Older saved curves have no entry here and remain ordinary fixed sketch curves.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MotionTrace {
|
||||
pub sketch: u64,
|
||||
pub wire: String,
|
||||
pub occurrence: Vec<u64>,
|
||||
pub point: crate::joints::JointOrigin,
|
||||
pub motion: TraceMotion,
|
||||
pub samples: Option<usize>,
|
||||
pub seeds: Vec<(u64, Vec<f64>)>,
|
||||
}
|
||||
|
||||
/// Occurrences whose bodies may touch but not pass through each other while joints move.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ContactSet {
|
||||
|
||||
@@ -266,10 +266,7 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
// Every body's names are read below: work out the ones not known yet on all cores first
|
||||
// (each body's names are its own, whichever thread works them out).
|
||||
if legacy_name(name).is_none() {
|
||||
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
|
||||
solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| {
|
||||
let _ = naming::names(b);
|
||||
});
|
||||
naming::warm_names(st.bodies.iter().filter(|b| !b.body.is_mesh()));
|
||||
}
|
||||
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
|
||||
}
|
||||
@@ -445,9 +442,7 @@ pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<Fac
|
||||
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
|
||||
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
|
||||
if bodies.len() > 1 && legacy_name(name).is_none() {
|
||||
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
|
||||
let _ = naming::names(b);
|
||||
});
|
||||
naming::warm_names(bodies.iter().map(|(_, b)| *b));
|
||||
}
|
||||
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
|
||||
if hit.split {
|
||||
|
||||
@@ -229,6 +229,23 @@ pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
|
||||
names(b).verified(index)
|
||||
}
|
||||
|
||||
/// Prepare only names not yet known before a lookup reads several bodies. A ready cell is
|
||||
/// shared by snapshots and poses: dispatching threads just to read it costs more than a lookup.
|
||||
pub(crate) fn warm_names<'a>(bodies: impl Iterator<Item = &'a ModelBody>) {
|
||||
let bodies: Vec<_> = bodies.filter(|b| b.names.names.get().is_none()).collect();
|
||||
#[cfg(test)]
|
||||
tests::record_warm_jobs(bodies.len());
|
||||
match bodies.as_slice() {
|
||||
[] => {}
|
||||
[body] => {
|
||||
let _ = names(body);
|
||||
}
|
||||
_ => solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| {
|
||||
let _ = names(b);
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A body's names, with what finding them again needs: from the history of the feature that
|
||||
/// made it where it reports one, else from geometry (unverified).
|
||||
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
|
||||
@@ -872,6 +889,85 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
thread_local! {
|
||||
static WARM_JOBS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub(super) fn record_warm_jobs(n: usize) {
|
||||
WARM_JOBS.with(|jobs| jobs.set(jobs.get() + n));
|
||||
}
|
||||
|
||||
fn take_warm_jobs() -> usize {
|
||||
WARM_JOBS.with(|jobs| jobs.replace(0))
|
||||
}
|
||||
|
||||
fn lookup_bodies() -> ModelState {
|
||||
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 2., 1.)).unwrap();
|
||||
let mut st = ModelState::default();
|
||||
st.bodies = (0..13).map(|i| ModelBody::new(format!("Part{i}"), body.clone(), i + 1)).collect();
|
||||
st
|
||||
}
|
||||
|
||||
fn same_hit(a: &FaceHit, b: &FaceHit) {
|
||||
assert_eq!((a.body, a.face, a.point), (b.body, b.face, b.point));
|
||||
assert_eq!((a.split, a.merged, a.verified, a.on_point, a.legacy), (b.split, b.merged, b.verified, b.on_point, b.legacy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_face_lookups_schedule_no_name_warmup() {
|
||||
let st = lookup_bodies();
|
||||
for b in &st.bodies {
|
||||
let _ = face_names(b);
|
||||
}
|
||||
let name = face_names(&st.bodies[0])[0].clone();
|
||||
let picked = locate_face(&st, &name, Vec3::ZERO).unwrap();
|
||||
let unique = locate_one(&st, None, &name).unwrap();
|
||||
take_warm_jobs();
|
||||
// The two public lookup paths used by picked origins and named-face commands.
|
||||
for _ in 0..145 {
|
||||
same_hit(&locate_face(&st, &name, Vec3::ZERO).unwrap(), &picked);
|
||||
same_hit(&locate_one(&st, None, &name).unwrap(), &unique);
|
||||
}
|
||||
assert!(locate_face(&st, "absent:face:0", Vec3::ZERO).is_none());
|
||||
assert!(locate_one(&st, None, "absent:face:0").is_err());
|
||||
assert_eq!(take_warm_jobs(), 0, "cached names must not dispatch preparation work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_lookup_warms_only_cold_and_invalidated_names() {
|
||||
// Exercise both entry points as the first reader of a partly named model.
|
||||
for picked in [false, true] {
|
||||
let mut st = lookup_bodies();
|
||||
let name = face_names(&st.bodies[0])[0].clone();
|
||||
let lookup = |st: &ModelState, name: &str| {
|
||||
if picked { locate_face(st, name, Vec3::ZERO).unwrap() } else { locate_one(st, None, name).unwrap() }
|
||||
};
|
||||
take_warm_jobs();
|
||||
let hit = lookup(&st, &name);
|
||||
assert_eq!(take_warm_jobs(), 12, "only the other bodies need names");
|
||||
let snapshot = st.clone();
|
||||
same_hit(&lookup(&snapshot, &name), &hit);
|
||||
assert_eq!(take_warm_jobs(), 0, "snapshots share ready names");
|
||||
|
||||
st.bodies[0].name = "Renamed".into();
|
||||
st.bodies[0].names = st.bodies[0].names.renamed();
|
||||
let renamed = lookup(&st, "Renamed:face:0");
|
||||
same_hit(&renamed, &hit);
|
||||
assert_eq!(take_warm_jobs(), 1, "a rename invalidates only its own names");
|
||||
assert!(locate_face(&st, &name, Vec3::ZERO).is_none());
|
||||
same_hit(&lookup(&snapshot, &name), &hit);
|
||||
assert_eq!(take_warm_jobs(), 0, "the retained snapshot stays ready");
|
||||
|
||||
let source = st.bodies[1].clone();
|
||||
st.bodies[1].names = NameCell::same(&source);
|
||||
same_hit(&lookup(&st, "Renamed:face:0"), &renamed);
|
||||
assert_eq!(take_warm_jobs(), 1, "a lazy moved copy is prepared once");
|
||||
assert!(Arc::ptr_eq(&names(&st.bodies[1]), &names(&source)));
|
||||
same_hit(&lookup(&st, "Renamed:face:0"), &renamed);
|
||||
assert_eq!(take_warm_jobs(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pieces lying almost level along the sort axis must keep the order they arrived in, whatever
|
||||
/// the low bits of their centres do. The centre is the mean of a tessellated boundary, so those
|
||||
/// bits depend on where the loop's polyline starts and can differ between processes — which is
|
||||
|
||||
@@ -76,6 +76,9 @@ impl FeatureKind {
|
||||
ExprRole::Placement => placement.push(e.as_str()),
|
||||
});
|
||||
inputs.extend(placement);
|
||||
if let FeatureKind::Sketch { sketch, .. } = self {
|
||||
inputs.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()));
|
||||
}
|
||||
inputs
|
||||
}
|
||||
}
|
||||
@@ -694,6 +697,11 @@ impl Document {
|
||||
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
|
||||
}
|
||||
}
|
||||
for pattern in &sketch.patterns {
|
||||
if pattern.count_expression.as_ref().is_some_and(|e| expr::references(e).iter().any(|r| r == name)) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: format!("{} Quantity", pattern.id) });
|
||||
}
|
||||
}
|
||||
for (what, e) in sketch.text_exprs() {
|
||||
if expr::references(e).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
|
||||
@@ -783,6 +791,13 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.patterns.iter_mut().filter_map(|p| p.count_expression.as_mut()) {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.text_exprs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
@@ -853,6 +868,7 @@ impl Document {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
|
||||
}
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
|
||||
@@ -330,10 +330,32 @@ struct Chamfer {
|
||||
fn chamfer_at(mb: &ModelBody, mesh: &Mesh, p: Vec3, tol: f64, near: f64) -> Option<Chamfer> {
|
||||
let b = &mb.body;
|
||||
let (d, f) = crate::appearance::nearest_face(mb, p)?;
|
||||
if d <= near
|
||||
&& let Some(c) = bend_cylinder(b, mesh, f, tol)
|
||||
{
|
||||
return Some(c);
|
||||
if d <= near {
|
||||
if let Some(c) = bend_cylinder(b, mesh, f, tol) {
|
||||
return Some(c);
|
||||
}
|
||||
// A tangent edge belongs to both the cylinder and a planar panel. The nearest
|
||||
// face can be either: resolve the cylinder from the edge's incident faces too.
|
||||
let edge = mesh
|
||||
.edges
|
||||
.iter()
|
||||
.zip(&mesh.edge_faces)
|
||||
.map(|(line, faces)| {
|
||||
let distance = line
|
||||
.windows(2)
|
||||
.filter_map(|w| match w {
|
||||
[a, b] => Some(p.dist_to_segment(*a, *b)),
|
||||
_ => None,
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
(distance, faces)
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some((_, faces)) = edge.filter(|(distance, _)| *distance <= near)
|
||||
&& let Some(c) = faces.iter().find_map(|g| bend_cylinder(b, mesh, *g as usize, tol))
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
if d > tol {
|
||||
return None;
|
||||
@@ -672,8 +694,17 @@ pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<
|
||||
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
|
||||
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
|
||||
/// `body_to_world` places diagnostic points in world coordinates. Geometry and picks stay local.
|
||||
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
|
||||
pub fn convert_body(
|
||||
mb: &ModelBody,
|
||||
rule: &str,
|
||||
rv: &RuleValues,
|
||||
face: Vec3,
|
||||
bends: &[Vec3],
|
||||
body_to_world: &crate::Mat,
|
||||
) -> Result<(SheetBody, Vec<String>)> {
|
||||
let at = |p| at(apply_point(body_to_world, p));
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
|
||||
@@ -251,6 +251,45 @@ fn params_and_serialization() {
|
||||
assert!(doc.param("d1").is_none(), "unused model parameters are pruned");
|
||||
}
|
||||
|
||||
/// #4783: a body reads the material its mass is computed with, as the library or the design
|
||||
/// spells it. An unknown name, or a design material with an unusable density, reads as the
|
||||
/// default material it weighs. `density` agrees in every case, and a configuration keeps the
|
||||
/// assignment as stored.
|
||||
#[test]
|
||||
fn a_body_reads_the_material_its_mass_is_computed_with() {
|
||||
let mut doc = Document::new("materials");
|
||||
let usable = [("Walnut", 0.65), ("Mote", f64::MIN_POSITIVE), ("Limit", MAX_DENSITY)];
|
||||
let unusable = [("Broken", -3.0), ("Weightless", 0.0), ("Beyond", MAX_DENSITY.next_up())];
|
||||
for (name, density) in usable.into_iter().chain(unusable) {
|
||||
doc.user_materials.push(UserMaterial { name: name.into(), density, appearance: None });
|
||||
}
|
||||
// (body, its assignment as stored, the material it reads and weighs, that material's density)
|
||||
let cases = [
|
||||
("Known", Some("Steel"), "Steel", 7.85),
|
||||
("Miscased", Some("steel"), "Steel", 7.85),
|
||||
("Own", Some(" walnut "), "Walnut", 0.65),
|
||||
("Lightest", Some("Mote"), "Mote", f64::MIN_POSITIVE),
|
||||
("At the limit", Some("Limit"), "Limit", MAX_DENSITY),
|
||||
("Unknown", Some("Unobtainium"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
|
||||
("Unusable", Some("Broken"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
|
||||
("Zero density", Some("Weightless"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
|
||||
("Over the limit", Some("Beyond"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
|
||||
("Unassigned", None, DEFAULT_MATERIAL, DEFAULT_DENSITY),
|
||||
];
|
||||
for (body, stored, ..) in cases {
|
||||
if let Some(material) = stored {
|
||||
doc.materials.insert(body.into(), material.into());
|
||||
}
|
||||
}
|
||||
for (body, stored, material, density) in cases {
|
||||
assert_eq!(doc.body_material(body), material, "{body}");
|
||||
assert_eq!(doc.density(body), density, "{body}");
|
||||
assert_eq!(doc.material_density(material), Some(density), "{body}: the density is its material's");
|
||||
let cell = doc.config_current(&crate::config::Column::Material(body.into()));
|
||||
assert_eq!(cell.as_deref(), Some(stored.unwrap_or(DEFAULT_MATERIAL)), "{body}: a configuration keeps the assignment as stored");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitives_and_combine() {
|
||||
let mut doc = Document::new("p");
|
||||
|
||||
@@ -1223,3 +1223,78 @@ fn extrude_cut_into_a_rounded_block_top() {
|
||||
let v = before - volume(&cut, "Body1");
|
||||
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_intersection_is_refused_without_changing_the_design() -> crate::Result<()> {
|
||||
for command in ["solid.extrude", "solid.box", "solid.combine"] {
|
||||
for x in [5, 20] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Base"}))?;
|
||||
s.execute("solid.box", &json!({"corner":[x,0,0],"length":10,"width":10,"height":10,"body_name":"Tool","operation":"new"}))?;
|
||||
s.execute("sketch.create", &json!({"plane":"XY"}))?;
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,0],"p1":[x+10,10]}))?;
|
||||
s.execute("sketch.finish", &json!({}))?;
|
||||
let before = s.doc.to_json();
|
||||
let params = match command {
|
||||
"solid.combine" => json!({"target":"Base","tools":["Tool"],"operation":"intersect"}),
|
||||
"solid.box" => json!({"corner":[x,0,0],"length":10,"width":10,"height":10,"operation":"intersect","targets":["Base"]}),
|
||||
_ => json!({"distance":10,"operation":"intersect","targets":["Base"]}),
|
||||
};
|
||||
let result = s.execute(command, ¶ms);
|
||||
if x == 20 {
|
||||
let error = result.err().ok_or_else(|| crate::EngineError::Other(format!("{command} accepted an empty intersection")))?;
|
||||
assert!(
|
||||
error.to_string().contains("the intersection is empty: nothing of `Base` would remain; check that the profile overlaps the body"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert!((volume(&s, "Base") - 1000.0).abs() < 1e-6);
|
||||
assert!((volume(&s, "Tool") - 1000.0).abs() < 1e-6);
|
||||
} else {
|
||||
result?;
|
||||
assert!((volume(&s, "Base") - 500.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_intersection_in_a_saved_design_fails_and_keeps_the_target() -> crate::Result<()> {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Base"}))?;
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":40,"body_name":"Tall","operation":"new"}))?;
|
||||
s.execute("sketch.create", &json!({"plane":"XY","offset":5}))?;
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[0,0],"p1":[10,10]}))?;
|
||||
s.execute("sketch.finish", &json!({}))?;
|
||||
let result = s.execute("solid.extrude", &json!({"distance":10,"operation":"intersect","targets":["Tall","Base"]}))?;
|
||||
let id = result["feature"].as_u64().ok_or_else(|| crate::EngineError::Other("missing feature id".into()))?;
|
||||
assert!((volume(&s, "Base") - 500.0).abs() < 1e-6);
|
||||
let mut doc = solidcraft_doc::Document::from_json(&s.doc.to_json())?;
|
||||
if let Some(f) = doc.feature_mut(id)
|
||||
&& let solidcraft_doc::FeatureKind::Extrude { extent, .. } = &mut f.kind
|
||||
{
|
||||
extent.start_offset = Some("20 mm".into());
|
||||
}
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&doc.to_json())?);
|
||||
assert!(reopened.model.result(id).and_then(|r| r.error.as_ref()).is_some_and(|e| e.contains("the intersection is empty")));
|
||||
assert_eq!(reopened.model.state().bodies.len(), 2);
|
||||
assert!((volume(&reopened, "Tall") - 4000.0).abs() < 1e-6);
|
||||
assert!((volume(&reopened, "Base") - 1000.0).abs() < 1e-6);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_with_every_body_still_drops_the_ones_it_misses() -> crate::Result<()> {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Near"}))?;
|
||||
s.execute("solid.box", &json!({"corner":[30,0,0],"length":10,"width":10,"height":10,"body_name":"Far","operation":"new"}))?;
|
||||
s.execute("sketch.create", &json!({"plane":"XY"}))?;
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[0,0],"p1":[5,10]}))?;
|
||||
s.execute("sketch.finish", &json!({}))?;
|
||||
s.execute("solid.extrude", &json!({"distance":10,"operation":"intersect"}))?;
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert!((volume(&s, "Near") - 500.0).abs() < 1e-6);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm; roll defaults to 0, must be finite and is clamped to ±π; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
@@ -453,8 +453,17 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
|
||||
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
|
||||
(None, Some(c)) => {
|
||||
if c.get("up").is_some() {
|
||||
return Err(bad(cmd, "camera `up` is not supported: the up axis comes from the design settings"));
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => {
|
||||
value.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(cmd, "camera `roll` must be a finite number in radians"))?
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
camera_of(&v)
|
||||
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
|
||||
}
|
||||
// Standard view names come first, as in the browser's Named Views folder.
|
||||
(Some(name), None) => match crate::render::StandardView::parse(name) {
|
||||
@@ -548,8 +557,10 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// includes the siblings whose stored name is shared: the solids of a STEP file with one label
|
||||
// are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`, so the stored names are set
|
||||
// to the numbered ones, or `X (3)` would turn into `X (2)` once the second solid is renamed.
|
||||
// Read the feature's own result so siblings removed by later features keep their names too.
|
||||
let fid = b.feature;
|
||||
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
||||
let made = s.model.result(fid).map_or(&st.bodies, |r| &r.state.bodies);
|
||||
let siblings: Vec<String> = made.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
||||
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
|
||||
// A body numbered by the counter (`BodyN`) gives its number back once it has a name of its
|
||||
// own, and every counted body after it would move down one (`Body3` becoming `Body1`) and
|
||||
@@ -777,6 +788,30 @@ mod tests {
|
||||
assert_eq!(names(&s)[1], b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_body_keeps_a_later_sibling_removal() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
for x in [0, 20, 40] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 10, 10]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "new", "name": "Extrude1"}));
|
||||
assert_eq!(names(&s), ["Body1", "Body2", "Body3"]);
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Body3"]}));
|
||||
assert_eq!(names(&s), ["Body1", "Body2"]);
|
||||
|
||||
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Ext"}));
|
||||
assert_eq!(s.doc.find_feature("Extrude1").unwrap().body_names, ["Ext", "Body2", "Body3"]);
|
||||
assert_eq!(names(&s), ["Ext", "Body2"]);
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the remove still finds Body3");
|
||||
|
||||
let mut fresh = Session::new((*s.doc).clone());
|
||||
fresh.refresh();
|
||||
assert_eq!(names(&fresh), ["Ext", "Body2"]);
|
||||
assert!(fresh.model.results.iter().all(|r| r.error.is_none()), "the remove also works after a full evaluation");
|
||||
}
|
||||
|
||||
/// #3510: each piece of a split is renamed on its own. Renaming the split body leaves the
|
||||
/// other piece, and the features that use it, as they were.
|
||||
#[test]
|
||||
@@ -797,6 +832,28 @@ mod tests {
|
||||
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
|
||||
}
|
||||
|
||||
/// Renaming one half of a split leaves the other half's name, also once a later feature
|
||||
/// joins that other half away.
|
||||
#[test]
|
||||
fn renaming_one_half_of_a_split_leaves_the_other() {
|
||||
let ok = |s: &Session| s.model.results.iter().all(|r| r.error.is_none());
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, -5]}));
|
||||
run(&mut s, "solid.split_body", json!({"body": "Body1", "plane": "XY"}));
|
||||
assert_eq!(names(&s), ["Body1", "Body1 (1)"]);
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [10, 0, -5], "operation": "new", "body_name": "Tab"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Tab", "tools": ["Body1 (1)"], "operation": "join"}));
|
||||
assert_eq!(names(&s), ["Body1", "Tab"]);
|
||||
|
||||
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Upper"}));
|
||||
assert_eq!(names(&s), ["Upper", "Tab"]);
|
||||
assert!(ok(&s), "the join still finds the other half");
|
||||
let mut fresh = Session::new((*s.doc).clone());
|
||||
fresh.refresh();
|
||||
assert_eq!(names(&fresh), ["Upper", "Tab"]);
|
||||
assert!(ok(&fresh), "the join also works after a full evaluation");
|
||||
}
|
||||
|
||||
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
|
||||
/// follows them, as it follows the names inside the kind (a combine's target and tools).
|
||||
#[test]
|
||||
@@ -985,8 +1042,14 @@ mod tests {
|
||||
run(&mut s, "view.apply", json!({"view": "top"}));
|
||||
let c = s.camera.expect("a camera for the top view");
|
||||
assert!((c.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3 && (c.target.x - 20.0).abs() < 1e-6, "{c:?}");
|
||||
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5}}));
|
||||
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov)), Some((1.0, 80.0, 0.5)));
|
||||
let r = run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5, "roll": 1.0}}));
|
||||
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov, c.roll)), Some((1.0, 80.0, 0.5, 1.0)));
|
||||
assert_eq!(r["camera"]["roll"], 1.0);
|
||||
for (roll, expected) in [(f64::MAX, std::f64::consts::PI), (-f64::MAX, -std::f64::consts::PI)] {
|
||||
let r = run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": roll}}));
|
||||
assert_eq!(r["camera"]["roll"], expected);
|
||||
assert_eq!(s.camera.map(|c| c.roll), Some(expected));
|
||||
}
|
||||
assert_eq!(s.undo.len(), undo, "view state, not an undo step");
|
||||
for p in [
|
||||
json!({}),
|
||||
@@ -999,6 +1062,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_camera_rejects_up() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "view.apply", json!({"view": "front"}));
|
||||
let before = serde_json::to_value(s.camera).unwrap();
|
||||
let err = s.execute("view.apply", &json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "up": "y"}})).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("up") && message.contains("design") && message.contains("settings"), "{message}");
|
||||
assert_eq!(serde_json::to_value(s.camera).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_camera_rejects_invalid_roll() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "view.apply", json!({"view": "front"}));
|
||||
let before = serde_json::to_value(s.camera).unwrap();
|
||||
// Non-finite floating-point values serialize as null in JSON.
|
||||
for roll in [json!(f64::NAN), json!(f64::INFINITY), json!(f64::NEG_INFINITY), json!("NaN"), json!(true)] {
|
||||
let err =
|
||||
s.execute("view.apply", &json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": roll}})).unwrap_err();
|
||||
assert!(err.to_string().contains("roll"), "{err}");
|
||||
assert_eq!(serde_json::to_value(s.camera).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_views_are_saved_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -109,6 +109,12 @@ pub(super) fn valid_position(m: &Mat) -> bool {
|
||||
|
||||
pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
if let Some(params) = p.as_object()
|
||||
&& let Some(key) = params.keys().find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate"))
|
||||
{
|
||||
let hint = if params.contains_key("bodies") { "; use component.move_bodies or component.from_bodies to move bodies" } else { "" };
|
||||
return Err(bad(cmd, format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate{hint}")));
|
||||
}
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
|
||||
let parent_path = if let Some(value) = p.get("parent_path") {
|
||||
let path = value
|
||||
|
||||
@@ -99,7 +99,7 @@ fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
/// name is built as stored, or as `stored (n)` when a body of that name exists already: the solids
|
||||
/// of a STEP file that share a label are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`,
|
||||
/// and a second import of a file builds `Aa (2)` for the stored `Aa`.
|
||||
fn named_by(stored: &[String], built: &[String]) -> bool {
|
||||
pub(super) fn named_by(stored: &[String], built: &[String]) -> bool {
|
||||
!stored.is_empty()
|
||||
&& stored.len() == built.len()
|
||||
&& stored.iter().zip(built).all(|(stored, built)| {
|
||||
|
||||
@@ -15,6 +15,9 @@ use super::CommandSpec;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "component_extract.rs"]
|
||||
mod extract;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_relink_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
@@ -35,6 +38,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.break_link", "Break Link", break_link)
|
||||
.icon("break")
|
||||
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
|
||||
CommandSpec::new("component.extract", "Extract to File", extract::extract).icon("export").params(
|
||||
"component: id or name of a component that is not linked; path: the .solidcraft design to write → its history, subcomponents and their joints \
|
||||
move to that design, and the component becomes linked to it (as component.insert_design makes it); returns path, file, feature, bodies, \
|
||||
notices (what stays behind), warnings and copies (components that build the same bodies: see component.replace_with_link)",
|
||||
),
|
||||
CommandSpec::new("component.copies", "Find Identical Copies", extract::copies).icon("inspect").noundo().params(
|
||||
"component: id or name of a linked component → copies: [{component, name}], the components not linked whose own history builds the same bodies",
|
||||
),
|
||||
CommandSpec::new("component.replace_with_link", "Replace with Link", extract::replace_with_link).icon("chain").params(
|
||||
"component or components: [ids or names] of components that are not linked; with: a linked component, or path: a .solidcraft design \
|
||||
(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()
|
||||
@@ -112,8 +128,12 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_i
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let name = doc.name.clone();
|
||||
let part = Session::new(doc);
|
||||
session_bodies(&Session::new(doc), &shown)
|
||||
}
|
||||
|
||||
/// The solid bodies of `part`, the design saved as `shown`, as an Import feature.
|
||||
fn session_bodies(part: &Session, shown: &str) -> Result<(String, solidcraft_io::StepFeature)> {
|
||||
let name = part.doc.name.clone();
|
||||
if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it")));
|
||||
}
|
||||
@@ -124,7 +144,7 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_i
|
||||
}
|
||||
let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name)
|
||||
.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let mut feature = solidcraft_io::step_import_feature(&step, shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
if world.bodies.len() > solids.len() {
|
||||
feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len()));
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
edges += measured.merged.edges;
|
||||
vertices += measured.merged.vertices;
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
|
||||
"material": doc.body_material(&body.name),
|
||||
"density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
|
||||
"area_mm2": measured.area, "center_of_mass": measured.centroid,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
|
||||
@@ -256,7 +256,8 @@ fn user_defined_material_survives_save_and_reopen() {
|
||||
|
||||
/// #532: designs from before user materials open unchanged: built-in assignments keep their
|
||||
/// densities and a design without its own materials saves no `user_materials`. A damaged
|
||||
/// file's unusable density falls back to the default instead of producing nonsense masses.
|
||||
/// file's unusable density falls back to the default instead of producing nonsense masses, and
|
||||
/// the body reads as the default material.
|
||||
#[test]
|
||||
fn designs_without_user_materials_open_unchanged() {
|
||||
let (dir, path) = temp_design("legacy");
|
||||
@@ -278,7 +279,17 @@ fn designs_without_user_materials_open_unchanged() {
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
|
||||
// #4783: it reads as the material it weighs; the file's assignment is kept as it is.
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Default", "{props}");
|
||||
let measured = run(&mut s, "inspect.measure", json!({}));
|
||||
assert_eq!(measured["bodies"][0]["material"], "Default", "{measured}");
|
||||
close(&measured["bodies"][0]["density_g_cm3"], solidcraft_doc::DEFAULT_DENSITY);
|
||||
assert_eq!(s.doc.materials.get("Plate").map(String::as_str), Some("Broken"));
|
||||
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
|
||||
// Assigning the default material, in any case, clears the damaged assignment.
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "default"}));
|
||||
assert!(!s.doc.materials.contains_key("Plate"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -292,6 +303,10 @@ fn configurations_switch_to_a_user_defined_material() {
|
||||
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
close(&mass(&mut s), 0.65);
|
||||
// A row of the default material, in any case, clears the assignment.
|
||||
run(&mut s, "config.row", json!({"name": "Plain", "values": {"material:Slat": "default"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "Plain"}));
|
||||
assert!(!s.doc.materials.contains_key("Slat"));
|
||||
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! suppressions) and switching between its rows.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::DEFAULT_MATERIAL;
|
||||
use solidcraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -265,7 +266,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
Some(Column::Material(_)) => {
|
||||
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| DEFAULT_MATERIAL.into());
|
||||
}
|
||||
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
|
||||
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
|
||||
@@ -39,13 +39,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.doc_only()
|
||||
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active, or \"end\" (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback")
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", roll_to).icon("rollback")
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
|
||||
),
|
||||
@@ -491,7 +491,9 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
|
||||
let active = active_param(p, cmd)?;
|
||||
let (mut next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
|
||||
place_marker(s, &mut next, &moving, active, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
@@ -499,10 +501,19 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_marker(s, p, "timeline.rollback")
|
||||
}
|
||||
|
||||
fn roll_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_marker(s, p, "timeline.roll_to")
|
||||
}
|
||||
|
||||
/// `timeline.rollback` and `timeline.roll_to`, refusing in the name of the one asked for (#4712).
|
||||
fn move_marker(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let pos = match p.get("feature") {
|
||||
Some(f) => {
|
||||
let id = feature_id(s, Some(f), "timeline.roll_to")?;
|
||||
let id = feature_id(s, Some(f), cmd)?;
|
||||
s.doc.feature_index(id).map(|i| i + 1)
|
||||
}
|
||||
// A number of features, or "end" (as omitting it); anything else is refused rather than
|
||||
@@ -513,7 +524,13 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => {
|
||||
let x = v.as_f64().or_else(|| v.as_str().and_then(|t| t.trim().parse::<f64>().ok())).and_then(crate::params::finite);
|
||||
let why = "`position` must be a number of features to keep active, or \"end\"";
|
||||
Some(x.map(|x| x.max(0.0) as usize).ok_or_else(|| bad("timeline.roll_to", why))?)
|
||||
let x = x.ok_or_else(|| bad(cmd, why))?;
|
||||
// A negative position was taken as 0, rolling the whole timeline back: an agent
|
||||
// meaning "one step back" by -1 lost every body without a word (#4712).
|
||||
if x < 0.0 {
|
||||
return Err(bad(cmd, format!("`position` {x} is negative: it counts the features to keep active, 0 to {n} (or \"end\")")));
|
||||
}
|
||||
Some(x as usize)
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1057,7 +1074,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let to = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?.min(n.saturating_sub(1));
|
||||
let from = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if from == to {
|
||||
let active = active_param(p, cmd)?;
|
||||
if from == to && active.is_none() {
|
||||
return Ok(json!({"feature": id, "position": to}));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
@@ -1074,10 +1092,39 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
|
||||
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
place_marker(s, &mut next, &[id], active, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
/// The optional `active` of a move: whether the moved features end up before the history marker.
|
||||
fn active_param(p: &Value, cmd: &str) -> Result<Option<bool>> {
|
||||
match p.get("active") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(active)) => Ok(Some(*active)),
|
||||
Some(_) => Err(bad(cmd, "`active` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// With `active` given, put the history marker of the reordered `next` so the `moving` features
|
||||
/// are active (before it) or rolled back (behind it) while every other feature keeps its state, in
|
||||
/// the same step as the move. Refused when `position` puts them on the other side of it.
|
||||
fn place_marker(s: &Session, next: &mut solidcraft_doc::Document, moving: &[u64], active: Option<bool>, cmd: &str) -> Result<()> {
|
||||
let Some(active) = active else { return Ok(()) };
|
||||
let n = s.doc.features.len();
|
||||
let was_active: std::collections::BTreeSet<u64> = s.doc.features.iter().take(s.doc.marker.unwrap_or(n)).map(|feature| feature.id).collect();
|
||||
let stays_active = |id: u64| if moving.contains(&id) { active } else { was_active.contains(&id) };
|
||||
let count = next.features.iter().filter(|feature| stays_active(feature.id)).count();
|
||||
if !next.features.iter().take(count).all(|feature| stays_active(feature.id)) {
|
||||
let side = if active { "before" } else { "behind" };
|
||||
return Err(bad(cmd, format!("`position` is not {side} the history marker, as `active: {active}` asks")));
|
||||
}
|
||||
next.marker = (count < next.features.len()).then_some(count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.reorder_many";
|
||||
let ids = group_features(s, p, true, cmd)?;
|
||||
|
||||