Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 492e2d1c17 | |||
| a09ecc2ec6 | |||
| a077906234 | |||
| b2733d607a |
@@ -609,25 +609,6 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| assets/icons/ui/design/svg/model/timeline_stop.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_horizontal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_vertical.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_angle.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_parallel.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_perpendicular.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_tangent_external.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_tangent_at.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_tangent_two.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/construction_line_bisector.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-circle.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-circle.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-flower.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-flower.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-involute.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-involute.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-line.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-line.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-parabola.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-parabola.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-spiral.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-spiral.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/equation-wave.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-wave.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -130,21 +130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher 0.4.4",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -159,21 +148,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -530,15 +504,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -579,16 +544,6 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -800,16 +755,7 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -898,17 +844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.5",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1033,12 +969,6 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1148,9 +1078,9 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"block-padding 0.3.3",
|
||||
"cbc 0.1.2",
|
||||
"aes",
|
||||
"block-padding",
|
||||
"cbc",
|
||||
"dbus",
|
||||
"fastrand",
|
||||
"hkdf",
|
||||
@@ -1260,15 +1190,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecb"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
@@ -1785,7 +1706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2243,20 +2164,10 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||
dependencies = [
|
||||
"block-padding 0.4.2",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -2521,33 +2432,6 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"bitflags 2.13.2",
|
||||
"brotli-decompressor",
|
||||
"cbc 0.2.1",
|
||||
"ecb",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"nom",
|
||||
"rand 0.10.3",
|
||||
"rangemap",
|
||||
"sha2 0.11.1",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"weezl 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
@@ -2557,16 +2441,6 @@ dependencies = [
|
||||
"num-complex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -2750,15 +2624,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -3565,7 +3430,6 @@ version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
@@ -3610,12 +3474,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -4000,8 +3858,8 @@ version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"cbc 0.1.2",
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hkdf",
|
||||
@@ -4352,7 +4210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4388,11 +4246,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4404,7 +4262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4413,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4430,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4451,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4460,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4480,7 +4338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4504,7 +4362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4524,7 +4382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4533,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4542,12 +4400,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4560,21 +4418,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"lopdf",
|
||||
"minisign-verify",
|
||||
"nix 0.31.3",
|
||||
"pollster 0.4.0",
|
||||
@@ -4598,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4653,17 +4510,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4720,7 +4566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4785,7 +4631,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -4824,12 +4670,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -5135,21 +4975,6 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -5551,12 +5376,6 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "30.0.1"
|
||||
@@ -6179,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde",
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.1"
|
||||
version = "0.26.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -98,7 +98,7 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
|
||||
runs its own design session, or with `--connect` it drives the running app so you can watch.
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
|
||||
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
|
||||
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
|
||||
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
|
||||
[docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Scripts
|
||||
@@ -150,4 +150,3 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
|
||||
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://solidcraft.io/app
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ fn run() -> Result<(), String> {
|
||||
let mut session = Session::default();
|
||||
eprintln!("Generating original CAD scene from {recipe_path}");
|
||||
let generation_start = Instant::now();
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
|
||||
let generation_seconds = generation_start.elapsed().as_secs_f64();
|
||||
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
|
||||
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
@@ -58,7 +57,7 @@ struct Command {
|
||||
}
|
||||
|
||||
/// `run`'s lines in the usage.
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
/// `eval`'s lines in the usage.
|
||||
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
|
||||
/// `script`'s lines in the usage.
|
||||
@@ -300,36 +299,18 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
|
||||
run_script_warning(&mut s, path, &script)?;
|
||||
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
|
||||
// (stderr, so stdout stays the caller's).
|
||||
let mut warn = |step: usize, r: &Value| {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
};
|
||||
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
|
||||
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
|
||||
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
|
||||
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
|
||||
fn print_param_warnings(path: &str, step: usize, r: &Value) {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
|
||||
/// treated (docs/mcp.md); off when the option isn't given.
|
||||
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
|
||||
match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
|
||||
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
@@ -386,15 +367,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let json_out = json_out(args)?;
|
||||
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
|
||||
let check = parse_param_check("run", args)?;
|
||||
known_args(
|
||||
"run",
|
||||
args,
|
||||
&["--in", "--out", "--save", "--json-out", "--param-check"],
|
||||
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
|
||||
true,
|
||||
)?;
|
||||
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
@@ -407,25 +380,23 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
let (mut s, results) = if want_results || timings {
|
||||
// Collect every command's result or time (scripts only), then process requested files too.
|
||||
let mut s = match base {
|
||||
Some(b) => load_checked(b, check)?,
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
s.param_check = check;
|
||||
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
|
||||
let script = path.ok_or(needs)?;
|
||||
let v = read_json(script)?;
|
||||
let v = read_json(path.ok_or(needs)?)?;
|
||||
let out = if timings {
|
||||
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).map_err(|e| e.to_string())?;
|
||||
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
|
||||
if want_results && let Some(o) = t.as_object_mut() {
|
||||
o.insert("results".into(), Value::Array(r));
|
||||
}
|
||||
t
|
||||
} else {
|
||||
Value::Array(run_script_warning(&mut s, script, &v)?)
|
||||
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
|
||||
};
|
||||
(s, Some(out))
|
||||
} else {
|
||||
(open(base, path, check)?, None)
|
||||
(open(base, path)?, None)
|
||||
};
|
||||
// A feature that fails leaves the bodies as they were before it (a parameter change can
|
||||
// break a feature without failing the command that made it): writing them would hand out
|
||||
@@ -450,16 +421,16 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// The session `run`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
|
||||
/// positional script run on top of it, or either one alone, with scripts run at `check`.
|
||||
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
/// positional script run on top of it, or either one alone.
|
||||
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
|
||||
match (base, path) {
|
||||
(Some(b), Some(p)) => {
|
||||
let mut s = load_checked(b, check)?;
|
||||
run_script_warning(&mut s, p, &read_json(p)?)?;
|
||||
let mut s = load(b)?;
|
||||
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
|
||||
Ok(s)
|
||||
}
|
||||
(Some(b), None) => load_checked(b, check),
|
||||
(None, Some(p)) => load_checked(p, check),
|
||||
(Some(b), None) => load(b),
|
||||
(None, Some(p)) => load(p),
|
||||
(None, None) => Err(usage()),
|
||||
}
|
||||
}
|
||||
@@ -502,7 +473,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||||
let out = json!({
|
||||
@@ -596,7 +567,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
|
||||
};
|
||||
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
|
||||
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
// How the bodies are drawn and where the model is cut, through the commands the app's
|
||||
// Appearance dialog and Section Analysis run (`preview`).
|
||||
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
|
||||
@@ -720,8 +691,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How calls that break a command's parameter contract are treated, headless or bridged.
|
||||
let param_check = parse_param_check("mcp", args)?;
|
||||
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
|
||||
let param_check = match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
|
||||
None => solidcraft_engine::schema::ParamCheck::Off,
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
|
||||
}
|
||||
};
|
||||
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
|
||||
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
|
||||
}
|
||||
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => {
|
||||
@@ -729,7 +709,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
|
||||
};
|
||||
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
|
||||
if let Err(e) = remote.try_connect() {
|
||||
eprintln!(
|
||||
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
|
||||
@@ -749,8 +729,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
let auto = !args.iter().any(|a| a == "--headless");
|
||||
if auto && let Some(port) = discover_app() {
|
||||
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
|
||||
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
|
||||
return serve_mcp(Box::new(remote), scope);
|
||||
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
|
||||
}
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
|
||||
@@ -288,47 +288,3 @@ fn undoing_an_edit_after_opening_reuses_the_saved_results() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
|
||||
/// command would, and opening it is no change. That command pinned them as part of itself, so
|
||||
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
|
||||
/// undo after opening from the command line evaluated every feature again (82-93 s in a
|
||||
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
|
||||
#[test]
|
||||
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let mut doc = built.design().into_owned();
|
||||
for f in &mut doc.features {
|
||||
f.body_names.clear();
|
||||
}
|
||||
doc.reserved_body_names.clear();
|
||||
let unpinned = Session::new(doc);
|
||||
let design = unpinned.design();
|
||||
let saved = unpinned.model.saved(&design).unwrap();
|
||||
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
|
||||
.unwrap();
|
||||
let path = temp("unpinned");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let file = path.to_string_lossy().to_string();
|
||||
// As the command line opens a design (`load`), and as the app does (`doc.open`).
|
||||
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
|
||||
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
|
||||
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
|
||||
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
|
||||
assert!(!s.is_dirty(), "{how}: opening is no change");
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
|
||||
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
|
||||
// results in the model itself or evaluates them again.
|
||||
*s.model.cache.lock().unwrap() = Default::default();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
|
||||
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -496,78 +496,6 @@ fn timings_and_profile_report_each_command() {
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
|
||||
}
|
||||
|
||||
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
|
||||
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
|
||||
/// `--profile`), and a warning reaches stderr even when a later step fails.
|
||||
#[test]
|
||||
fn param_check_reaches_every_script_run_takes() {
|
||||
let dir = Scratch::new();
|
||||
let typo = dir.0.join("typo.json");
|
||||
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
|
||||
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let saved = dir.0.join("saved.solidcraft");
|
||||
let start = dir.script();
|
||||
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
|
||||
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
|
||||
let run = |level: Option<&str>, extra: &[&str]| {
|
||||
std::fs::remove_file(&saved).ok();
|
||||
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
|
||||
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
|
||||
};
|
||||
|
||||
// Off, the default: the step runs as it always has, without a word.
|
||||
let off = run(None, &[]);
|
||||
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
|
||||
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
|
||||
for extra in paths {
|
||||
let output = run(Some("warn"), extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
|
||||
assert!(saved.exists(), "{extra:?}");
|
||||
if extra == ["--results"] {
|
||||
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
|
||||
}
|
||||
}
|
||||
|
||||
// Strict: on each path the run stops at the step, and nothing is written.
|
||||
for extra in paths {
|
||||
let output = run(Some("strict"), extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
|
||||
assert!(!saved.exists(), "{extra:?}");
|
||||
}
|
||||
|
||||
// Warn, with a later step that fails: the run fails, and the first step's warning is still
|
||||
// reported, timed or not.
|
||||
let failing = dir.0.join("failing.json");
|
||||
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
|
||||
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
|
||||
for extra in [&[][..], &["--timings"]] {
|
||||
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
|
||||
}
|
||||
|
||||
// A level it doesn't know and a missing value fail.
|
||||
for (bad, says) in [
|
||||
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
|
||||
(&["--param-check"], "run: --param-check wants off, warn or strict"),
|
||||
] {
|
||||
let output = cli().args(["run", typo]).args(bad).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
|
||||
assert!(stderr.contains(says), "{bad:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
|
||||
let path = dir.0.join("render.json");
|
||||
std::fs::write(
|
||||
|
||||
@@ -34,7 +34,7 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
|
||||
|
||||
@@ -119,43 +119,6 @@ fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown:
|
||||
design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w")
|
||||
}
|
||||
|
||||
/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644).
|
||||
/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every
|
||||
/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a
|
||||
/// change made in another. A change is now written in the frame it is made, and the other tabs
|
||||
/// load it ([`settings_written`]).
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[derive(Default, Debug)]
|
||||
struct PrefsWriter {
|
||||
written: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
impl PrefsWriter {
|
||||
/// `prefs` if they are not the settings written or loaded last (they count as written from
|
||||
/// now on).
|
||||
fn take(&mut self, prefs: String) -> Option<String> {
|
||||
if self.written.as_deref() == Some(prefs.as_str()) {
|
||||
return None;
|
||||
}
|
||||
self.written = Some(prefs.clone());
|
||||
Some(prefs)
|
||||
}
|
||||
|
||||
/// The settings the tab holds after loading another tab's: the tab doesn't write them back.
|
||||
fn loaded(&mut self, prefs: String) {
|
||||
self.written = Some(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings another tab wrote, from a `storage` event (the browser sends one to every other
|
||||
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
|
||||
/// data), leave this tab's settings as they are.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
|
||||
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
@@ -254,24 +217,6 @@ mod web {
|
||||
on_key.forget();
|
||||
}
|
||||
|
||||
/// Settings another tab wrote, waiting for this tab's next frame (#8644).
|
||||
type PrefsInbox = Rc<std::cell::Cell<Option<String>>>;
|
||||
|
||||
/// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which
|
||||
/// loads them.
|
||||
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
|
||||
let ctx = ctx.clone();
|
||||
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
|
||||
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
|
||||
inbox.set(Some(prefs));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_storage.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
@@ -301,48 +246,9 @@ mod web {
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
|
||||
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
|
||||
prefs_writer: crate::PrefsWriter,
|
||||
other_tab_prefs: PrefsInbox,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// The settings as they are kept between visits: with the user's own language, not a
|
||||
/// session-only English fallback.
|
||||
fn persisted_prefs(&mut self) -> String {
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
let prefs = self.app.prefs();
|
||||
self.app.preferences.language = live;
|
||||
prefs
|
||||
}
|
||||
|
||||
/// A settings change goes to the browser's storage in the frame it is made, and from
|
||||
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
|
||||
/// without any is skipped; eframe's own save still catches anything else.
|
||||
fn store_prefs(&mut self, ctx: &egui::Context) {
|
||||
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let prefs = self.persisted_prefs();
|
||||
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
|
||||
let stored =
|
||||
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
|
||||
if !matches!(stored, Some(Ok(()))) {
|
||||
log::warn!("settings: the browser's storage did not take them; they are saved again later");
|
||||
self.prefs_writer = crate::PrefsWriter::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings another tab wrote replace this tab's, so this tab's own saves keep them
|
||||
/// instead of putting its older copy back (#8644).
|
||||
fn load_other_tabs_prefs(&mut self) {
|
||||
let Some(prefs) = self.other_tab_prefs.take() else { return };
|
||||
self.app.load_prefs(&prefs);
|
||||
let held = self.persisted_prefs();
|
||||
self.prefs_writer.loaded(held);
|
||||
}
|
||||
|
||||
/// Files from the computer, writes to store, messages and the autosave.
|
||||
fn files_tick(&mut self, ctx: &egui::Context) {
|
||||
if !self.launch.persists() {
|
||||
@@ -741,8 +647,6 @@ mod web {
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// First: a language another tab chose fetches its font this frame.
|
||||
self.load_other_tabs_prefs();
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.api.poll();
|
||||
@@ -785,7 +689,6 @@ mod web {
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
self.watch_text_for_font(&ctx);
|
||||
self.store_prefs(&ctx);
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
self.launch.persists()
|
||||
@@ -794,8 +697,11 @@ mod web {
|
||||
if !self.launch.persists() {
|
||||
return;
|
||||
}
|
||||
let prefs = self.persisted_prefs();
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,12 +822,6 @@ mod web {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
|
||||
app.embedded = launch.embed;
|
||||
// Listening before reading: settings another tab writes from now on are
|
||||
// newer than those read here.
|
||||
let other_tab_prefs = PrefsInbox::default();
|
||||
if launch.persists() {
|
||||
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
|
||||
}
|
||||
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
@@ -965,8 +865,6 @@ mod web {
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
prefs_writer: crate::PrefsWriter::default(),
|
||||
other_tab_prefs,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
@@ -1103,28 +1001,4 @@ mod tests {
|
||||
assert!(!closes_design("w", false, false, false, true));
|
||||
assert!(!closes_design("s", true, false, false, true));
|
||||
}
|
||||
|
||||
/// A tab writes its settings when they change, and not again while they stay the same; the
|
||||
/// settings it loaded from another tab aren't written back (#8644).
|
||||
#[test]
|
||||
fn changed_settings_are_written_once_and_loaded_ones_not_at_all() {
|
||||
let mut writer = PrefsWriter::default();
|
||||
assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written");
|
||||
assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again");
|
||||
assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once");
|
||||
writer.loaded(r#"{"a":3}"#.into());
|
||||
assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back");
|
||||
assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written");
|
||||
}
|
||||
|
||||
/// Only the settings another tab wrote are loaded: not other keys, and not settings taken
|
||||
/// away by clearing the site's data (#8644).
|
||||
#[test]
|
||||
fn only_settings_another_tab_wrote_are_loaded() {
|
||||
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
|
||||
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
|
||||
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
|
||||
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
|
||||
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,14 +475,6 @@ fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen:
|
||||
}
|
||||
}
|
||||
|
||||
/// What this app's control channel does beyond the methods every version has, for a client to
|
||||
/// find out before it relies on it (an older app answers `control.capabilities` with "unknown
|
||||
/// method"). `param_check`: the levels `engine.execute`, `engine.batch` and `engine.script` take
|
||||
/// as a request's own parameter check (#9184).
|
||||
fn capabilities() -> Value {
|
||||
json!({"param_check": solidcraft_engine::schema::ParamCheck::ALL.map(|l| l.name())})
|
||||
}
|
||||
|
||||
/// Write `reply` on the connection: through its subscription once it has one.
|
||||
fn answer(out: &mut TcpStream, events: Option<&Subscription>, reply: &Value) -> std::io::Result<()> {
|
||||
match events {
|
||||
@@ -543,20 +535,15 @@ fn serve_with(
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// A request that timed out may have finished since (#1618). `control.running` and
|
||||
// `control.capabilities` only look, so they leave that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| !matches!(method.as_str(), "control.running" | "control.capabilities")).and_then(|p| match p
|
||||
.reply
|
||||
.try_recv()
|
||||
{
|
||||
// A request that timed out may have finished since (#1618). `control.running` only
|
||||
// looks, so it leaves that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
});
|
||||
let mut r = if method == "control.running" {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else if method == "control.capabilities" {
|
||||
json!({"ok": true, "result": capabilities()})
|
||||
} else if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
@@ -763,9 +750,6 @@ mod tests {
|
||||
let mut watcher = open();
|
||||
let (r, rows) = running(&mut watcher, &|_| true);
|
||||
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
|
||||
// What the app's control channel does beyond the methods every version has (#9184).
|
||||
let r = watcher(json!({"id": 0, "method": "control.capabilities", "token": token}));
|
||||
assert_eq!((&r["ok"], &r["result"]["param_check"]), (&json!(true), &json!(["off", "warn", "strict"])), "{r}");
|
||||
|
||||
let mut export = open();
|
||||
let path = "/private/part.step";
|
||||
@@ -797,9 +781,6 @@ mod tests {
|
||||
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
|
||||
assert_eq!(own.len(), 1, "{r}");
|
||||
assert_eq!(own[0]["command"], "file.export", "{r}");
|
||||
// So may it ask what the app can do: answered at once, not refused as busy.
|
||||
let r = export(json!({"id": 8, "method": "control.capabilities"}));
|
||||
assert_eq!(r["result"]["param_check"], json!(["off", "warn", "strict"]), "{r}");
|
||||
|
||||
gate_tx.send(()).unwrap();
|
||||
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
|
||||
|
||||
@@ -1016,10 +1016,6 @@ fn main() -> eframe::Result {
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
app.session.mark_saved();
|
||||
app.session.undo.clear();
|
||||
// Built straight into the session, so nothing asked for a fit: frame it at the
|
||||
// home view as any opened design is (#9195). The fit waits for the first layout.
|
||||
app.cam.set_view(solidcraft_engine::render::StandardView::Iso);
|
||||
app.fit_view();
|
||||
}
|
||||
// Lock files next to open designs: a design open on another computer says so (#5467),
|
||||
// the files given at launch included.
|
||||
|
||||
@@ -188,7 +188,7 @@ surface = [
|
||||
sketch = [
|
||||
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
|
||||
"blend", "circle", "circle2", "circle3", "circle_tt",
|
||||
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
|
||||
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
|
||||
"extend", "finish", "fit_section", "include",
|
||||
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
|
||||
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
|
||||
@@ -204,10 +204,7 @@ constraint = [
|
||||
"c_polygon", "c_smooth", "c_symmetry", "c_tangent",
|
||||
]
|
||||
construction = [
|
||||
"centerline", "construction", "construction_line", "construction_line_angle",
|
||||
"construction_line_bisector", "construction_line_horizontal", "construction_line_parallel",
|
||||
"construction_line_perpendicular", "construction_line_tangent_at", "construction_line_tangent_external",
|
||||
"construction_line_tangent_two", "construction_line_vertical", "infinite",
|
||||
"centerline", "construction", "infinite",
|
||||
]
|
||||
mesh = [
|
||||
"mesh_body",
|
||||
|
||||
@@ -70,16 +70,6 @@ configuration_table false
|
||||
conic false
|
||||
constrainer false
|
||||
construction false
|
||||
construction_line false
|
||||
construction_line_angle false
|
||||
construction_line_bisector false
|
||||
construction_line_horizontal false
|
||||
construction_line_parallel false
|
||||
construction_line_perpendicular false
|
||||
construction_line_tangent_at false
|
||||
construction_line_tangent_external false
|
||||
construction_line_tangent_two false
|
||||
construction_line_vertical false
|
||||
contact_all false
|
||||
contact_new false
|
||||
contact_off false
|
||||
@@ -105,7 +95,6 @@ edit false
|
||||
ellipse false
|
||||
emboss false
|
||||
environment_map false
|
||||
equation_curve false
|
||||
error false
|
||||
explode false
|
||||
export false
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Construction line through two points</title>
|
||||
<path d="M8 52L56 12" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
|
||||
<circle cx="20" cy="42" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="20" cy="42" r="3.4" fill="#236ead"/>
|
||||
<circle cx="44" cy="22" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="44" cy="22" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 472 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Construction line at an angle</title>
|
||||
<path d="M10.26 48.66L58.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M28.65 33.23A24 24 0 0 1 34.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M10.26 48.66L54.26 11.66" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="10.26" cy="48.66" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="10.26" cy="48.66" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 646 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Angle bisector</title>
|
||||
<path d="M13.92 12.18L9.92 52.17L54.92 44.17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12.11 30.29A22 22 0 0 1 31.58 48.33" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M9.92 52.17L47.3 11.83" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 526 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Horizontal construction line</title>
|
||||
<path d="M8 32L56 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
|
||||
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 362 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Parallel construction line</title>
|
||||
<path d="M9 37L41 9" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23 55L55 27" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 380 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Perpendicular construction line</title>
|
||||
<path d="M8 44L56 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 8L32 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 34L42 34L42 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 490 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Tangent at a curve point</title>
|
||||
<circle cx="32" cy="34.26" r="17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M8 17.26L56 17.26" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="17.26" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="32" cy="17.26" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 480 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Tangent through an external point</title>
|
||||
<circle cx="21.74" cy="38.77" r="14" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M53.6 8.23L28.55 55.9" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="51.74" cy="11.77" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="51.74" cy="11.77" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 502 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Common tangent to two curves</title>
|
||||
<circle cx="20" cy="33.59" r="12" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="46" cy="33.59" r="8" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M6 19.27L58 27.36" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 509 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Vertical construction line</title>
|
||||
<path d="M32 8L32 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
|
||||
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 360 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Equation curve</title>
|
||||
<path d="M12 8L12 56M6 32L58 32" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 32C15.42 24.83 18.72 18 22.5 18C26.28 18 29.58 24.83 33 32C36.42 39.17 39.72 46 43.5 46C47.28 46 50.58 39.17 54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="12" cy="32" r="3.4" fill="#236ead"/>
|
||||
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 666 B |
@@ -852,16 +852,6 @@
|
||||
"cmd.sheet_refold": "Buk tilbage",
|
||||
"cmd.sheet_rules": "Vis pladeregler",
|
||||
"cmd.sheet_unfold": "Fold ud",
|
||||
"cmd.sketch.construction_line": "Hjælpelinje",
|
||||
"cmd.sketch.construction_line.angle": "Hjælpelinje med vinkel",
|
||||
"cmd.sketch.construction_line.bisector": "Vinkelhalveringslinje",
|
||||
"cmd.sketch.construction_line.horizontal": "Vandret hjælpelinje",
|
||||
"cmd.sketch.construction_line.parallel": "Parallel hjælpelinje",
|
||||
"cmd.sketch.construction_line.perpendicular": "Vinkelret hjælpelinje",
|
||||
"cmd.sketch.construction_line.tangent_at": "Tangent i kurvepunkt",
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangent gennem eksternt punkt",
|
||||
"cmd.sketch.construction_line.tangent_two": "Fælles tangent",
|
||||
"cmd.sketch.construction_line.vertical": "Lodret hjælpelinje",
|
||||
"cmd.sketch_arc_center_point": "Bue med centrum",
|
||||
"cmd.sketch_arc_tangent": "Tangentbue",
|
||||
"cmd.sketch_arc_three_point": "Trepunktsbue",
|
||||
@@ -1913,7 +1903,6 @@
|
||||
"ui.constant": "Konstant",
|
||||
"ui.constraints_conflict": "Bindinger er i konflikt",
|
||||
"ui.construction": "Konstruktionsgeometri",
|
||||
"ui.construction.ribbon_label": "Hjælpelinjer",
|
||||
"ui.context_menu": "Genvejsmenu",
|
||||
"ui.continuity_g0": "G0-knæk",
|
||||
"ui.continuity_g1": "G1-tangentkontinuitet",
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
{
|
||||
"A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges",
|
||||
"Base Center": "Base Centre",
|
||||
"Center X": "Centre X",
|
||||
"Center Y": "Centre Y",
|
||||
"Center of mass": "Centre of mass",
|
||||
"Centre to centre": "Centre to centre",
|
||||
"Circle center": "Circle centre",
|
||||
"Colour": "Colour",
|
||||
"Copy all licences": "Copy all licences",
|
||||
"Customize Toolbar": "Customise Toolbar",
|
||||
"Customize…": "Customise…",
|
||||
"Face center": "Face centre",
|
||||
"Favorites": "Favourites",
|
||||
"Favourite": "Favourite",
|
||||
"Mass Center": "Mass Centre",
|
||||
"Modeled": "Modelled",
|
||||
"Select the centre the pattern turns around first": "Select the centre the pattern turns around first",
|
||||
"Snap to face and edge centres": "Snap to face and edge centres",
|
||||
"Specialise": "Specialise",
|
||||
"cmd.appearance_favorite": "Favourite Look",
|
||||
"cmd.component_cycle_color": "Cycle Component Colour",
|
||||
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
|
||||
"cmd.inspect_center_of_mass": "Centre of Mass",
|
||||
"cmd.inspect_colored_components": "Enable Component Colours",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colours",
|
||||
"cmd.parameters_favorite": "Favourite Parameter",
|
||||
"cmd.render_aging_describe": "Layered Surface Aging",
|
||||
"cmd.render_effects_describe": "Lens and Creative Effects",
|
||||
"cmd.sketch.construction_line": "Construction Line",
|
||||
"cmd.sketch.construction_line.angle": "Construction Line at Angle",
|
||||
"cmd.sketch.construction_line.bisector": "Angle Bisector",
|
||||
"cmd.sketch.construction_line.horizontal": "Horizontal Construction Line",
|
||||
"cmd.sketch.construction_line.parallel": "Parallel Construction Line",
|
||||
"cmd.sketch.construction_line.perpendicular": "Perpendicular Construction Line",
|
||||
"cmd.sketch.construction_line.tangent_at": "Tangent at Curve Point",
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
|
||||
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
|
||||
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
|
||||
"cmd.sketch_arc_center_point": "Centre Point Arc",
|
||||
"cmd.sketch_circle_center": "Centre Diameter Circle",
|
||||
"cmd.sketch_rectangle_center": "Centre Rectangle",
|
||||
@@ -48,26 +28,7 @@
|
||||
"color.gamma": "Showing colour values in 0-255 gamma space",
|
||||
"color.linear": "Showing colour values in 0-1 linear space",
|
||||
"color.selected": "Selected colour",
|
||||
"dxf.cancelled": "DXF export cancelled",
|
||||
"dxf.cancelling": "Cancelling DXF export",
|
||||
"help.apache": "Apache licence 2.0",
|
||||
"help.appearance.assign": "Gives the selected bodies, faces or components a colour or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.",
|
||||
"help.face.delete": "Removes the selected faces and lets the neighbouring faces close the gap, or leaves the gap open as a surface. Use it to take off a rounded edge, a hole or a boss that is in the way.",
|
||||
"help.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans (SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.",
|
||||
"help.licences": "Licences",
|
||||
"help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.",
|
||||
"help.mit": "MIT licence",
|
||||
"help.solid.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on moulded or cast parts so that they slide out of the mould.",
|
||||
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
|
||||
"help.solid.split_body": "Cuts a body into separate bodies with a plane, the face of another body or a sketch line. Use it to make parts that are printed, machined or coloured on their own.",
|
||||
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.new_mm_hint": "Millimetres · opens in Design",
|
||||
"navbar.custom.customise": "Customise navigation bar…",
|
||||
"navbar.custom.position_centre": "Centre",
|
||||
"navbar.description.separator": "A thin line that groups neighbouring slots.",
|
||||
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
|
||||
"prefs.bottom_centre": "Bottom centre",
|
||||
"prefs.logo_theme": "Theme's colours",
|
||||
"prefs.max_file_size": "Largest file to open",
|
||||
"prefs.max_file_size_design": "Design files",
|
||||
@@ -78,9 +39,6 @@
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour",
|
||||
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.",
|
||||
"prefs.sketch_construction_lines": "Extended construction line tools",
|
||||
"prefs.sketch_recent_snap": "Auto snap to recent centres and midpoints",
|
||||
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centres or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
|
||||
"prefs.sketch_state_icons": "Sketch icons in the colour of their state",
|
||||
"prefs.sketch_state_icons_note": "In History and the browser, a sketch's icon takes the colour of its state (under constrained, fully constrained or conflicting) and its tooltip names the state. Off: every sketch has the same icon.",
|
||||
"prefs.sketch_tracking_guides": "Centre and midpoint alignment guides",
|
||||
@@ -92,7 +50,6 @@
|
||||
"prefs.theme_follow_note": "Reloaded whenever another tool rewrites it. Choosing a theme stops following it.",
|
||||
"prefs.theme_follow_off": "Off",
|
||||
"prefs.theme_follow_stop": "Stop following",
|
||||
"prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behaviour.",
|
||||
"prefs.view": "View behaviour",
|
||||
"prefs.viewport_background_colour": "Colour",
|
||||
"render.aging_add": "Add aging layer",
|
||||
@@ -122,7 +79,6 @@
|
||||
"render.aging_target_help": "A named target must still match its captured index and name. Global masks are world-anchored; they do not follow moving parts. This is neither cavity detection nor true edge wear. Filter the list to reach additional bodies.",
|
||||
"render.aging_target_search": "Filter body names",
|
||||
"render.aging_upward": "Upward-facing deposition",
|
||||
"render.appearance_editor.base_colour": "Base colour",
|
||||
"render.backdrop_first": "Start / centre colour",
|
||||
"render.backdrop_second": "End / edge colour",
|
||||
"render.backdrop_x": "Centre X",
|
||||
@@ -139,31 +95,21 @@
|
||||
"render.base_color": "Base colour · sRGB",
|
||||
"render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalised box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.",
|
||||
"render.chromatic_offset": "Chromatic edge offset",
|
||||
"render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points towards the emitting side. This differs from GPU half-edge vectors.",
|
||||
"render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colours are linear RGB and intensity is relative, not watts.",
|
||||
"render.cpu_linear_color": "Linear colour (R, G, B)",
|
||||
"render.effects_gpu_only": "These effects require GPU path tracing. Changing the page never switches the renderer automatically.",
|
||||
"render.effects_help": "Combine ordered colour looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.",
|
||||
"render.effects_reset_lens": "Reset lens effects",
|
||||
"render.effects_seed": "Pattern seed",
|
||||
"render.env_colour": "Colour",
|
||||
"render.environment.radial_only": "Centre and radius apply to Radial mode.",
|
||||
"render.error_finish_gesture": "finish the modelling gesture before previewing",
|
||||
"render.error_finish_operation": "finish the current modelling operation before entering Render",
|
||||
"render.film_grain": "Photographic grain",
|
||||
"render.flare_ghosts": "Ghost rings per source",
|
||||
"render.flare_halo": "Flare halo",
|
||||
"render.flare_streak": "Anamorphic-style streak",
|
||||
"render.flare_strength": "Highlight flare strength",
|
||||
"render.flare_threshold": "Scene-linear flare threshold",
|
||||
"render.frame_centre_mark": "Centre mark",
|
||||
"render.hdr_primaries_declared": "The source declares its colour primaries.",
|
||||
"render.hdr_primaries_srgb": "Colour primaries: sRGB/D65",
|
||||
"render.hdr_primaries_undeclared": "The source does not declare primaries; review the renderer colour requirements.",
|
||||
"render.image_cancelled": "Render cancelled",
|
||||
"render.image_cancelling": "Cancelling",
|
||||
"render.image_scene_stale": "Finish the modelling gesture or refresh the scene before checking or starting an image.",
|
||||
"render.inspector_colour": "Colour",
|
||||
"render.layer_down": "Move down",
|
||||
"render.layer_enabled": "Layer enabled",
|
||||
"render.layer_remove": "Remove layer",
|
||||
@@ -178,11 +124,6 @@
|
||||
"render.library_rig_help": "Fit once at an explicit centre and physical extent. Complete rigs include passive cards; calibration blockers still refuse. Conservative emitter/card clearance is checked before saving.",
|
||||
"render.library_source_preview_help": "Authored base-colour source map. This is not a rendered or measured material preview.",
|
||||
"render.library_unfavorite": "Remove favourite",
|
||||
"render.light_behaviour": "BEHAVIOUR",
|
||||
"render.light_preview_help": "Live view is unshadowed; Area lights use a centre-sample approximation. Render uses the complete light model.",
|
||||
"render.light_product_centre": "Product centre",
|
||||
"render.light_temperature": "Colour temperature",
|
||||
"render.lighting_colour": "Colour",
|
||||
"render.look_add": "Add colour look",
|
||||
"render.look_bleach": "Bleach-bypass-inspired",
|
||||
"render.look_cool": "Cool",
|
||||
@@ -206,45 +147,30 @@
|
||||
"render.radial_distortion": "Radial distortion",
|
||||
"render.ribbon_aging": "Aging",
|
||||
"render.ribbon_effects": "Effects",
|
||||
"render.ribbon_studios_help": "Browse implemented studio compositions and review their replacements before applying. Concept-only scenes remain labelled.",
|
||||
"render.sharpening": "Detail sharpening",
|
||||
"render.soft_focus": "Soft focus",
|
||||
"render.stage_panels.centre": "Centre",
|
||||
"render.stage_panels.source_note": "Authored base-colour source map. Not a measured material.",
|
||||
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.",
|
||||
"render.studio_center": "Centre X, Y, Z (mm)",
|
||||
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
|
||||
"sketch_text.align_center": "Centre",
|
||||
"ui.3d_mouse_kind_cap": "Return to Centre",
|
||||
"ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.",
|
||||
"ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre",
|
||||
"ui.3d_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
|
||||
"ui.3d_mouse_switch_cap": "Calibrate as Return to Centre",
|
||||
"ui.agent_chat_minimize": "Minimise to a strip",
|
||||
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
|
||||
"ui.center": "Centre",
|
||||
"ui.center_arc_follows_cursor": "Centre point arc follows the cursor's direction",
|
||||
"ui.center_arc_follows_cursor_tip": "A centre point arc runs clockwise or counter-clockwise the way the cursor goes round its centre. Off: always counter-clockwise from the start.",
|
||||
"ui.centerline": "Centreline",
|
||||
"ui.colour_vision": "Colour vision",
|
||||
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
|
||||
"ui.colour_vision_off_tip": "The theme's own colours",
|
||||
"ui.construction.ribbon_label": "X-Lines",
|
||||
"ui.customizeToolbar": "Customise Toolbar…",
|
||||
"ui.default_modelling_orientation": "Default modelling orientation",
|
||||
"ui.design_properties.license": "Licence",
|
||||
"ui.design_properties.organization": "Organisation",
|
||||
"ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file",
|
||||
"ui.layout_compact": "Compact",
|
||||
"ui.layout_floating": "Floating",
|
||||
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
|
||||
"ui.point_cloud_color_by": "Colour By",
|
||||
"ui.point_cloud_file_colors": "File Colours",
|
||||
"ui.point_cloud_no_file_colors": "The file has no colours",
|
||||
"ui.point_cloud_single_color": "Single Colour",
|
||||
"ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)",
|
||||
"ui.ribbon_labels": "Show text under ribbon icons",
|
||||
"ui.view_centre": "View centre",
|
||||
"ui.workspace_layout": "Workspace layout",
|
||||
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"en": {
|
||||
"render.cpu_glass_continue": "Continue reflections",
|
||||
"render.cpu_glass_help": "Standard uses bounded paths and may darken reflective glass. Continue reflections stops with an error if a supported optical limit is reached. Selected opaque reflections and unsupported glass shapes remain unavailable.",
|
||||
"render.cpu_glass_label": "Glass rendering",
|
||||
"render.cpu_glass_standard": "Standard"
|
||||
},
|
||||
"ar": {
|
||||
"render.cpu_glass_continue": "متابعة الانعكاسات",
|
||||
"render.cpu_glass_help": "يستخدم الوضع القياسي مسارات ضوء محدودة، وقد يجعل الزجاج العاكس أغمق. يوقف خيار «متابعة الانعكاسات» التصيير مع ظهور خطأ عند بلوغ أحد الحدود البصرية المدعومة. تظل الانعكاسات المحددة على الأجسام المعتمة وأشكال الزجاج غير المدعومة غير متاحة.",
|
||||
"render.cpu_glass_label": "تصيير الزجاج",
|
||||
"render.cpu_glass_standard": "قياسي"
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_glass_continue": "Reflexionen fortsetzen",
|
||||
"render.cpu_glass_help": "Standard begrenzt die Lichtwege und kann reflektierendes Glas abdunkeln. Reflexionen fortsetzen stoppt das Rendering mit einem Fehler, wenn eine unterstützte optische Grenze erreicht wird. Ausgewählte opake Reflexionen und nicht unterstützte Glasformen bleiben nicht verfügbar.",
|
||||
"render.cpu_glass_label": "Glasdarstellung",
|
||||
"render.cpu_glass_standard": "Standard"
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_glass_continue": "Continuar reflejos",
|
||||
"render.cpu_glass_help": "Estándar limita los recorridos y puede oscurecer el vidrio reflectante. Continuar reflejos detiene el render con un error si alcanza un límite óptico admitido. Los reflejos opacos seleccionados y las formas de vidrio no compatibles siguen sin estar disponibles.",
|
||||
"render.cpu_glass_label": "Render de vidrio",
|
||||
"render.cpu_glass_standard": "Estándar"
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_glass_continue": "המשך השתקפויות",
|
||||
"render.cpu_glass_help": "המצב הרגיל מגביל את מסלולי האור ועלול להכהות זכוכית מחזירת אור. האפשרות «המשך השתקפויות» עוצרת את הרינדור עם שגיאה כאשר מגיעים לאחת המגבלות האופטיות הנתמכות. השתקפויות נבחרות על גופים אטומים וצורות זכוכית שאינן נתמכות נשארות לא זמינות.",
|
||||
"render.cpu_glass_label": "רינדור זכוכית",
|
||||
"render.cpu_glass_standard": "רגיל"
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_glass_continue": "反射を継続",
|
||||
"render.cpu_glass_help": "標準では光路を制限するため、反射するガラスが暗くなる場合があります。反射を継続する設定では、対応する光学的な上限に達するとエラーでレンダリングを停止します。選択された不透明面の反射や未対応のガラス形状は引き続き使用できません。",
|
||||
"render.cpu_glass_label": "ガラスの描画",
|
||||
"render.cpu_glass_standard": "標準"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_glass_continue": "반사 계속",
|
||||
"render.cpu_glass_help": "표준은 광선 경로를 제한하므로 반사 유리가 어둡게 보일 수 있습니다. 반사 계속은 지원되는 광학 한계에 도달하면 오류와 함께 렌더링을 중지합니다. 선택한 불투명 표면 반사와 지원되지 않는 유리 형상은 계속 사용할 수 없습니다.",
|
||||
"render.cpu_glass_label": "유리 렌더링",
|
||||
"render.cpu_glass_standard": "표준"
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_glass_continue": "Tiếp tục phản xạ",
|
||||
"render.cpu_glass_help": "Chế độ tiêu chuẩn giới hạn đường truyền ánh sáng và có thể làm kính phản xạ trông tối hơn. Tùy chọn Tiếp tục phản xạ dừng kết xuất kèm lỗi khi đạt một giới hạn quang học được hỗ trợ. Các phản xạ đã chọn trên vật thể đục và các hình dạng kính không được hỗ trợ vẫn không khả dụng.",
|
||||
"render.cpu_glass_label": "Kết xuất kính",
|
||||
"render.cpu_glass_standard": "Tiêu chuẩn"
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_glass_continue": "继续反射",
|
||||
"render.cpu_glass_help": "标准模式限制光线路径,可能使反射玻璃变暗。继续反射模式在达到支持的光学限制时会报错并停止渲染。所选不透明表面反射和不支持的玻璃形状仍不可用。",
|
||||
"render.cpu_glass_label": "玻璃渲染",
|
||||
"render.cpu_glass_standard": "标准"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"en": {
|
||||
"render.cpu_directional_shadow_help": "CPU optics are temporary and require certified sheets or axis-aligned boxes with opaque body and face appearances. Directional transmitted shadows require global shadows on and all active assignments to agree. Mixed choices, nesting, environment lighting and local lights are unsupported in that mode. This is straight RGB visibility, not refracted shadows or caustics. Subsurface, coats and maps remain unsupported; fixed and progressive sampling retain finite-depth limits.",
|
||||
"render.cpu_shadow_conflict": "The global directional transmission option conflicts with an optical assignment whose transmitted shadows are off. No authored option was changed.",
|
||||
"render.cpu_shadow_mixed": "CPU uses one transmitted-shadow switch for all active optical assignments. Mixed on/off choices cannot be represented and have been preserved."
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_directional_shadow_help": "La óptica CPU es temporal y requiere láminas o cajas alineadas con los ejes certificadas, con aspectos opacos de cuerpo y caras. Las sombras transmitidas direccionales requieren sombras globales activadas y que todas las asignaciones activas coincidan. Ese modo no admite mezclas, nesting, iluminación de entorno ni luces locales. Es visibilidad RGB recta, sin sombras refractadas ni cáusticas. La dispersión subsuperficial, las capas y los mapas siguen sin admitirse; el muestreo fijo y progresivo conserva los límites de profundidad.",
|
||||
"render.cpu_shadow_conflict": "La opción global de transmisión direccional contradice una asignación óptica con sombras transmitidas desactivadas. No se ha cambiado ninguna opción definida.",
|
||||
"render.cpu_shadow_mixed": "CPU usa un único control de sombras transmitidas para todas las asignaciones ópticas activas. No puede representar opciones activadas y desactivadas mezcladas; se han conservado."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_directional_shadow_help": "CPU-Optik ist temporär und erfordert zertifizierte Scheiben oder achsenparallele Quader mit opaken Körper- und Flächenerscheinungen. Gerichtete durchgelassene Schatten erfordern globale Schatten und übereinstimmende aktive Zuweisungen. Gemischte Optionen, Verschachtelung, Umgebungslicht und lokale Lichter sind in diesem Modus nicht unterstützt. Dies ist geradlinige RGB-Sichtbarkeit, keine gebrochenen Schatten oder Kaustiken. Subsurface-Streuung, Beschichtungen und Maps bleiben nicht unterstützt; feste und progressive Abtastung behalten die endliche Tiefengrenze.",
|
||||
"render.cpu_shadow_conflict": "Die globale gerichtete Transmission widerspricht einer optischen Zuweisung mit ausgeschalteten durchgelassenen Schatten. Keine festgelegte Option wurde geändert.",
|
||||
"render.cpu_shadow_mixed": "CPU verwendet einen Schalter für durchgelassene Schatten aller aktiven optischen Zuweisungen. Gemischte Ein-/Aus-Werte sind nicht darstellbar und wurden beibehalten."
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_directional_shadow_help": "CPU 광학은 임시 설정이며 불투명한 바디와 면 외관을 가진 인증된 판 또는 축 정렬 상자가 필요합니다. 방향성 투과 그림자는 전역 그림자가 켜져 있고 모든 활성 할당의 설정이 같아야 합니다. 이 모드는 혼합 설정, 중첩, 환경 조명 및 로컬 광원을 지원하지 않습니다. 이는 직선 RGB 가시성이며 굴절 그림자나 코스틱이 아닙니다. 표면하 산란, 코팅 및 맵은 계속 지원하지 않으며 고정 및 점진적 샘플링은 유한 깊이 제한을 유지합니다.",
|
||||
"render.cpu_shadow_conflict": "전역 방향성 투과 옵션이 투과 그림자가 꺼진 광학 할당과 충돌합니다. 작성된 옵션은 변경되지 않았습니다.",
|
||||
"render.cpu_shadow_mixed": "CPU는 모든 활성 광학 할당에 하나의 투과 그림자 스위치를 사용합니다. 켜짐과 꺼짐이 섞인 설정은 표현할 수 없으며 그대로 보존되었습니다."
|
||||
},
|
||||
"ar": {
|
||||
"render.cpu_directional_shadow_help": "بصريات CPU مؤقتة وتتطلب صفائح أو صناديق محاذية للمحاور اجتازت التحقق، بمظاهر معتمة للجسم والأوجه. تتطلب الظلال النافذة الاتجاهية تفعيل الظلال العامة وتطابق كل التعيينات النشطة. لا يدعم هذا الوضع الخيارات المختلطة أو التداخل أو إضاءة البيئة أو الأضواء المحلية. هذه رؤية RGB مستقيمة وليست ظلالًا منكسرة أو كاوستيك. يبقى التشتت تحت السطحي والطلاءات والخرائط غير مدعوم، وتبقى حدود العمق المحدودة لأخذ العينات الثابت والتدريجي.",
|
||||
"render.cpu_shadow_conflict": "يتعارض خيار النفاذ الاتجاهي العام مع تعيين بصري تكون ظلاله النافذة معطلة. لم يتغير أي خيار محدد.",
|
||||
"render.cpu_shadow_mixed": "يستخدم CPU مفتاح ظلال نافذة واحدًا لجميع التعيينات البصرية النشطة. لا يمكن تمثيل خليط من التفعيل والتعطيل، وقد تم الاحتفاظ بالخيارات."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_directional_shadow_help": "אופטיקת CPU היא זמנית ודורשת יריעות או תיבות מיושרות לצירים שעברו אימות, עם מראה אטום של הגוף והפאות. צללים מועברים כיווניים דורשים צללים כלליים פעילים ואחידות בכל ההקצאות הפעילות. מצב זה אינו תומך בערכים מעורבים, קינון, תאורת סביבה או אורות מקומיים. זו נראות RGB בקו ישר, ללא צללים נשברים או קאוסטיקה. פיזור תת-משטחי, ציפויים ומפות עדיין אינם נתמכים; דגימה קבועה והדרגתית שומרות על מגבלות העומק הסופי.",
|
||||
"render.cpu_shadow_conflict": "אפשרות ההעברה הכיוונית הכללית סותרת הקצאה אופטית שהצללים המועברים שלה כבויים. שום אפשרות שנקבעה לא שונתה.",
|
||||
"render.cpu_shadow_mixed": "CPU משתמש במתג צללים מועברים אחד לכל ההקצאות האופטיות הפעילות. לא ניתן לייצג ערכים מעורבים של פעיל וכבוי, והם נשמרו."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_directional_shadow_help": "CPU 光学設定は一時的で、不透明なボディと面の外観を持つ認証済みの薄板または軸に平行なボックスが必要です。平行光源の透過シャドウには全体のシャドウを有効にし、すべての有効な割り当ての設定を一致させる必要があります。このモードでは混在設定、入れ子、環境光、ローカルライトは非対応です。これは直線上の RGB 可視性であり、屈折シャドウやコースティクスではありません。表面下散乱、コート、マップは引き続き非対応で、固定およびプログレッシブサンプリングでも有限深度制限は残ります。",
|
||||
"render.cpu_shadow_conflict": "全体の平行光透過オプションが、透過シャドウを無効にした光学割り当てと競合しています。設定したオプションは変更されていません。",
|
||||
"render.cpu_shadow_mixed": "CPU はすべての有効な光学割り当てに単一の透過シャドウスイッチを使用します。有効と無効の混在は表現できないため、その設定を保持しています。"
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_directional_shadow_help": "Quang học CPU là tạm thời và cần tấm hoặc hộp song song các trục đã được kiểm chứng, với diện mạo thân và mặt đục. Bóng truyền qua từ đèn định hướng cần bật bóng toàn cục và mọi phép gán đang bật phải cùng lựa chọn. Chế độ này không hỗ trợ lựa chọn lẫn bật/tắt, lồng nhau, ánh sáng môi trường hoặc đèn cục bộ. Đây là độ nhìn thấy RGB theo đường thẳng, không phải bóng khúc xạ hay caustic. Tán xạ dưới bề mặt, lớp phủ và bản đồ vẫn không được hỗ trợ; lấy mẫu cố định và lũy tiến vẫn có giới hạn độ sâu hữu hạn.",
|
||||
"render.cpu_shadow_conflict": "Tùy chọn truyền sáng định hướng toàn cục xung đột với phép gán quang học có bóng truyền qua đang tắt. Không thay đổi tùy chọn đã soạn nào.",
|
||||
"render.cpu_shadow_mixed": "CPU dùng một công tắc bóng truyền qua cho tất cả phép gán quang học đang bật. Không thể biểu diễn lựa chọn lẫn bật và tắt; các lựa chọn đã được giữ nguyên."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_directional_shadow_help": "CPU 光学设置是临时的,需要通过认证的薄片或轴对齐盒体,以及不透明的实体和面外观。方向光透射阴影要求开启全局阴影,并且所有启用的分配采用相同设置。此模式不支持混合开关、嵌套、环境照明或局部光源。这是直线 RGB 可见性,并非折射阴影或焦散。次表面散射、涂层和贴图仍不支持;固定和渐进采样仍保留有限深度限制。",
|
||||
"render.cpu_shadow_conflict": "全局方向光透射选项与关闭透射阴影的光学分配冲突。未更改任何已设置的选项。",
|
||||
"render.cpu_shadow_mixed": "CPU 对所有启用的光学分配使用一个透射阴影开关。无法表示开启和关闭混合的设置,已保留这些设置。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"en": {
|
||||
"render.cpu_optics_certification": "CPU Check and Start validate captured identity, opaque body and face appearances, and the requested sheet or axis-aligned box certificate. Choosing a boundary does not certify geometry. Start rechecks the capture; passing Check does not guarantee later admission."
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_optics_certification": "Comprobar e Iniciar en CPU validan la identidad capturada, los aspectos opacos del cuerpo y sus caras, y el certificado de lámina o caja alineada con los ejes solicitado. Elegir una frontera no certifica la geometría. Iniciar comprueba de nuevo la captura; superar Comprobar no garantiza una admisión posterior."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_optics_certification": "Prüfen und Starten auf der CPU validieren die erfasste Identität, opake Körper- und Flächenerscheinungen sowie das angeforderte Zertifikat für Scheiben oder achsenparallele Quader. Die Wahl einer Grenze zertifiziert keine Geometrie. Starten prüft die Erfassung erneut; erfolgreiches Prüfen garantiert keine spätere Zulassung."
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_optics_certification": "CPU의 확인과 시작은 캡처한 식별 정보, 불투명한 바디와 면 외관, 요청한 판 또는 축 정렬 상자의 인증을 검증합니다. 경계를 선택하는 것만으로 형상이 인증되지는 않습니다. 시작 시 캡처를 다시 확인하며, 확인에 성공해도 이후 승인이 보장되지는 않습니다."
|
||||
},
|
||||
"ar": {
|
||||
"render.cpu_optics_certification": "يتحقق الفحص والبدء على CPU من الهوية الملتقطة ومظاهر الجسم والأوجه المعتمة وشهادة الصفيحة أو الصندوق المحاذي للمحاور المطلوبة. اختيار حد لا يثبت صلاحية الهندسة. يعيد البدء فحص اللقطة؛ نجاح الفحص لا يضمن القبول لاحقًا."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_optics_certification": "בדיקה והתחלה ב-CPU מאמתות את הזהות שנלכדה, מראה אטום של הגוף והפאות ואת האישור המבוקש ליריעה או לתיבה מיושרת לצירים. בחירת גבול אינה מאשרת את הגאומטריה. התחלה בודקת שוב את הלכידה; הצלחה בבדיקה אינה מבטיחה אישור מאוחר יותר."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_optics_certification": "CPU の確認と開始では、取得した識別情報、不透明なボディと面の外観、要求された薄板または軸に平行なボックスの認証を検証します。境界の選択だけでは形状は認証されません。開始時に取得内容を再確認するため、確認の成功は後の受け入れを保証しません。"
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_optics_certification": "Kiểm tra và Bắt đầu trên CPU xác thực danh tính đã chụp, diện mạo thân và mặt đục, cùng chứng nhận tấm hoặc hộp song song các trục được yêu cầu. Chọn biên không chứng nhận hình học. Bắt đầu kiểm tra lại bản chụp; vượt qua Kiểm tra không bảo đảm được chấp nhận về sau."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_optics_certification": "CPU 的检查和开始会验证捕获的身份、不透明的实体和面外观,以及请求的薄片或轴对齐盒体认证。选择边界本身并不能认证几何体。开始时会重新检查捕获内容;通过检查并不保证之后仍能获准执行。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"en": {
|
||||
"render.cpu_optics_boundary": "CPU requires an explicit Thin sheet or Box boundary. The captured geometry must pass certification.",
|
||||
"render.cpu_optics_conflict": "CPU optical assignments cannot replace existing dielectric overrides.",
|
||||
"render.cpu_optics_enable": "Use these optics on CPU",
|
||||
"render.cpu_optics_explicit": "Review this assignment and explicitly enable its CPU optical route.",
|
||||
"render.cpu_optics_fixed": "The installed CPU transport cannot admit this optical sampling request.",
|
||||
"render.cpu_optics_help": "Temporary CPU optics: fixed or progressive sampling, opaque body and face appearances, certified sheets or axis-aligned boxes. Transmitted shadows, subsurface, coats and maps are unsupported. Box nesting needs explicit opt-in and certification. Accumulation retains the finite-depth transport limits.",
|
||||
"render.cpu_optics_limit": "CPU supports at most eight optical body assignments.",
|
||||
"render.cpu_optics_request": "CPU optics require the typed CPU image request and its existing options.",
|
||||
"render.cpu_optics_saved": "Saved GPU optics cannot be mixed with temporary CPU optical assignments.",
|
||||
"render.cpu_optics_unsupported": "This CPU route supports glass without transmitted shadows, subsurface, coats or maps. Review the authored options; they have been preserved."
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_optics_boundary": "CPU requiere elegir explícitamente Lámina fina o Caja. La geometría capturada debe superar la certificación.",
|
||||
"render.cpu_optics_conflict": "Las asignaciones ópticas CPU no pueden sustituir las modificaciones dieléctricas existentes.",
|
||||
"render.cpu_optics_enable": "Usar esta óptica en CPU",
|
||||
"render.cpu_optics_explicit": "Revisa esta asignación y activa explícitamente su ruta óptica CPU.",
|
||||
"render.cpu_optics_fixed": "El transporte CPU instalado no puede admitir esta solicitud de muestreo óptico.",
|
||||
"render.cpu_optics_help": "Óptica CPU temporal: muestreo fijo o progresivo, aspectos opacos de cuerpo y caras, láminas o cajas alineadas con los ejes certificadas. No admite sombras transmitidas, dispersión subsuperficial, capas ni mapas. Las cajas anidadas requieren activación explícita y certificación. La acumulación conserva los límites de profundidad del transporte.",
|
||||
"render.cpu_optics_limit": "CPU admite como máximo ocho asignaciones ópticas de cuerpo.",
|
||||
"render.cpu_optics_request": "La óptica CPU requiere la solicitud de imagen CPU tipada y sus opciones existentes.",
|
||||
"render.cpu_optics_saved": "La óptica GPU guardada no se puede mezclar con asignaciones ópticas CPU temporales.",
|
||||
"render.cpu_optics_unsupported": "Esta ruta CPU admite vidrio sin sombras transmitidas, dispersión subsuperficial, capas ni mapas. Revisa las opciones definidas; se han conservado."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_optics_boundary": "CPU erfordert die ausdrückliche Wahl von Dünner Scheibe oder Quader. Die erfasste Geometrie muss zertifiziert werden.",
|
||||
"render.cpu_optics_conflict": "Optische CPU-Zuweisungen können vorhandene dielektrische Überschreibungen nicht ersetzen.",
|
||||
"render.cpu_optics_enable": "Diese Optik auf der CPU verwenden",
|
||||
"render.cpu_optics_explicit": "Prüfen Sie diese Zuweisung und aktivieren Sie ihren optischen CPU-Pfad ausdrücklich.",
|
||||
"render.cpu_optics_fixed": "Der installierte CPU-Transport kann diese optische Abtastanforderung nicht zulassen.",
|
||||
"render.cpu_optics_help": "Temporäre CPU-Optik: feste oder progressive Abtastung, opake Körper- und Flächenerscheinungen, zertifizierte Scheiben oder achsenparallele Quader. Durchgelassene Schatten, Subsurface-Streuung, Beschichtungen und Maps werden nicht unterstützt. Verschachtelte Quader erfordern ausdrückliche Aktivierung und Zertifizierung. Die Akkumulation behält die endliche Transporttiefe bei.",
|
||||
"render.cpu_optics_limit": "CPU unterstützt höchstens acht optische Körperzuweisungen.",
|
||||
"render.cpu_optics_request": "CPU-Optik erfordert die typisierte CPU-Bildanforderung und deren vorhandene Optionen.",
|
||||
"render.cpu_optics_saved": "Gespeicherte GPU-Optik kann nicht mit temporären optischen CPU-Zuweisungen kombiniert werden.",
|
||||
"render.cpu_optics_unsupported": "Dieser CPU-Pfad unterstützt Glas ohne durchgelassene Schatten, Subsurface-Streuung, Beschichtungen oder Maps. Prüfen Sie die unverändert erhaltenen Optionen."
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_optics_boundary": "CPU에서는 얇은 판 또는 상자 경계를 명시적으로 선택해야 합니다. 캡처한 형상은 인증을 통과해야 합니다.",
|
||||
"render.cpu_optics_conflict": "CPU 광학 할당은 기존 유전체 재정의를 대체할 수 없습니다.",
|
||||
"render.cpu_optics_enable": "이 광학 설정을 CPU에서 사용",
|
||||
"render.cpu_optics_explicit": "이 할당을 검토하고 CPU 광학 경로를 명시적으로 활성화하세요.",
|
||||
"render.cpu_optics_fixed": "설치된 CPU 전송 모델은 이 광학 샘플링 요청을 허용할 수 없습니다.",
|
||||
"render.cpu_optics_help": "임시 CPU 광학: 고정 또는 점진적 샘플링, 불투명한 바디와 면 외관, 인증된 판 또는 축 정렬 상자. 투과 그림자, 표면하 산란, 코팅 및 맵은 지원하지 않습니다. 중첩 상자는 명시적 활성화와 인증이 필요합니다. 누적해도 전송의 유한 깊이 제한은 유지됩니다.",
|
||||
"render.cpu_optics_limit": "CPU는 최대 8개의 광학 바디 할당을 지원합니다.",
|
||||
"render.cpu_optics_request": "CPU 광학에는 형식화된 CPU 이미지 요청과 기존 옵션이 필요합니다.",
|
||||
"render.cpu_optics_saved": "저장된 GPU 광학을 임시 CPU 광학 할당과 혼합할 수 없습니다.",
|
||||
"render.cpu_optics_unsupported": "이 CPU 경로는 투과 그림자, 표면하 산란, 코팅 및 맵이 없는 유리를 지원합니다. 작성된 옵션을 검토하세요. 옵션은 그대로 보존되었습니다."
|
||||
},
|
||||
"ar": {
|
||||
"render.cpu_optics_boundary": "يتطلب CPU اختيار حد صفيحة رقيقة أو صندوق صراحةً. يجب أن تجتاز الهندسة الملتقطة التحقق.",
|
||||
"render.cpu_optics_conflict": "لا يمكن لتعيينات البصريات على CPU استبدال تجاوزات العوازل الموجودة.",
|
||||
"render.cpu_optics_enable": "استخدام هذه البصريات على CPU",
|
||||
"render.cpu_optics_explicit": "راجع هذا التعيين وفعّل مساره البصري على CPU صراحةً.",
|
||||
"render.cpu_optics_fixed": "لا يمكن لنموذج النقل المثبت على CPU قبول طلب أخذ العينات البصري هذا.",
|
||||
"render.cpu_optics_help": "بصريات CPU مؤقتة: أخذ عينات ثابت أو تدريجي، ومظاهر معتمة للجسم والأوجه، وصفائح أو صناديق محاذية للمحاور اجتازت التحقق. الظلال النافذة والتشتت تحت السطحي والطلاءات والخرائط غير مدعومة. تتطلب الصناديق المتداخلة تفعيلًا صريحًا والتحقق. يبقى عمق النقل محدودًا حتى مع التراكم.",
|
||||
"render.cpu_optics_limit": "يدعم CPU ثمانية تعيينات بصرية للأجسام كحد أقصى.",
|
||||
"render.cpu_optics_request": "تتطلب بصريات CPU طلب صورة CPU محدد النوع وخياراته الموجودة.",
|
||||
"render.cpu_optics_saved": "لا يمكن خلط بصريات GPU المحفوظة مع تعيينات بصريات CPU المؤقتة.",
|
||||
"render.cpu_optics_unsupported": "يدعم مسار CPU هذا الزجاج دون ظلال نافذة أو تشتت تحت سطحي أو طلاءات أو خرائط. راجع الخيارات المحددة؛ فقد تم الاحتفاظ بها."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_optics_boundary": "CPU דורש בחירה מפורשת בגבול יריעה דקה או תיבה. הגאומטריה שנלכדה חייבת לעבור אימות.",
|
||||
"render.cpu_optics_conflict": "הקצאות אופטיות של CPU אינן יכולות להחליף דריסות דיאלקטריות קיימות.",
|
||||
"render.cpu_optics_enable": "שימוש באופטיקה זו ב-CPU",
|
||||
"render.cpu_optics_explicit": "יש לבדוק הקצאה זו ולהפעיל במפורש את הנתיב האופטי שלה ב-CPU.",
|
||||
"render.cpu_optics_fixed": "מודל התעבורה המותקן ב-CPU אינו יכול לאשר בקשת דגימה אופטית זו.",
|
||||
"render.cpu_optics_help": "אופטיקה זמנית ב-CPU: דגימה קבועה או הדרגתית, מראה אטום של הגוף והפאות, יריעות או תיבות מיושרות לצירים שעברו אימות. צללים מועברים, פיזור תת-משטחי, ציפויים ומפות אינם נתמכים. תיבות מקוננות דורשות הפעלה מפורשת ואימות. הצבירה שומרת על מגבלת עומק התעבורה הסופית.",
|
||||
"render.cpu_optics_limit": "CPU תומך בעד שמונה הקצאות אופטיות לגופים.",
|
||||
"render.cpu_optics_request": "אופטיקת CPU דורשת בקשת תמונת CPU מטיפוס מוגדר ואת האפשרויות הקיימות שלה.",
|
||||
"render.cpu_optics_saved": "לא ניתן לערבב אופטיקת GPU שמורה עם הקצאות אופטיות זמניות של CPU.",
|
||||
"render.cpu_optics_unsupported": "נתיב CPU זה תומך בזכוכית ללא צללים מועברים, פיזור תת-משטחי, ציפויים או מפות. יש לבדוק את האפשרויות שנקבעו; הן נשמרו ללא שינוי."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_optics_boundary": "CPU では薄板またはボックス境界を明示的に選択する必要があります。取得した形状は認証に合格する必要があります。",
|
||||
"render.cpu_optics_conflict": "CPU の光学割り当てで既存の誘電体オーバーライドを置き換えることはできません。",
|
||||
"render.cpu_optics_enable": "この光学設定を CPU で使用",
|
||||
"render.cpu_optics_explicit": "この割り当てを確認し、CPU 光学経路を明示的に有効にしてください。",
|
||||
"render.cpu_optics_fixed": "インストール済みの CPU 光輸送では、この光学サンプリング要求を受け付けられません。",
|
||||
"render.cpu_optics_help": "一時的な CPU 光学設定:固定またはプログレッシブサンプリング、不透明なボディと面の外観、認証済みの薄板または軸に平行なボックス。透過シャドウ、表面下散乱、コート、マップは非対応です。入れ子のボックスには明示的な有効化と認証が必要です。累積しても光輸送の有限深度制限は残ります。",
|
||||
"render.cpu_optics_limit": "CPU はボディへの光学割り当てを最大 8 個までサポートします。",
|
||||
"render.cpu_optics_request": "CPU 光学設定には型付きの CPU 画像リクエストと既存のオプションが必要です。",
|
||||
"render.cpu_optics_saved": "保存済み GPU 光学設定と一時的な CPU 光学割り当ては混在できません。",
|
||||
"render.cpu_optics_unsupported": "この CPU 経路は透過シャドウ、表面下散乱、コート、マップのないガラスに対応します。設定したオプションを確認してください。内容は保持されています。"
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_optics_boundary": "CPU yêu cầu chọn rõ ràng biên Tấm mỏng hoặc Hộp. Hình học đã chụp phải vượt qua kiểm chứng.",
|
||||
"render.cpu_optics_conflict": "Các gán quang học CPU không thể thay thế các ghi đè điện môi hiện có.",
|
||||
"render.cpu_optics_enable": "Dùng quang học này trên CPU",
|
||||
"render.cpu_optics_explicit": "Hãy xem lại phép gán này và bật rõ ràng tuyến quang học CPU của nó.",
|
||||
"render.cpu_optics_fixed": "Mô hình truyền ánh sáng CPU đã cài không thể chấp nhận yêu cầu lấy mẫu quang học này.",
|
||||
"render.cpu_optics_help": "Quang học CPU tạm thời: lấy mẫu cố định hoặc lũy tiến, diện mạo thân và mặt đục, tấm hoặc hộp song song các trục đã được kiểm chứng. Không hỗ trợ bóng truyền qua, tán xạ dưới bề mặt, lớp phủ và bản đồ. Hộp lồng nhau cần được bật rõ ràng và kiểm chứng. Tích lũy vẫn giữ giới hạn độ sâu hữu hạn của truyền ánh sáng.",
|
||||
"render.cpu_optics_limit": "CPU hỗ trợ tối đa tám phép gán quang học cho thân.",
|
||||
"render.cpu_optics_request": "Quang học CPU yêu cầu yêu cầu ảnh CPU có kiểu cùng các tùy chọn hiện có.",
|
||||
"render.cpu_optics_saved": "Không thể trộn quang học GPU đã lưu với các gán quang học CPU tạm thời.",
|
||||
"render.cpu_optics_unsupported": "Tuyến CPU này hỗ trợ kính không có bóng truyền qua, tán xạ dưới bề mặt, lớp phủ hoặc bản đồ. Hãy xem lại các tùy chọn đã soạn; chúng đã được giữ nguyên."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_optics_boundary": "CPU 要求明确选择薄片或盒体边界。捕获的几何体必须通过认证。",
|
||||
"render.cpu_optics_conflict": "CPU 光学分配不能替换现有的电介质覆盖设置。",
|
||||
"render.cpu_optics_enable": "在 CPU 上使用此光学设置",
|
||||
"render.cpu_optics_explicit": "请检查此分配并明确启用其 CPU 光学路径。",
|
||||
"render.cpu_optics_fixed": "已安装的 CPU 光传输无法接受此光学采样请求。",
|
||||
"render.cpu_optics_help": "临时 CPU 光学设置:固定或渐进采样、不透明的实体和面外观、通过认证的薄片或轴对齐盒体。不支持透射阴影、次表面散射、涂层或贴图。嵌套盒体需要明确启用并通过认证。累积仍保留光传输的有限深度限制。",
|
||||
"render.cpu_optics_limit": "CPU 最多支持八个实体光学分配。",
|
||||
"render.cpu_optics_request": "CPU 光学设置要求类型化的 CPU 图像请求及其现有选项。",
|
||||
"render.cpu_optics_saved": "已保存的 GPU 光学设置不能与临时 CPU 光学分配混用。",
|
||||
"render.cpu_optics_unsupported": "此 CPU 路径支持不含透射阴影、次表面散射、涂层或贴图的玻璃。请检查所编写的选项;这些选项已保留。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"de": {
|
||||
"render.cpu_pass_progress": "Vollständige Durchgänge: {current} / {target} · vorläufig"
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_pass_progress": "Complete passes: {current} / {target} · provisional"
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_pass_progress": "Pasadas completas: {current} / {target} · provisional"
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_pass_progress": "完了したパス: {current} / {target} · 暫定"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_pass_progress": "완료된 패스: {current} / {target} · 임시"
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_pass_progress": "完整遍数:{current} / {target} · 临时结果"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.product_exposure_invalid": "يجب أن يكون تعريض معاينة النموذج قيمة محدودة بين −32 و32 EV."
|
||||
},
|
||||
"de": {
|
||||
"render.product_exposure_invalid": "Die Belichtung der Modellvorschau muss endlich sein und zwischen −32 und 32 EV liegen."
|
||||
},
|
||||
"en": {
|
||||
"render.product_exposure_invalid": "Model Preview exposure must be finite and between −32 and 32 EV."
|
||||
},
|
||||
"es": {
|
||||
"render.product_exposure_invalid": "La exposición de la vista previa del modelo debe ser finita y estar entre −32 y 32 EV."
|
||||
},
|
||||
"he": {
|
||||
"render.product_exposure_invalid": "חשיפת תצוגת המודל המקדימה חייבת להיות סופית ובטווח שבין −32 ל־32 EV."
|
||||
},
|
||||
"ja": {
|
||||
"render.product_exposure_invalid": "モデルプレビューの露出は −32~32 EV の有限値にしてください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.product_exposure_invalid": "모델 미리보기 노출은 −32~32 EV 범위의 유한한 값이어야 합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.product_exposure_invalid": "Phơi sáng xem trước Mô hình phải là số hữu hạn trong khoảng −32 đến 32 EV."
|
||||
},
|
||||
"zh": {
|
||||
"render.product_exposure_invalid": "模型预览曝光必须是 −32 到 32 EV 之间的有限值。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.preview_environment_fixed": "تتطلب معاينة النموذج دوران بيئة بقيمة 0 وشدة بقيمة 1.",
|
||||
"render.raster_exposure_help": "يضبط تعريض التصيير النقطي من −32 إلى 32 EV بعد التظليل. يحافظ الصفر على الاستجابة الحالية، دون تغيير الأضواء أو المواد.",
|
||||
"render.raster_exposure_invalid": "يجب أن يكون تعريض التصيير النقطي قيمة محدودة بين −32 و32 EV."
|
||||
},
|
||||
"de": {
|
||||
"render.preview_environment_fixed": "Die Modellvorschau benötigt eine Umgebungsdrehung von 0 und eine Intensität von 1.",
|
||||
"render.raster_exposure_help": "Passt die Rasterbelichtung nach der Schattierung von −32 bis 32 EV an. Null erhält die bisherige Darstellung; Licht und Materialien bleiben unverändert.",
|
||||
"render.raster_exposure_invalid": "Die Rasterbelichtung muss endlich sein und zwischen −32 und 32 EV liegen."
|
||||
},
|
||||
"en": {
|
||||
"render.preview_environment_fixed": "Model Preview requires environment rotation 0 and intensity 1.",
|
||||
"render.raster_exposure_help": "Adjusts Raster exposure from −32 to 32 EV after shading. Zero preserves the current response; lights and materials stay unchanged.",
|
||||
"render.raster_exposure_invalid": "Raster exposure must be finite and between −32 and 32 EV."
|
||||
},
|
||||
"es": {
|
||||
"render.preview_environment_fixed": "La vista previa del modelo requiere una rotación del entorno de 0 y una intensidad de 1.",
|
||||
"render.raster_exposure_help": "Ajusta la exposición Raster de −32 a 32 EV después del sombreado. Cero conserva la respuesta actual; no cambia luces ni materiales.",
|
||||
"render.raster_exposure_invalid": "La exposición Raster debe ser finita y estar entre −32 y 32 EV."
|
||||
},
|
||||
"he": {
|
||||
"render.preview_environment_fixed": "תצוגת המודל המקדימה מחייבת סיבוב סביבה של 0 ועוצמה של 1.",
|
||||
"render.raster_exposure_help": "התאמת חשיפת רסטר בין −32 ל־32 EV לאחר ההצללה. אפס שומר על התוצאה הנוכחית; התאורה והחומרים אינם משתנים.",
|
||||
"render.raster_exposure_invalid": "חשיפת הרסטר חייבת להיות סופית ובטווח שבין −32 ל־32 EV."
|
||||
},
|
||||
"ja": {
|
||||
"render.preview_environment_fixed": "モデルプレビューでは、環境の回転を 0、強度を 1 にしてください。",
|
||||
"render.raster_exposure_help": "シェーディング後のラスター露出を −32~32 EV で調整します。0 は現在の表示を維持し、ライトやマテリアルは変更しません。",
|
||||
"render.raster_exposure_invalid": "ラスター露出は −32~32 EV の有限値にしてください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.preview_environment_fixed": "모델 미리보기에서는 환경 회전이 0이고 강도가 1이어야 합니다.",
|
||||
"render.raster_exposure_help": "셰이딩 후 래스터 노출을 −32~32 EV로 조정합니다. 0은 현재 표현을 유지하며 조명과 재질은 변경하지 않습니다.",
|
||||
"render.raster_exposure_invalid": "래스터 노출은 −32~32 EV 범위의 유한한 값이어야 합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.preview_environment_fixed": "Xem trước Mô hình yêu cầu góc xoay môi trường là 0 và cường độ là 1.",
|
||||
"render.raster_exposure_help": "Điều chỉnh phơi sáng Raster từ −32 đến 32 EV sau khi tô bóng. Giá trị 0 giữ cách hiển thị hiện tại; ánh sáng và vật liệu không thay đổi.",
|
||||
"render.raster_exposure_invalid": "Phơi sáng Raster phải là số hữu hạn trong khoảng −32 đến 32 EV."
|
||||
},
|
||||
"zh": {
|
||||
"render.preview_environment_fixed": "模型预览要求环境旋转为 0,强度为 1。",
|
||||
"render.raster_exposure_help": "在着色后将光栅曝光调整为 −32 到 32 EV。0 保留当前效果,不更改灯光和材质。",
|
||||
"render.raster_exposure_invalid": "光栅曝光必须是 −32 到 32 EV 之间的有限值。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"en": {
|
||||
"render.glass_library_conflict": "This body already has optical settings. Edit or remove them before preparing a different recipe.",
|
||||
"render.glass_library_edit": "Prepare physical glass",
|
||||
"render.glass_library_help": "Prepares the recipe in the existing editor without assigning an appearance or rendering. CPU needs explicit optical permission and a Thin sheet or Box that passes actual certification. Closed-mesh and shadow settings stay unchanged.",
|
||||
"render.glass_library_method": "Choose CPU or GPU rendering to prepare physical glass. Raster has no physical-glass route.",
|
||||
"render.glass_library_prepared": "Physical glass recipe prepared for editing; review its boundary and optical settings before rendering."
|
||||
},
|
||||
"es": {
|
||||
"render.glass_library_conflict": "Este cuerpo ya tiene ajustes ópticos. Edítalos o elimínalos antes de preparar otra receta.",
|
||||
"render.glass_library_edit": "Preparar vidrio físico",
|
||||
"render.glass_library_help": "Prepara la receta en el editor existente sin asignar un aspecto ni renderizar. CPU necesita permiso óptico explícito y una lámina o caja que supere la certificación real. No cambia los ajustes de malla cerrada ni de sombras.",
|
||||
"render.glass_library_method": "Elige renderizado CPU o GPU para preparar vidrio físico. Raster no tiene una ruta de vidrio físico.",
|
||||
"render.glass_library_prepared": "Receta de vidrio físico preparada para editar; revisa su frontera y sus ajustes ópticos antes de renderizar."
|
||||
},
|
||||
"de": {
|
||||
"render.glass_library_conflict": "Dieser Körper hat bereits optische Einstellungen. Bearbeite oder entferne sie, bevor du ein anderes Rezept vorbereitest.",
|
||||
"render.glass_library_edit": "Physikalisches Glas vorbereiten",
|
||||
"render.glass_library_help": "Bereitet das Rezept im vorhandenen Editor vor, ohne ein Aussehen zuzuweisen oder zu rendern. CPU benötigt eine ausdrückliche Freigabe der Optik und eine dünne Scheibe oder einen Quader mit tatsächlicher Zertifizierung. Einstellungen für geschlossene Netze und Schatten bleiben unverändert.",
|
||||
"render.glass_library_method": "Wähle CPU- oder GPU-Rendering, um physikalisches Glas vorzubereiten. Raster unterstützt kein physikalisches Glas.",
|
||||
"render.glass_library_prepared": "Glasrezept zur Bearbeitung vorbereitet; prüfe vor dem Rendern seine Begrenzung und optischen Einstellungen."
|
||||
},
|
||||
"ko": {
|
||||
"render.glass_library_conflict": "이 바디에는 이미 광학 설정이 있습니다. 다른 레시피를 준비하기 전에 기존 설정을 편집하거나 제거하세요.",
|
||||
"render.glass_library_edit": "물리 기반 유리 준비",
|
||||
"render.glass_library_help": "모양을 지정하거나 렌더링하지 않고 기존 편집기에서 레시피를 준비합니다. CPU에는 명시적인 광학 허용과 실제 검증을 통과하는 얇은 판 또는 상자가 필요합니다. 닫힌 메시와 그림자 설정은 변경하지 않습니다.",
|
||||
"render.glass_library_method": "물리 기반 유리를 준비하려면 CPU 또는 GPU 렌더링을 선택하세요. Raster는 물리 기반 유리를 지원하지 않습니다.",
|
||||
"render.glass_library_prepared": "편집할 유리 레시피를 준비했습니다. 렌더링 전에 경계와 광학 설정을 검토하세요."
|
||||
},
|
||||
"ar": {
|
||||
"render.glass_library_conflict": "لهذا الجسم إعدادات بصرية بالفعل. عدّلها أو أزلها قبل تجهيز وصفة مختلفة.",
|
||||
"render.glass_library_edit": "تجهيز زجاج فيزيائي",
|
||||
"render.glass_library_help": "يجهّز الوصفة في المحرر الحالي دون تعيين مظهر أو تصيير. يتطلب CPU إذنًا بصريًا صريحًا وصفيحة رقيقة أو صندوقًا يجتاز التحقق الفعلي. تبقى إعدادات الشبكة المغلقة والظلال دون تغيير.",
|
||||
"render.glass_library_method": "اختر التصيير عبر CPU أو GPU لتجهيز زجاج فيزيائي. لا يدعم Raster الزجاج الفيزيائي.",
|
||||
"render.glass_library_prepared": "جُهّزت وصفة الزجاج للتحرير؛ راجع حدودها وإعداداتها البصرية قبل التصيير."
|
||||
},
|
||||
"he": {
|
||||
"render.glass_library_conflict": "לגוף הזה כבר יש הגדרות אופטיות. יש לערוך או להסיר אותן לפני הכנת מתכון אחר.",
|
||||
"render.glass_library_edit": "הכנת זכוכית פיזיקלית",
|
||||
"render.glass_library_help": "מכין את המתכון בעורך הקיים בלי להקצות מראה או לרנדר. CPU דורש אישור אופטי מפורש ולוח דק או תיבה שעוברים אימות בפועל. הגדרות רשת סגורה וצללים אינן משתנות.",
|
||||
"render.glass_library_method": "יש לבחור רינדור CPU או GPU כדי להכין זכוכית פיזיקלית. Raster אינו תומך בזכוכית פיזיקלית.",
|
||||
"render.glass_library_prepared": "מתכון הזכוכית הוכן לעריכה; יש לבדוק את הגבול ואת ההגדרות האופטיות לפני הרינדור."
|
||||
},
|
||||
"ja": {
|
||||
"render.glass_library_conflict": "このボディには既に光学設定があります。別のレシピを準備する前に、既存の設定を編集または削除してください。",
|
||||
"render.glass_library_edit": "物理ガラスを準備",
|
||||
"render.glass_library_help": "外観の割り当てやレンダリングを行わず、既存のエディターでレシピを準備します。CPU では光学処理の明示的な許可と、実際の認証に合格する薄板またはボックスが必要です。閉じたメッシュと影の設定は変更しません。",
|
||||
"render.glass_library_method": "物理ガラスを準備するには CPU または GPU レンダリングを選択してください。Raster は物理ガラスに対応していません。",
|
||||
"render.glass_library_prepared": "編集用のガラスレシピを準備しました。レンダリング前に境界と光学設定を確認してください。"
|
||||
},
|
||||
"vi": {
|
||||
"render.glass_library_conflict": "Thân này đã có thiết lập quang học. Hãy sửa hoặc xóa chúng trước khi chuẩn bị công thức khác.",
|
||||
"render.glass_library_edit": "Chuẩn bị kính vật lý",
|
||||
"render.glass_library_help": "Chuẩn bị công thức trong trình chỉnh sửa hiện có mà không gán diện mạo hay kết xuất. CPU cần cho phép quang học rõ ràng và tấm mỏng hoặc hộp vượt qua kiểm chứng thực tế. Không đổi thiết lập lưới kín và bóng đổ.",
|
||||
"render.glass_library_method": "Chọn kết xuất CPU hoặc GPU để chuẩn bị kính vật lý. Raster không hỗ trợ kính vật lý.",
|
||||
"render.glass_library_prepared": "Đã chuẩn bị công thức kính để chỉnh sửa; hãy kiểm tra biên và thiết lập quang học trước khi kết xuất."
|
||||
},
|
||||
"zh": {
|
||||
"render.glass_library_conflict": "此实体已有光学设置。准备其他配方前,请编辑或移除现有设置。",
|
||||
"render.glass_library_edit": "准备物理玻璃",
|
||||
"render.glass_library_help": "在现有编辑器中准备配方,不分配外观,也不进行渲染。CPU 需要明确启用光学处理,并使用通过实际认证的薄片或盒体。封闭网格和阴影设置保持不变。",
|
||||
"render.glass_library_method": "请选择 CPU 或 GPU 渲染来准备物理玻璃。Raster 不支持物理玻璃。",
|
||||
"render.glass_library_prepared": "玻璃配方已准备好供编辑;渲染前请检查边界和光学设置。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.render.interactive": "Interactive Render",
|
||||
"help.render.interactive": "Explicitly enable a provisional CPU preview while editing. Final render settings and exportable images stay unchanged.",
|
||||
"render.interactive_error": "Interactive preview error",
|
||||
"render.interactive_feedback_milestones_complete": "No more preview updates are scheduled for this render.",
|
||||
"render.interactive_feedback_policy": "Preview updates are limited per render; final image settings stay unchanged.",
|
||||
"render.interactive_feedback_quota_exhausted": "Preview update limit reached for this render.",
|
||||
"render.interactive_feedback_usage": "{started}/{limit} preview updates started for this render.",
|
||||
"render.interactive_final_active": "Final render active",
|
||||
"render.interactive_off": "Off",
|
||||
"render.interactive_on": "On",
|
||||
"render.interactive_partial_dimensions": "Display {display_width} × {display_height} px · Sampling {source_width} × {source_height} px · Output {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "Not enough remaining work budget for another partial image.",
|
||||
"render.interactive_partial_samples": "Samples/pixel {min}–{max} · {known}/{pixels} source pixels sampled",
|
||||
"render.interactive_pause": "Pause",
|
||||
"render.interactive_paused": "Paused",
|
||||
"render.interactive_provisional": "Provisional preview",
|
||||
"render.interactive_refining": "Refining preview",
|
||||
"render.interactive_requires_cpu": "Interactive preview requires CPU ray tracing.",
|
||||
"render.interactive_resume": "Resume",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "Out of date",
|
||||
"render.interactive_target": "Sample target reached",
|
||||
"render.interactive_updating": "Updating"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.render.interactive": "تصيير تفاعلي",
|
||||
"help.render.interactive": "فعّل معاينة مؤقتة باستخدام المعالج CPU أثناء التحرير. تبقى إعدادات التصيير النهائي والصور القابلة للتصدير دون تغيير.",
|
||||
"render.interactive_error": "خطأ في المعاينة التفاعلية",
|
||||
"render.interactive_feedback_milestones_complete": "لم تُجدول تحديثات معاينة أخرى لهذا التصيير.",
|
||||
"render.interactive_feedback_policy": "تحديثات المعاينة محدودة لكل تصيير؛ تبقى إعدادات الصورة النهائية دون تغيير.",
|
||||
"render.interactive_feedback_quota_exhausted": "تم بلوغ حد تحديثات المعاينة لهذا التصيير.",
|
||||
"render.interactive_feedback_usage": "بدأ {started}/{limit} تحديثًا للمعاينة لهذا التصيير.",
|
||||
"render.interactive_final_active": "التصيير النهائي قيد التنفيذ",
|
||||
"render.interactive_off": "إيقاف",
|
||||
"render.interactive_on": "تفعيل",
|
||||
"render.interactive_partial_dimensions": "العرض {display_width} × {display_height} بكسل · أخذ العينات {source_width} × {source_height} بكسل · الإخراج {final_width} × {final_height} بكسل",
|
||||
"render.interactive_partial_exhausted": "لا تكفي ميزانية المعالجة المتبقية لصورة جزئية أخرى.",
|
||||
"render.interactive_partial_samples": "عينات لكل بكسل {min}–{max} · أُخذت عينات من {known}/{pixels} بكسل من المصدر",
|
||||
"render.interactive_pause": "إيقاف مؤقت",
|
||||
"render.interactive_paused": "متوقف مؤقتًا",
|
||||
"render.interactive_provisional": "معاينة مؤقتة",
|
||||
"render.interactive_refining": "جارٍ تحسين المعاينة",
|
||||
"render.interactive_requires_cpu": "تتطلب المعاينة التفاعلية تتبّع الأشعة على المعالج CPU.",
|
||||
"render.interactive_resume": "استئناف",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "غير محدثة",
|
||||
"render.interactive_target": "تم بلوغ عدد العينات المطلوب",
|
||||
"render.interactive_updating": "جارٍ التحديث"
|
||||
},
|
||||
"de": {
|
||||
"cmd.render.interactive": "Interaktives Rendering",
|
||||
"help.render.interactive": "Aktivieren Sie die vorläufige CPU-Vorschau zum Bearbeiten manuell. Einstellungen für das finale Rendering und exportierbare Bilder bleiben unverändert.",
|
||||
"render.interactive_error": "Fehler in der interaktiven Vorschau",
|
||||
"render.interactive_feedback_milestones_complete": "Für dieses Rendering sind keine weiteren Vorschauaktualisierungen geplant.",
|
||||
"render.interactive_feedback_policy": "Vorschauaktualisierungen sind pro Rendering begrenzt; die Einstellungen des finalen Bildes bleiben unverändert.",
|
||||
"render.interactive_feedback_quota_exhausted": "Limit für Vorschauaktualisierungen dieses Renderings erreicht.",
|
||||
"render.interactive_feedback_usage": "{started}/{limit} Vorschauaktualisierungen für dieses Rendering gestartet.",
|
||||
"render.interactive_final_active": "Finales Rendering aktiv",
|
||||
"render.interactive_off": "Aus",
|
||||
"render.interactive_on": "Ein",
|
||||
"render.interactive_partial_dimensions": "Anzeige {display_width} × {display_height} px · Abtastung {source_width} × {source_height} px · Ausgabe {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "Das verbleibende Rechenbudget reicht nicht für ein weiteres Teilbild.",
|
||||
"render.interactive_partial_samples": "Samples/Pixel {min}–{max} · {known}/{pixels} Quellpixel abgetastet",
|
||||
"render.interactive_pause": "Pausieren",
|
||||
"render.interactive_paused": "Pausiert",
|
||||
"render.interactive_provisional": "Vorläufige Vorschau",
|
||||
"render.interactive_refining": "Vorschau wird verfeinert",
|
||||
"render.interactive_requires_cpu": "Die interaktive Vorschau erfordert CPU-Raytracing.",
|
||||
"render.interactive_resume": "Fortsetzen",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "Nicht aktuell",
|
||||
"render.interactive_target": "Zielanzahl an Samples erreicht",
|
||||
"render.interactive_updating": "Wird aktualisiert"
|
||||
},
|
||||
"es": {
|
||||
"cmd.render.interactive": "Render interactivo",
|
||||
"help.render.interactive": "Activa explícitamente una vista previa provisional por CPU mientras editas. Los ajustes del render final y las imágenes exportables se mantienen intactos.",
|
||||
"render.interactive_error": "Error en la vista previa interactiva",
|
||||
"render.interactive_feedback_milestones_complete": "No hay más actualizaciones de vista previa programadas para este renderizado.",
|
||||
"render.interactive_feedback_policy": "Las actualizaciones de vista previa se limitan por renderizado; los ajustes de la imagen final no cambian.",
|
||||
"render.interactive_feedback_quota_exhausted": "Se ha alcanzado el límite de actualizaciones de vista previa de este renderizado.",
|
||||
"render.interactive_feedback_usage": "Se han iniciado {started}/{limit} actualizaciones de vista previa para este renderizado.",
|
||||
"render.interactive_final_active": "Render final activo",
|
||||
"render.interactive_off": "Desactivar",
|
||||
"render.interactive_on": "Activar",
|
||||
"render.interactive_partial_dimensions": "Visualización {display_width} × {display_height} px · Muestreo {source_width} × {source_height} px · Salida {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "El presupuesto de cálculo restante no permite otra imagen parcial.",
|
||||
"render.interactive_partial_samples": "Muestras/píxel {min}–{max} · {known}/{pixels} píxeles de origen muestreados",
|
||||
"render.interactive_pause": "Pausar",
|
||||
"render.interactive_paused": "En pausa",
|
||||
"render.interactive_provisional": "Vista previa provisional",
|
||||
"render.interactive_refining": "Refinando vista previa",
|
||||
"render.interactive_requires_cpu": "La vista previa interactiva requiere trazado de rayos por CPU.",
|
||||
"render.interactive_resume": "Reanudar",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "Desactualizada",
|
||||
"render.interactive_target": "Número objetivo de muestras alcanzado",
|
||||
"render.interactive_updating": "Actualizando"
|
||||
},
|
||||
"he": {
|
||||
"cmd.render.interactive": "רינדור אינטראקטיבי",
|
||||
"help.render.interactive": "יש להפעיל במפורש תצוגה מקדימה זמנית במעבד CPU בזמן העריכה. הגדרות הרינדור הסופי והתמונות הזמינות לייצוא נשארות ללא שינוי.",
|
||||
"render.interactive_error": "שגיאה בתצוגה המקדימה האינטראקטיבית",
|
||||
"render.interactive_feedback_milestones_complete": "לא מתוכננים עדכוני תצוגה מקדימה נוספים לרינדור זה.",
|
||||
"render.interactive_feedback_policy": "עדכוני התצוגה המקדימה מוגבלים לכל רינדור; הגדרות התמונה הסופית אינן משתנות.",
|
||||
"render.interactive_feedback_quota_exhausted": "הושגה מגבלת עדכוני התצוגה המקדימה לרינדור זה.",
|
||||
"render.interactive_feedback_usage": "החלו {started}/{limit} עדכוני תצוגה מקדימה לרינדור זה.",
|
||||
"render.interactive_final_active": "הרינדור הסופי פעיל",
|
||||
"render.interactive_off": "כיבוי",
|
||||
"render.interactive_on": "הפעלה",
|
||||
"render.interactive_partial_dimensions": "תצוגה {display_width} × {display_height} פיקסלים · דגימה {source_width} × {source_height} פיקסלים · פלט {final_width} × {final_height} פיקסלים",
|
||||
"render.interactive_partial_exhausted": "תקציב החישוב שנותר אינו מספיק לתמונה חלקית נוספת.",
|
||||
"render.interactive_partial_samples": "דגימות לפיקסל {min}–{max} · נדגמו {known}/{pixels} פיקסלים במקור",
|
||||
"render.interactive_pause": "השהיה",
|
||||
"render.interactive_paused": "מושהה",
|
||||
"render.interactive_provisional": "תצוגה מקדימה זמנית",
|
||||
"render.interactive_refining": "משפר את התצוגה המקדימה",
|
||||
"render.interactive_requires_cpu": "תצוגה מקדימה אינטראקטיבית דורשת עקיבת קרניים במעבד CPU.",
|
||||
"render.interactive_resume": "המשך",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "לא עדכני",
|
||||
"render.interactive_target": "הושג מספר הדגימות המבוקש",
|
||||
"render.interactive_updating": "מעדכן"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.render.interactive": "インタラクティブレンダリング",
|
||||
"help.render.interactive": "編集中にCPUによる暫定プレビューを明示的に有効にします。最終レンダリングの設定とエクスポート可能な画像は変更しません。",
|
||||
"render.interactive_error": "インタラクティブプレビューのエラー",
|
||||
"render.interactive_feedback_milestones_complete": "このレンダリングでは、これ以上のプレビュー更新は予定されていません。",
|
||||
"render.interactive_feedback_policy": "プレビュー更新はレンダリングごとに制限されます。最終画像の設定は変わりません。",
|
||||
"render.interactive_feedback_quota_exhausted": "このレンダリングのプレビュー更新上限に達しました。",
|
||||
"render.interactive_feedback_usage": "このレンダリングで開始したプレビュー更新:{started}/{limit}。",
|
||||
"render.interactive_final_active": "最終レンダリング中",
|
||||
"render.interactive_off": "無効にする",
|
||||
"render.interactive_on": "有効にする",
|
||||
"render.interactive_partial_dimensions": "表示 {display_width} × {display_height} px · サンプリング {source_width} × {source_height} px · 出力 {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "残りの計算予算では、次の部分画像を生成できません。",
|
||||
"render.interactive_partial_samples": "ピクセルあたり {min}~{max} サンプル · 元画像の {known}/{pixels} ピクセルをサンプリング済み",
|
||||
"render.interactive_pause": "一時停止",
|
||||
"render.interactive_paused": "一時停止中",
|
||||
"render.interactive_provisional": "暫定プレビュー",
|
||||
"render.interactive_refining": "プレビューを改善中",
|
||||
"render.interactive_requires_cpu": "インタラクティブプレビューにはCPUレイトレーシングが必要です。",
|
||||
"render.interactive_resume": "再開",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "最新ではありません",
|
||||
"render.interactive_target": "目標サンプル数に到達",
|
||||
"render.interactive_updating": "更新中"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.render.interactive": "대화형 렌더링",
|
||||
"help.render.interactive": "편집하는 동안 임시 CPU 미리 보기를 직접 켭니다. 최종 렌더링 설정과 내보낼 수 있는 이미지는 변경하지 않습니다.",
|
||||
"render.interactive_error": "대화형 미리 보기 오류",
|
||||
"render.interactive_feedback_milestones_complete": "이 렌더링에는 추가 미리 보기 업데이트가 예정되어 있지 않습니다.",
|
||||
"render.interactive_feedback_policy": "미리 보기 업데이트는 렌더링별로 제한되며 최종 이미지 설정은 바뀌지 않습니다.",
|
||||
"render.interactive_feedback_quota_exhausted": "이 렌더링의 미리 보기 업데이트 한도에 도달했습니다.",
|
||||
"render.interactive_feedback_usage": "이 렌더링에서 시작한 미리 보기 업데이트: {started}/{limit}.",
|
||||
"render.interactive_final_active": "최종 렌더링 진행 중",
|
||||
"render.interactive_off": "끄기",
|
||||
"render.interactive_on": "켜기",
|
||||
"render.interactive_partial_dimensions": "표시 {display_width} × {display_height} px · 샘플링 {source_width} × {source_height} px · 출력 {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "남은 연산 예산으로는 부분 이미지를 더 만들 수 없습니다.",
|
||||
"render.interactive_partial_samples": "픽셀당 샘플 {min}–{max} · 원본 픽셀 {known}/{pixels}개 샘플링됨",
|
||||
"render.interactive_pause": "일시 중지",
|
||||
"render.interactive_paused": "일시 중지됨",
|
||||
"render.interactive_provisional": "임시 미리 보기",
|
||||
"render.interactive_refining": "미리 보기 개선 중",
|
||||
"render.interactive_requires_cpu": "대화형 미리 보기에는 CPU 레이 트레이싱이 필요합니다.",
|
||||
"render.interactive_resume": "계속",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "최신 상태가 아님",
|
||||
"render.interactive_target": "목표 샘플 수 도달",
|
||||
"render.interactive_updating": "업데이트 중"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.render.interactive": "Kết xuất tương tác",
|
||||
"help.render.interactive": "Chủ động bật bản xem trước tạm thời bằng CPU khi chỉnh sửa. Thiết lập kết xuất cuối và các ảnh có thể xuất vẫn được giữ nguyên.",
|
||||
"render.interactive_error": "Lỗi xem trước tương tác",
|
||||
"render.interactive_feedback_milestones_complete": "Không còn cập nhật xem trước nào được lên lịch cho lần kết xuất này.",
|
||||
"render.interactive_feedback_policy": "Số lần cập nhật xem trước được giới hạn theo từng lần kết xuất; cài đặt ảnh cuối cùng không thay đổi.",
|
||||
"render.interactive_feedback_quota_exhausted": "Đã đạt giới hạn cập nhật xem trước cho lần kết xuất này.",
|
||||
"render.interactive_feedback_usage": "Đã bắt đầu {started}/{limit} cập nhật xem trước cho lần kết xuất này.",
|
||||
"render.interactive_final_active": "Đang kết xuất ảnh cuối",
|
||||
"render.interactive_off": "Tắt",
|
||||
"render.interactive_on": "Bật",
|
||||
"render.interactive_partial_dimensions": "Hiển thị {display_width} × {display_height} px · Lấy mẫu {source_width} × {source_height} px · Đầu ra {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "Ngân sách tính toán còn lại không đủ cho một ảnh từng phần nữa.",
|
||||
"render.interactive_partial_samples": "Mẫu/điểm ảnh {min}–{max} · Đã lấy mẫu {known}/{pixels} điểm ảnh nguồn",
|
||||
"render.interactive_pause": "Tạm dừng",
|
||||
"render.interactive_paused": "Đã tạm dừng",
|
||||
"render.interactive_provisional": "Bản xem trước tạm thời",
|
||||
"render.interactive_refining": "Đang cải thiện bản xem trước",
|
||||
"render.interactive_requires_cpu": "Xem trước tương tác yêu cầu dò tia bằng CPU.",
|
||||
"render.interactive_resume": "Tiếp tục",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "Chưa cập nhật",
|
||||
"render.interactive_target": "Đã đạt số mẫu mục tiêu",
|
||||
"render.interactive_updating": "Đang cập nhật"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.render.interactive": "交互式渲染",
|
||||
"help.render.interactive": "编辑时手动启用临时 CPU 预览。最终渲染设置和可导出的图像保持不变。",
|
||||
"render.interactive_error": "交互式预览错误",
|
||||
"render.interactive_feedback_milestones_complete": "本次渲染不再计划新的预览更新。",
|
||||
"render.interactive_feedback_policy": "每次渲染的预览更新次数有限;最终图像设置保持不变。",
|
||||
"render.interactive_feedback_quota_exhausted": "本次渲染的预览更新已达到上限。",
|
||||
"render.interactive_feedback_usage": "本次渲染已启动 {started}/{limit} 次预览更新。",
|
||||
"render.interactive_final_active": "最终渲染进行中",
|
||||
"render.interactive_off": "关闭",
|
||||
"render.interactive_on": "启用",
|
||||
"render.interactive_partial_dimensions": "显示 {display_width} × {display_height} px · 采样 {source_width} × {source_height} px · 输出 {final_width} × {final_height} px",
|
||||
"render.interactive_partial_exhausted": "剩余计算预算不足以生成另一张部分图像。",
|
||||
"render.interactive_partial_samples": "每像素样本数 {min}–{max} · 已采样 {known}/{pixels} 个源像素",
|
||||
"render.interactive_pause": "暂停",
|
||||
"render.interactive_paused": "已暂停",
|
||||
"render.interactive_provisional": "临时预览",
|
||||
"render.interactive_refining": "正在细化预览",
|
||||
"render.interactive_requires_cpu": "交互式预览需要 CPU 光线追踪。",
|
||||
"render.interactive_resume": "继续",
|
||||
"render.interactive_samples": "{done}/{total} SPP",
|
||||
"render.interactive_stale": "不是最新状态",
|
||||
"render.interactive_target": "已达到目标采样数",
|
||||
"render.interactive_updating": "正在更新"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"ar": {
|
||||
"prefs.render_open_result_on_start": "فتح النتيجة عند بدء التصيير",
|
||||
"prefs.render_open_result_on_start_help": "افتح النتيجة مرة واحدة بعد بدء التصيير بنجاح. عطّل هذا الخيار للاحتفاظ بالعرض المحدد. يمكنك العودة إلى النموذج أثناء التصيير."
|
||||
},
|
||||
"de": {
|
||||
"prefs.render_open_result_on_start": "Ergebnis beim Renderstart öffnen",
|
||||
"prefs.render_open_result_on_start_help": "Nach erfolgreichem Renderstart einmal das Ergebnis öffnen. Deaktivieren, um die gewählte Ansicht beizubehalten. Während des Renderns können Sie zum Modell zurückkehren."
|
||||
},
|
||||
"en": {
|
||||
"prefs.render_open_result_on_start": "Open Result when rendering starts",
|
||||
"prefs.render_open_result_on_start_help": "After starting a render successfully, open Result once. Turn off to keep your selected view. You can return to Model while rendering."
|
||||
},
|
||||
"es": {
|
||||
"prefs.render_open_result_on_start": "Abrir Resultado al iniciar el renderizado",
|
||||
"prefs.render_open_result_on_start_help": "Abre Resultado una vez tras iniciar correctamente el renderizado. Desactiva esta opción para conservar la vista seleccionada. Puedes volver a Modelo durante el renderizado."
|
||||
},
|
||||
"he": {
|
||||
"prefs.render_open_result_on_start": "פתיחת התוצאה עם תחילת הרינדור",
|
||||
"prefs.render_open_result_on_start_help": "פתיחת התוצאה פעם אחת לאחר התחלת רינדור בהצלחה. יש לבטל אפשרות זו כדי לשמור על התצוגה שנבחרה. אפשר לחזור למודל בזמן הרינדור."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.render_open_result_on_start": "レンダリング開始時に結果を開く",
|
||||
"prefs.render_open_result_on_start_help": "レンダリングの開始に成功したら、結果を一度だけ開きます。無効にすると、選択中の表示を維持します。レンダリング中でもモデルに戻れます。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.render_open_result_on_start": "렌더링 시작 시 결과 열기",
|
||||
"prefs.render_open_result_on_start_help": "렌더링이 성공적으로 시작되면 결과를 한 번 엽니다. 끄면 선택한 보기가 유지됩니다. 렌더링 중에도 모델로 돌아갈 수 있습니다."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.render_open_result_on_start": "Mở Kết quả khi bắt đầu kết xuất",
|
||||
"prefs.render_open_result_on_start_help": "Mở Kết quả một lần sau khi bắt đầu kết xuất thành công. Tắt để giữ chế độ xem đã chọn. Bạn có thể quay lại Mô hình trong khi kết xuất."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.render_open_result_on_start": "开始渲染时打开结果",
|
||||
"prefs.render_open_result_on_start_help": "成功开始渲染后打开结果一次。关闭此选项可保留当前选中的视图。渲染期间也可以返回模型视图。"
|
||||
}
|
||||
}
|
||||
@@ -709,16 +709,6 @@
|
||||
"cmd.sheet_refold": "Zegnij ponownie",
|
||||
"cmd.sheet_rules": "Lista reguł blach",
|
||||
"cmd.sheet_unfold": "Rozegnij",
|
||||
"cmd.sketch.construction_line": "Prosta konstrukcyjna",
|
||||
"cmd.sketch.construction_line.angle": "Prosta konstrukcyjna pod kątem",
|
||||
"cmd.sketch.construction_line.bisector": "Dwusieczna kąta",
|
||||
"cmd.sketch.construction_line.horizontal": "Pozioma prosta konstrukcyjna",
|
||||
"cmd.sketch.construction_line.parallel": "Równoległa prosta konstrukcyjna",
|
||||
"cmd.sketch.construction_line.perpendicular": "Prostopadła prosta konstrukcyjna",
|
||||
"cmd.sketch.construction_line.tangent_at": "Styczna w punkcie krzywej",
|
||||
"cmd.sketch.construction_line.tangent_external": "Styczna przez punkt zewnętrzny",
|
||||
"cmd.sketch.construction_line.tangent_two": "Wspólna styczna",
|
||||
"cmd.sketch.construction_line.vertical": "Pionowa prosta konstrukcyjna",
|
||||
"cmd.sketch_arc_center_point": "Łuk ze środka",
|
||||
"cmd.sketch_arc_tangent": "Łuk styczny",
|
||||
"cmd.sketch_arc_three_point": "Łuk przez 3 punkty",
|
||||
@@ -1310,10 +1300,6 @@
|
||||
"shortcut.pin": "Przypnij na górze pola S",
|
||||
"shortcut.unpin": "Odepnij",
|
||||
"sketch.degrees_of_freedom": "Stopnie swobody: {count}",
|
||||
"sketch.dimension_added_driven": "Szkic już określa ten wymiar, więc dodano go jako wymiar sterowany (referencyjny)",
|
||||
"sketch.dimension_applied": "Wymiar zastosowany",
|
||||
"sketch.dimension_applied_defined": "Wymiar zastosowany; zdefiniowano parametr `{name}`",
|
||||
"sketch.dimension_applied_renamed": "Wymiar zastosowany; parametr nazywa się teraz `{name}`",
|
||||
"sketch.dof_left": "Pozostałe stopnie swobody: {count}",
|
||||
"sketch.dof_left_one": "Pozostał {count} stopień swobody",
|
||||
"sketch.draw_curves": "Rysuj jako {linetype}",
|
||||
@@ -1749,7 +1735,6 @@
|
||||
"ui.constant": "Stały",
|
||||
"ui.constraints_conflict": "Konflikt wiązań",
|
||||
"ui.construction": "Konstrukcyjne",
|
||||
"ui.construction.ribbon_label": "Linie pom.",
|
||||
"ui.context_menu": "Menu kontekstowe",
|
||||
"ui.continuity_g0": "G0 załamanie",
|
||||
"ui.continuity_g1": "G1 styczność",
|
||||
@@ -2129,7 +2114,6 @@
|
||||
"ui.select": "Zaznacz",
|
||||
"ui.selectOther": "Wybierz inny…",
|
||||
"ui.selectTangentChain": "Zaznacz łańcuch styczny",
|
||||
"ui.select_all": "Zaznacz wszystko",
|
||||
"ui.selection": "Zaznaczenie",
|
||||
"ui.selection_modifiers": "Modyfikatory zaznaczania",
|
||||
"ui.selection_shift_add_only": "Shift+kliknięcie tylko dodaje",
|
||||
|
||||
@@ -815,16 +815,6 @@
|
||||
"cmd.sheet_refold": "Redobrar",
|
||||
"cmd.sheet_rules": "Listar regras de chapa metálica",
|
||||
"cmd.sheet_unfold": "Desdobrar",
|
||||
"cmd.sketch.construction_line": "Reta de construção",
|
||||
"cmd.sketch.construction_line.angle": "Reta de construção em ângulo",
|
||||
"cmd.sketch.construction_line.bisector": "Bissetriz",
|
||||
"cmd.sketch.construction_line.horizontal": "Reta de construção horizontal",
|
||||
"cmd.sketch.construction_line.parallel": "Reta de construção paralela",
|
||||
"cmd.sketch.construction_line.perpendicular": "Reta de construção perpendicular",
|
||||
"cmd.sketch.construction_line.tangent_at": "Tangente em um ponto da curva",
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente por um ponto externo",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tangente comum",
|
||||
"cmd.sketch.construction_line.vertical": "Reta de construção vertical",
|
||||
"cmd.sketch_arc_center_point": "Arco por ponto central",
|
||||
"cmd.sketch_arc_tangent": "Arco tangente",
|
||||
"cmd.sketch_arc_three_point": "Arco por 3 pontos",
|
||||
@@ -1874,7 +1864,6 @@
|
||||
"ui.constant": "Constante",
|
||||
"ui.constraints_conflict": "Restrições em conflito",
|
||||
"ui.construction": "Construção",
|
||||
"ui.construction.ribbon_label": "Linhas aux.",
|
||||
"ui.context_menu": "Menu de contexto",
|
||||
"ui.continuity_g0": "G0 vinco",
|
||||
"ui.continuity_g1": "G1 tangente",
|
||||
@@ -2255,7 +2244,6 @@
|
||||
"ui.select": "Selecionar",
|
||||
"ui.selectOther": "Selecionar outro…",
|
||||
"ui.selectTangentChain": "Selecionar cadeia tangente",
|
||||
"ui.select_all": "Selecionar tudo",
|
||||
"ui.selection": "Seleção",
|
||||
"ui.selection_modifiers": "Modificadores de seleção",
|
||||
"ui.selection_shift_add_only": "Shift+clique adiciona sem remover",
|
||||
|
||||
@@ -253,12 +253,6 @@ impl crate::Feature {
|
||||
for e in sketch.text_exprs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
// An equation curve's variable is its own: a parameter of that name is not renamed in it.
|
||||
for (variable, e) in sketch.equation_exprs_mut() {
|
||||
let others: BTreeMap<String, String> =
|
||||
parameters.iter().filter(|(old, _)| old.as_str() != variable).map(|(o, n)| (o.clone(), n.clone())).collect();
|
||||
*e = clone_expression(e, &others);
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
|
||||
|
||||
@@ -344,13 +344,10 @@ fn model_line(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
_ => Err(bad("the face is not a cylinder, cone or torus")),
|
||||
}
|
||||
}
|
||||
_ => Err(bad(MODEL_AXIS_REFS)),
|
||||
_ => Err(bad("pick a straight or circular edge, or a cylinder, cone or torus face")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`model_axis`] says about a reference that is neither an edge nor a face.
|
||||
pub const MODEL_AXIS_REFS: &str = "pick a straight or circular edge, or a cylinder, cone or torus face";
|
||||
|
||||
/// The axis a feature turns about when an edge or face of the model is picked for it (a revolve,
|
||||
/// a circular pattern, a plane at an angle), as [`model_line`] finds it. The direction is
|
||||
/// oriented the same way however the edge or surface runs (its largest component positive), so
|
||||
|
||||
@@ -3705,7 +3705,7 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Add or change a parameter. Fails if the result would not evaluate. A new parameter
|
||||
/// without a unit takes the unit of its value (a length in the design's units, deg or none).
|
||||
/// without a unit takes the unit of its value (mm, deg or none).
|
||||
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
|
||||
// A text parameter: one with the text unit, or an existing one when no unit is given.
|
||||
match (unit.map(crate::text_params::is_text_unit), self.text_param(name).is_some()) {
|
||||
@@ -3889,11 +3889,10 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply parameter values to every dimension in a sketch, to its points placed by parameters
|
||||
/// and to its equation curves (in place).
|
||||
/// Apply parameter values to every dimension in a sketch, and to its points placed by
|
||||
/// parameters (in place).
|
||||
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
self.apply_point_values(vals, sk)?;
|
||||
self.apply_equation_values(vals, sk)?;
|
||||
self.apply_pattern_values(vals, sk)?;
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(p) = &c.param {
|
||||
|
||||
@@ -792,11 +792,6 @@ fn split_piece_of(name: &str) -> Option<&str> {
|
||||
(!n.starts_with('0') && n.parse::<u32>().is_ok()).then_some(base)
|
||||
}
|
||||
|
||||
/// The warning of a sketch whose profile search was cut short (#6896).
|
||||
fn profiles_cut_short_note() -> String {
|
||||
format!("some profiles may be missing: a group of touching curves is cut into more than {} pieces", solidcraft_sketch::MAX_PROFILE_EDGES)
|
||||
}
|
||||
|
||||
/// Add `note` to a feature's warning.
|
||||
fn append_warning(warning: &mut Option<String>, note: String) {
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -2158,14 +2153,7 @@ impl Model {
|
||||
feature_component.entry(f.id).or_insert(f.component);
|
||||
}
|
||||
let watched = crate::progress::begin(doc.features.len());
|
||||
// The features that failed so far in this pass, by id: their names and errors (#9075).
|
||||
let mut failed: std::collections::HashMap<u64, (String, String)> = std::collections::HashMap::new();
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
if let Some(r) = out.last()
|
||||
&& let Some(e) = &r.error
|
||||
{
|
||||
failed.insert(r.id, (r.name.clone(), e.clone()));
|
||||
}
|
||||
self.placement_local = self.placement_local && placement_reuse::local_geometry(f, &feature_component);
|
||||
let rolled_back = i >= marker;
|
||||
// Retain at most one active result per rolled-back feature, even across several
|
||||
@@ -2225,33 +2213,6 @@ impl Model {
|
||||
if state.reserved_body_names != reserved {
|
||||
Arc::make_mut(&mut state).reserved_body_names.clone_from(&reserved);
|
||||
}
|
||||
// A sketch the feature is built from failed earlier in this pass: the feature fails naming
|
||||
// that sketch, with its error, as one using a failed parameter does (#9075), not as a
|
||||
// sketch that must come earlier in the timeline. Never cached: the cache key holds
|
||||
// neither the sketch's name nor its error (a rename, another failing value).
|
||||
let failed_sketch = if failed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
f.kind.input_sketches().into_iter().find_map(|id| failed.get(&id)).map(|(name, e)| format!("sketch `{name}` failed: {e}"))
|
||||
};
|
||||
if let Some(e) = failed_sketch {
|
||||
recomputed += 1;
|
||||
reuse = false;
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
error: Some(e),
|
||||
warning: None,
|
||||
boolean_diagnostics: Default::default(),
|
||||
ms: 0.0,
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
unkept: false,
|
||||
state: state.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// The same feature on the same inputs: its earlier result.
|
||||
let key =
|
||||
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
|
||||
@@ -2562,40 +2523,13 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
|
||||
|
||||
pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
|
||||
// Non-literal lengths (a parameter ± a bare number) must read that bare number in the design's
|
||||
// units during the expression, not only when the whole result is unit-less (#9015).
|
||||
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
|
||||
let defaults = expr::Defaults { len: scale, ..expr::Defaults::default() };
|
||||
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
|
||||
return expr::eval_with_defaults(e, &look, defaults)?.to_kind_in(k, defaults);
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
|
||||
return v.to_kind_in(k, expr::Defaults { len: scale, ..expr::Defaults::default() });
|
||||
}
|
||||
Document::eval_in(vals, e, k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod design_unit_val_tests {
|
||||
use super::*;
|
||||
use expr::Value;
|
||||
|
||||
/// #9015: with inches as the design unit, `baseLength - 1` subtracts one inch (not one mm).
|
||||
#[test]
|
||||
fn bare_offsets_beside_a_length_follow_the_design_units() {
|
||||
let mut vals = BTreeMap::new();
|
||||
vals.insert(DESIGN_LEN.into(), Value::num(25.4));
|
||||
vals.insert("baseLength".into(), Value::length(76.0 * 25.4));
|
||||
let got = val(&vals, "baseLength - 1", Kind::Length).unwrap();
|
||||
assert!((got - 75.0 * 25.4).abs() < 1e-9, "got {got} mm, want 75 in");
|
||||
let got = val(&vals, "baseLength - 1.5", Kind::Length).unwrap();
|
||||
assert!((got - 74.5 * 25.4).abs() < 1e-9, "got {got} mm, want 74.5 in");
|
||||
// An explicit millimetre still wins.
|
||||
let got = val(&vals, "baseLength - 1 mm", Kind::Length).unwrap();
|
||||
assert!((got - (76.0 * 25.4 - 1.0)).abs() < 1e-9, "got {got}");
|
||||
// A bare literal stays millimetres (feature inputs write the design unit in when typed).
|
||||
let got = val(&vals, "2", Kind::Length).unwrap();
|
||||
assert!((got - 2.0).abs() < 1e-12, "got {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsing the outline and its holes onto one apex cannot make a manifold solid.
|
||||
fn refuse_loft_holes(r: &Region2) -> Result<()> {
|
||||
if r.holes.is_empty() {
|
||||
@@ -7262,13 +7196,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let profiles = find_profiles(&sk);
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
|
||||
@@ -161,12 +161,6 @@ pub const FUNCTIONS: &[(&str, &str)] = &[
|
||||
("ceil", "ceil(x)"),
|
||||
("round", "round(x)"),
|
||||
("exp", "exp(x)"),
|
||||
("sinh", "sinh(x)"),
|
||||
("cosh", "cosh(x)"),
|
||||
("tanh", "tanh(x)"),
|
||||
("asinh", "asinh(x)"),
|
||||
("acosh", "acosh(x), x ≥ 1"),
|
||||
("atanh", "atanh(x), −1 < x < 1"),
|
||||
("ln", "ln(x)"),
|
||||
("log", "log(x) (base 10)"),
|
||||
("log10", "log10(x)"),
|
||||
@@ -595,11 +589,9 @@ pub fn is_literal(s: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the expression a bare number without a unit, like `20` or `-1.5`? A leading `=` only marks
|
||||
/// a formula (#4681), so `=20` is the same bare number.
|
||||
/// Is the expression a bare number without a unit, like `20` or `-1.5`?
|
||||
pub fn is_bare_number(s: &str) -> bool {
|
||||
let number = s.trim().strip_prefix('=').unwrap_or(s);
|
||||
is_literal(s) && number.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
|
||||
is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Is `s` shaped like a parameter name: a letter or `_`, then letters, digits and `_`? The shape
|
||||
@@ -951,24 +943,6 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
|
||||
unit_fn(r * if scale != 0.0 { scale } else { 1.0 }, a)
|
||||
}
|
||||
"exp" => Value::num(num(one()?)?.exp()),
|
||||
"sinh" => Value::num(num(one()?)?.sinh()),
|
||||
"cosh" => Value::num(num(one()?)?.cosh()),
|
||||
"tanh" => Value::num(num(one()?)?.tanh()),
|
||||
"asinh" => Value::num(num(one()?)?.asinh()),
|
||||
"acosh" => {
|
||||
let x = num(one()?)?;
|
||||
if !(x >= 1.0) {
|
||||
return Err(DocError::Expr("`acosh` needs a number of at least 1".into()));
|
||||
}
|
||||
Value::num(x.acosh())
|
||||
}
|
||||
"atanh" => {
|
||||
let x = num(one()?)?;
|
||||
if !(x > -1.0 && x < 1.0) {
|
||||
return Err(DocError::Expr("`atanh` needs a number between -1 and 1, both excluded".into()));
|
||||
}
|
||||
Value::num(x.atanh())
|
||||
}
|
||||
"ln" | "log" | "log10" => {
|
||||
let x = num(one()?)?;
|
||||
if x <= 0.0 {
|
||||
@@ -1354,15 +1328,6 @@ mod tests {
|
||||
assert!((ev("atan2(1; 1)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("exp(ln(5))", Kind::Unitless).unwrap() - 5.0).abs() < 1e-12);
|
||||
assert!((ev("log(1000)", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12);
|
||||
// Hyperbolic functions (#9332): their definitions in exp, identities and inverses.
|
||||
assert!((ev("cosh(1.5) - (exp(1.5) + exp(-1.5)) / 2", Kind::Unitless).unwrap()).abs() < 1e-12);
|
||||
assert!((ev("sinh(1.5) - (exp(1.5) - exp(-1.5)) / 2", Kind::Unitless).unwrap()).abs() < 1e-12);
|
||||
assert!((ev("tanh(0.7) - sinh(0.7) / cosh(0.7)", Kind::Unitless).unwrap()).abs() < 1e-12);
|
||||
assert!((ev("cosh(2) ^ 2 - sinh(2) ^ 2", Kind::Unitless).unwrap() - 1.0).abs() < 1e-12);
|
||||
assert!((ev("asinh(sinh(1.25))", Kind::Unitless).unwrap() - 1.25).abs() < 1e-12);
|
||||
assert!((ev("acosh(cosh(1.25))", Kind::Unitless).unwrap() - 1.25).abs() < 1e-12);
|
||||
assert!((ev("atanh(tanh(0.5))", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12);
|
||||
assert_eq!(ev("cosh(0) + sinh(0) + tanh(0) + asinh(0) + acosh(1) + atanh(0)", Kind::Unitless).unwrap(), 1.0);
|
||||
assert_eq!(ev("floor(2.7) + ceil(0.2) + round(1.5) + abs(-1)", Kind::Unitless).unwrap(), 6.0);
|
||||
assert!((ev("1 rad", Kind::Angle).unwrap() - 1.0).abs() < 1e-12);
|
||||
assert!((ev("acos(0)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
@@ -1372,22 +1337,6 @@ mod tests {
|
||||
assert_eq!(ev("width^2 / width", Kind::Length).unwrap(), 40.0);
|
||||
}
|
||||
|
||||
/// The hyperbolic functions (#9332) take a unit-less number, and the inverses refuse what lies
|
||||
/// outside their domain, as `asin` and `ln` do.
|
||||
#[test]
|
||||
fn hyperbolic_functions_refuse_units_and_numbers_outside_their_domain() {
|
||||
for (e, says) in [
|
||||
("cosh(2 mm)", "`cosh` needs a unit-less number"),
|
||||
("tanh(30 deg)", "`tanh` needs a unit-less number"),
|
||||
("acosh(0.5)", "`acosh` needs a number of at least 1"),
|
||||
("atanh(1)", "`atanh` needs a number between -1 and 1"),
|
||||
("atanh(-1.5)", "`atanh` needs a number between -1 and 1"),
|
||||
] {
|
||||
let err = ev(e, Kind::Unitless).map(|v| v.to_string()).unwrap_or_else(|e| e.to_string());
|
||||
assert!(err.contains(says), "{e}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
|
||||
#[test]
|
||||
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
|
||||
@@ -1500,8 +1449,6 @@ mod tests {
|
||||
assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg"));
|
||||
assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width"));
|
||||
assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)"));
|
||||
// A leading `=` only marks a formula (#4681): `=5` is the bare number `5`, not `==5`.
|
||||
assert!(is_bare_number("=5") && is_bare_number(" = -1.5") && !is_bare_number("=5 mm") && !is_bare_number("==5") && !is_bare_number("=d3"));
|
||||
assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm"));
|
||||
}
|
||||
|
||||
|
||||
@@ -46,17 +46,6 @@ impl FeatureKind {
|
||||
found
|
||||
}
|
||||
|
||||
/// Bodies the feature works on (by name).
|
||||
pub fn input_bodies(&self) -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_refs(&mut |r| {
|
||||
if let Ref::Body(name) = r {
|
||||
v.push(name.as_ref().to_string());
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Point references to sketches and features (both by id) at their copies, and references
|
||||
/// that are still keyed by name at a feature's new name (`timeline.rename`).
|
||||
pub fn remap_refs(&mut self, ids: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! CPU render setups save their explicit dielectric transport choice. Missing or
|
||||
//! older values retain LegacyBounded; its default is omitted. StrictTir preserves
|
||||
//! deterministic internal reflection and refuses unresolved positive optical tails;
|
||||
//! it does not make captured body bindings portable or add unsupported geometry.
|
||||
//! No existing value needs migration or clamping. The last release refuses the
|
||||
//! newer design format and asks for an update instead of losing the saved choice.
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Saved CPU render setups accept finite exposure from -32 through32 EV, matching the
|
||||
//! existing RenderSetup/UI range. Exposure is still saved only on RenderSetup, applied
|
||||
//! once after linear accumulation, with default0 unchanged. Existing valid designs keep
|
||||
//! their numerical intent; no migration/clamping or second exposure field is introduced.
|
||||
//! The last release refuses this newer design format and requests an update instead of
|
||||
//! accepting a CPU setup whose wider exposure it cannot execute.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Saves up to eight bounded linear-RGB directional lights in a Raster render job.
|
||||
//! Older jobs without lights retain the exact implicit studio; an explicit empty
|
||||
//! rig remains dark. No older data rewrite is needed. The last released reader
|
||||
//! asks for an update instead of silently discarding this newer saved intent.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Raster product images now apply the existing saved exposure_ev control.
|
||||
//! Older zero/omitted exposure retains the exact old path. Nonzero exposure
|
||||
//! changes explicit rendering intent that was previously refused or ignored;
|
||||
//! the last released reader asks for an update for this newer meaning.
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A sketch saves its equation curves (#8352) in `equations`: for each, the fit point spline it
|
||||
//! places, its formulas (x(t) and y(t), or y = f(x) without `x`), the range of the variable and
|
||||
//! the tolerance its points were chosen for. An older design has none, and its sketches mean
|
||||
//! what they meant. The last release refuses a design that has them (it asks for an update)
|
||||
//! instead of opening the splines as plain fixed splines that no longer follow their formulas.
|
||||
@@ -191,11 +191,7 @@ impl Document {
|
||||
}
|
||||
let mut out = Vec::with_capacity(found.defs.len());
|
||||
for d in found.defs {
|
||||
// A value of no fixed kind (a joint's) is evaluated without the design's units, so
|
||||
// its bare number stays a millimetre length whatever they are (#8865): `w = 5` is
|
||||
// what `5` is there.
|
||||
let unit = d.unit.unwrap_or_else(|| Self::infer_unit_in(&next.param_values().0, &d.expression, "mm"));
|
||||
next.add_user_param(&d.name, &d.expression, Some(&unit), None)
|
||||
next.add_user_param(&d.name, &d.expression, d.unit.as_deref(), None)
|
||||
.map_err(|e| DocError::Invalid(format!("`{} = {}`: {e}", d.name, d.expression)))?;
|
||||
let unit = next.param(&d.name).map(|p| p.unit.clone()).unwrap_or_default();
|
||||
out.push(Defined { name: d.name, expression: d.expression, unit });
|
||||
|
||||
@@ -71,7 +71,6 @@ pub mod render_settings;
|
||||
pub mod render_surface;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
pub mod sketch_equation;
|
||||
pub mod text_params;
|
||||
|
||||
pub use assembly::{
|
||||
|
||||
@@ -268,7 +268,7 @@ impl Document {
|
||||
.filter(|(_, _, k)| *k == Kind::Length)
|
||||
.map(|(_, e, _)| {
|
||||
let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } };
|
||||
expr::eval_with_defaults(&e, &look, d).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
|
||||
expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -585,7 +585,7 @@ impl Document {
|
||||
}
|
||||
let unit = match unit {
|
||||
Some(u) => u.trim().to_string(),
|
||||
None => Self::infer_unit_in(vals, expr_s, &self.units),
|
||||
None => Self::infer_unit_in(vals, expr_s),
|
||||
};
|
||||
self.params.push(crate::Param {
|
||||
name: name.into(),
|
||||
@@ -696,19 +696,18 @@ impl Document {
|
||||
}
|
||||
|
||||
/// The unit for a new parameter with this expression: that of its value; a bare number is
|
||||
/// a length in the design's units, as a typed length reads it (#8865).
|
||||
/// a length (mm).
|
||||
pub(crate) fn infer_unit(&self, e: &str) -> String {
|
||||
Self::infer_unit_in(&self.param_values().0, e, &self.units)
|
||||
Self::infer_unit_in(&self.param_values().0, e)
|
||||
}
|
||||
|
||||
/// [`Document::infer_unit`] against parameter values already worked out, a length taking
|
||||
/// `units` (the design's, or `mm` where a bare number reads as millimetres).
|
||||
pub(crate) fn infer_unit_in(vals: &BTreeMap<String, Value>, e: &str, units: &str) -> String {
|
||||
/// [`Document::infer_unit`] against parameter values already worked out.
|
||||
fn infer_unit_in(vals: &BTreeMap<String, Value>, e: &str) -> String {
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))));
|
||||
match v.ok().and_then(|v| v.kind()) {
|
||||
Some(Kind::Unitless) if !expr::references(e).is_empty() => String::new(),
|
||||
Some(Kind::Angle) => "deg".into(),
|
||||
_ => expr::length_unit_or_mm(units).to_string(),
|
||||
_ => "mm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,18 +896,6 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
// An equation curve's variable hides a parameter of its name: such a parameter is
|
||||
// not what its formulas refer to.
|
||||
for (variable, e) in sketch.equation_exprs_mut() {
|
||||
if variable == old {
|
||||
continue;
|
||||
}
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.text_word_exprs_mut() {
|
||||
let r = crate::text_params::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
@@ -1150,19 +1137,6 @@ mod design_length_tests {
|
||||
assert_eq!(written("", 5.0), w("mm", "5 mm"));
|
||||
}
|
||||
|
||||
/// A bare number typed as a formula (`=5`) means the design's units like `5` does.
|
||||
#[test]
|
||||
fn a_formula_that_is_a_bare_number_gets_the_design_unit() {
|
||||
let mut doc = Document::new("Units");
|
||||
doc.units = "in".into();
|
||||
let length = crate::expr::Kind::Length;
|
||||
assert_eq!(doc.with_design_unit("5", length), "5 in");
|
||||
assert_eq!(doc.with_design_unit("=5", length), "=5 in");
|
||||
assert_eq!(doc.with_design_unit("=5 mm", length), "=5 mm");
|
||||
let inches = doc.eval(&doc.with_design_unit("=5", length), length).unwrap();
|
||||
assert!((inches - 127.0).abs() < 1e-9, "{inches}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_value_reads_back_within_a_micrometre() {
|
||||
for units in ["mm", "cm", "m", "in", "ft"] {
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::{
|
||||
Camera,
|
||||
job::{
|
||||
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
|
||||
CpuBudget, CpuDielectricTransport, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
|
||||
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
|
||||
},
|
||||
lighting::environment::SkyGradient,
|
||||
};
|
||||
@@ -25,6 +25,9 @@ pub enum RenderJob {
|
||||
Raster {
|
||||
#[serde(default)]
|
||||
transparent_background: bool,
|
||||
/// Omission retains the fixed studio; an explicit empty rig is dark.
|
||||
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_lights", skip_serializing_if = "Option::is_none")]
|
||||
lights: Option<Vec<DirectionalLightOptions>>,
|
||||
},
|
||||
CpuRaytrace {
|
||||
#[serde(default)]
|
||||
@@ -42,8 +45,12 @@ pub enum RenderJob {
|
||||
impl RenderJob {
|
||||
pub fn validate(&self, camera: &Camera, exposure_ev: f64) -> Result<()> {
|
||||
match self {
|
||||
Self::Raster { .. } if exposure_ev != 0.0 => Err(DocError::Invalid("saved raster rendering requires exposure_ev: 0".into())),
|
||||
Self::Raster { .. } => Ok(()),
|
||||
Self::Raster { lights, .. } => {
|
||||
if !exposure_ev.is_finite() || !(-32.0..=32.0).contains(&exposure_ev) {
|
||||
return Err(DocError::Invalid("saved Raster exposure must be finite and within -32..32 EV".into()));
|
||||
}
|
||||
solidcraft_render::job::validate_raster_lights(lights).map_err(|e| DocError::Invalid(e.to_string()))
|
||||
}
|
||||
Self::CpuRaytrace { options } => options.to_runtime(exposure_ev).map(|_| ()),
|
||||
Self::GpuPath { options, transparent_background } => {
|
||||
if *transparent_background && options.backdrop.is_some() {
|
||||
@@ -67,6 +74,8 @@ impl RenderJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuJob {
|
||||
#[serde(skip_serializing_if = "CpuDielectricTransport::is_legacy")]
|
||||
pub dielectric_transport: CpuDielectricTransport,
|
||||
#[serde(skip_serializing_if = "CpuBudget::is_compact")]
|
||||
pub budget: CpuBudget,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -94,6 +103,7 @@ pub struct CpuJob {
|
||||
impl Default for CpuJob {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dielectric_transport: CpuDielectricTransport::LegacyBounded,
|
||||
budget: CpuBudget::Compact,
|
||||
progressive: None,
|
||||
primary_samples: 1,
|
||||
@@ -129,6 +139,7 @@ impl CpuJob {
|
||||
// a light family that is explicitly absent. This does not rewrite the file.
|
||||
let active_area = self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty());
|
||||
let options = RaytraceOptions {
|
||||
dielectric_transport: self.dielectric_transport,
|
||||
budget: self.budget,
|
||||
progressive: self.progressive,
|
||||
primary_samples: self.primary_samples,
|
||||
@@ -346,3 +357,41 @@ fn punctual_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resul
|
||||
fn area_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Option<Vec<RectangleLightOptions>>, D::Error> {
|
||||
optional_lights::<D, RectangleLightOptions, 4>(d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn cpu_dielectric_transport_saved_field_has_legacy_default_and_explicit_runtime_forward() {
|
||||
let legacy: CpuJob = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(legacy.dielectric_transport, CpuDielectricTransport::LegacyBounded);
|
||||
assert!(serde_json::to_value(&legacy).unwrap().get("dielectric_transport").is_none());
|
||||
for policy in [CpuDielectricTransport::LegacyBounded, CpuDielectricTransport::StrictTir] {
|
||||
let saved = CpuJob { dielectric_transport: policy, ..Default::default() };
|
||||
let reopened: CpuJob = serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap();
|
||||
assert_eq!(reopened, saved);
|
||||
let runtime = reopened.to_runtime(0.0).unwrap();
|
||||
assert_eq!(runtime.dielectric_transport, policy);
|
||||
assert_eq!(runtime.exposure_ev, 0.0);
|
||||
assert_eq!(runtime.budget, CpuBudget::Compact);
|
||||
}
|
||||
for value in [serde_json::json!(null), serde_json::json!(true), serde_json::json!(17), serde_json::json!("unlimited")] {
|
||||
assert!(serde_json::from_value::<CpuJob>(serde_json::json!({"dielectric_transport":value})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn cpu_dielectric_transport_document_roundtrip_keeps_choice_without_captured_bindings() {
|
||||
for policy in [CpuDielectricTransport::LegacyBounded, CpuDielectricTransport::StrictTir] {
|
||||
let setup = crate::render_settings::RenderSetup {
|
||||
job: Some(RenderJob::CpuRaytrace { options: CpuJob { dielectric_transport: policy, ..Default::default() } }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut document = crate::Document::default();
|
||||
std::sync::Arc::make_mut(&mut document.render).setups.insert("Policy".into(), setup.clone());
|
||||
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(document, reopened);
|
||||
assert_eq!(reopened.render.setups["Policy"], setup);
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &reopened.render.setups["Policy"].job else { panic!("saved CPU method changed") };
|
||||
assert_eq!(options.to_runtime(0.0).unwrap().dielectric_transport, policy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,81 @@ mod tests {
|
||||
use crate::Document;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn raster_saved_exposure_uses_the_existing_field_and_roundtrips_its_real_bounds() {
|
||||
for ev in [-32.0, -1.0, 0.0, 1.0, 2.0, 32.0] {
|
||||
let setup =
|
||||
RenderSetup { exposure_ev: ev, job: Some(RenderJob::Raster { transparent_background: false, lights: None }), ..Default::default() };
|
||||
setup.validate().unwrap();
|
||||
let mut document = Document::default();
|
||||
Arc::make_mut(&mut document.render).setups.insert("EV".into(), setup.clone());
|
||||
let loaded = Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(loaded.render.setups["EV"], setup);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_directional_jobs_roundtrip_original_intent_and_omit_legacy_none() {
|
||||
use serde_json::json;
|
||||
use solidcraft_render::job::DirectionalLightOptions;
|
||||
let old = json!({"method":"raster","transparent_background":false});
|
||||
let job: RenderJob = serde_json::from_value(old.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&job).unwrap(), old);
|
||||
let null: RenderJob = serde_json::from_value(json!({"method":"raster","lights":null})).unwrap();
|
||||
assert_eq!(null, job);
|
||||
let source = DirectionalLightOptions { direction: [3.0, -4.0, 0.0], color: [0.25, 0.5, 1.0], intensity: 2.0 };
|
||||
for count in [0, 1, 8] {
|
||||
let setup = RenderSetup {
|
||||
job: Some(RenderJob::Raster { transparent_background: true, lights: Some(vec![source.clone(); count]) }),
|
||||
width: 32,
|
||||
height: 24,
|
||||
background: [17, 29, 53],
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
let mut document = Document::default();
|
||||
let settings = Arc::make_mut(&mut document.render);
|
||||
settings.setups.insert("Raster".into(), setup.clone());
|
||||
settings
|
||||
.setups
|
||||
.insert("CPU".into(), RenderSetup { job: Some(RenderJob::CpuRaytrace { options: CpuJob::default() }), ..Default::default() });
|
||||
settings.setups.insert(
|
||||
"GPU".into(),
|
||||
RenderSetup { job: Some(RenderJob::GpuPath { transparent_background: false, options: Box::default() }), ..Default::default() },
|
||||
);
|
||||
let loaded = Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(loaded, document);
|
||||
assert_eq!(loaded.render.setups["Raster"], setup);
|
||||
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &loaded.render.setups["Raster"].job else {
|
||||
panic!("saved method or explicit rig lost")
|
||||
};
|
||||
assert_eq!(lights, &vec![source.clone(); count]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_raster_rigs_refuse_invalid_values_unknown_controls_and_excess_capacity() {
|
||||
use serde_json::json;
|
||||
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
|
||||
for job in [
|
||||
json!({"method":"raster","lights":vec![light;9]}),
|
||||
json!({"method":"raster","lights":[{"direction":[0,0,0],"color":[1,1,1],"intensity":0}]}),
|
||||
json!({"method":"raster","lights":[{"direction":[0,0,1],"color":[2,1,1],"intensity":1}]}),
|
||||
json!({"method":"raster","lights":[],"options":{"shadows":true}}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<RenderJob>(job).is_err());
|
||||
}
|
||||
let setup =
|
||||
RenderSetup { job: Some(RenderJob::Raster { transparent_background: false, lights: Some(Vec::with_capacity(9)) }), ..Default::default() };
|
||||
assert!(setup.validate().is_err());
|
||||
let exposure = RenderSetup {
|
||||
exposure_ev: 33.0,
|
||||
job: Some(RenderJob::Raster { transparent_background: false, lights: Some(Vec::new()) }),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(exposure.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_light_coordinates_are_admitted_only_when_the_document_can_reopen() {
|
||||
use serde_json::json;
|
||||
@@ -642,8 +717,9 @@ mod tests {
|
||||
for setup in [
|
||||
json!({"job":{}}),
|
||||
json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":2}}}),
|
||||
json!({"exposure_ev":17,"job":{"method":"cpu_raytrace"}}),
|
||||
json!({"exposure_ev":1,"job":{"method":"raster"}}),
|
||||
// CPU17 is now valid within the shared saved range;33 remains invalid.
|
||||
json!({"exposure_ev":33,"job":{"method":"cpu_raytrace"}}),
|
||||
json!({"exposure_ev":33,"job":{"method":"raster"}}),
|
||||
json!({"environment_rotation":0.5,"job":{"method":"gpu_path"}}),
|
||||
json!({"environment_intensity":0,"job":{"method":"cpu_raytrace"}}),
|
||||
json!({"job":{"method":"cpu_raytrace","options":{"seed":-1}}}),
|
||||
@@ -739,7 +815,9 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod cpu_budget_tests {
|
||||
use super::*;
|
||||
use crate::Document;
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn inactive_area_tuning_roundtrips_without_leaking_into_runtime_options() {
|
||||
@@ -783,4 +861,48 @@ mod cpu_budget_tests {
|
||||
let legacy_large = RenderSetup { width: 3840, height: 2160, ..Default::default() };
|
||||
assert!(legacy_large.validate().is_err(), "large intent must name an explicit CPU job profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_exposure_expanded_range_roundtrips_offline_with_one_saved_authority() {
|
||||
use serde_json::json;
|
||||
assert_eq!(RenderSetup::default().exposure_ev, 0.0);
|
||||
for ev in [-32.0, -17.0, 17.0, 32.0] {
|
||||
for progressive in [false, true] {
|
||||
let mut options = json!({"lights":[],"shadows":false});
|
||||
if progressive {
|
||||
options["progressive"] = json!({"samples_per_pixel":3,"seed":31});
|
||||
}
|
||||
let saved = json!({"setups":{"EV":{"width":2,"height":1,"exposure_ev":ev,
|
||||
"job":{"method":"cpu_raytrace","options":options}}}});
|
||||
let settings: RenderSettings = serde_json::from_value(saved).unwrap();
|
||||
let document = Document { render: Arc::new(settings), ..Default::default() };
|
||||
let text = document.to_json();
|
||||
let wire: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let pending = crate::format::pending_names();
|
||||
if !pending.is_empty() {
|
||||
assert_eq!(wire["format_pending"], json!(pending));
|
||||
}
|
||||
let reopened = Document::from_json(&text).unwrap();
|
||||
assert_eq!(document, reopened);
|
||||
let setup = &reopened.render.setups["EV"];
|
||||
assert_eq!(setup.exposure_ev, ev);
|
||||
let RenderJob::CpuRaytrace { options } = setup.job.as_ref().unwrap() else { panic!("CPU method changed") };
|
||||
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
|
||||
assert_eq!(runtime.exposure_ev, ev);
|
||||
assert_eq!(runtime.budget, solidcraft_render::job::CpuBudget::Compact);
|
||||
assert_eq!(runtime.progressive.is_some(), progressive);
|
||||
assert!(serde_json::to_value(options).unwrap().get("exposure_ev").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_exposure_expansion_keeps_nonfinite_and_outside_saved_values_invalid() {
|
||||
for ev in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -33.0, 33.0, -32.01, 32.01] {
|
||||
let options = CpuJob::default();
|
||||
assert!(options.to_runtime(ev).is_err());
|
||||
let setup = RenderSetup { exposure_ev: ev, job: Some(RenderJob::CpuRaytrace { options }), ..Default::default() };
|
||||
assert!(setup.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Equation curves (#8352): the points of a sketch curve worked out from its formulas over the
|
||||
//! design's parameters. The formulas are evaluated at evenly spaced values of the curve's
|
||||
//! variable; the sketch draws a cubic fit spline through those points. How many points: as many
|
||||
//! as it takes for the spline to stay within the curve's tolerance of the formulas, checked
|
||||
//! between the points, up to the most a spline takes. When the parameters change, the same
|
||||
//! number of points is placed again (`Document::apply_equation_values`), before every solve.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{EquationCurve, MAX_COORD, MAX_EQUATION_TEXT, MAX_SPLINE_POINTS, Sketch, fit_beziers_mode};
|
||||
|
||||
use crate::expr::{self, Defaults, Kind, Value};
|
||||
use crate::{DocError, Result};
|
||||
|
||||
/// The tolerance a new equation curve gets (mm).
|
||||
pub const DEFAULT_TOLERANCE: f64 = 0.01;
|
||||
/// The tightest and loosest tolerances (mm).
|
||||
pub const MIN_TOLERANCE: f64 = 1e-4;
|
||||
pub const MAX_TOLERANCE: f64 = 10.0;
|
||||
/// The fewest points a fit starts from.
|
||||
const FIRST_COUNT: usize = 9;
|
||||
/// The widest range of the variable.
|
||||
const MAX_RANGE: f64 = 1e6;
|
||||
/// How many times the point count is raised before the fit gives up.
|
||||
const MAX_ROUNDS: usize = 12;
|
||||
|
||||
/// An equation curve's points and how well the spline through them follows the formulas.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EquationFit {
|
||||
/// The spline's points in order; a closed curve's last point is its first one again.
|
||||
pub points: Vec<Vec2>,
|
||||
/// The curve ends where it starts: the spline closes smoothly there.
|
||||
pub closed: bool,
|
||||
/// The largest distance (mm) found between the spline and the formulas.
|
||||
pub deviation: f64,
|
||||
}
|
||||
|
||||
/// How bare numbers read in the formulas: angles in radians (the variable is a plain number,
|
||||
/// `sin(t)` its sine), lengths in the design's unit.
|
||||
fn defaults(vals: &BTreeMap<String, Value>) -> Defaults {
|
||||
let len = vals.get(crate::eval::DESIGN_LEN).map(|v| v.v).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
|
||||
Defaults { len, ang: 1.0 }
|
||||
}
|
||||
|
||||
fn checked_text<'a>(what: &str, e: &'a str) -> Result<&'a str> {
|
||||
let e = e.trim();
|
||||
if e.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{what} is empty")));
|
||||
}
|
||||
if e.chars().count() > MAX_EQUATION_TEXT {
|
||||
return Err(DocError::Invalid(format!("{what} is longer than {MAX_EQUATION_TEXT} characters")));
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// A formula's value (mm) where the variable is `at`. The variable hides a parameter of its name.
|
||||
fn formula(vals: &BTreeMap<String, Value>, e: &str, variable: &str, at: f64, d: Defaults) -> Result<f64> {
|
||||
let lookup = |n: &str| {
|
||||
if n == variable { Ok(Value::num(at)) } else { vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) }
|
||||
};
|
||||
let v = expr::eval_with_defaults(e, &lookup, d)?.to_kind_in(Kind::Length, d)?;
|
||||
if v.is_finite() && v.abs() < MAX_COORD { Ok(v) } else { Err(DocError::Expr("the curve goes out of range".into())) }
|
||||
}
|
||||
|
||||
/// Where the variable starts or ends, as the plain number the formulas see: a number, an angle
|
||||
/// (in radians) or a length (in the design's unit).
|
||||
fn range_end(vals: &BTreeMap<String, Value>, e: &str, d: Defaults) -> Result<f64> {
|
||||
let v = expr::eval_with_defaults(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), d)?;
|
||||
let out = match v.kind() {
|
||||
Some(Kind::Unitless | Kind::Angle) => v.v,
|
||||
Some(Kind::Length) => v.v / d.len,
|
||||
None => return Err(DocError::Expr(format!("expected a number, an angle or a length, got {}", v.describe()))),
|
||||
};
|
||||
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("not a finite number".into())) }
|
||||
}
|
||||
|
||||
/// The variable's range, checked.
|
||||
fn range(vals: &BTreeMap<String, Value>, eq: &EquationCurve, d: Defaults) -> Result<(f64, f64)> {
|
||||
let a = range_end(vals, checked_text("the start", &eq.start)?, d).map_err(|e| DocError::Expr(format!("start: {e}")))?;
|
||||
let b = range_end(vals, checked_text("the end", &eq.end)?, d).map_err(|e| DocError::Expr(format!("end: {e}")))?;
|
||||
if (b - a).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0) {
|
||||
return Err(DocError::Invalid("the start and the end of the range must differ".into()));
|
||||
}
|
||||
if (b - a).abs() > MAX_RANGE {
|
||||
return Err(DocError::Invalid(format!("the range is wider than {MAX_RANGE}")));
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
/// The curve's point where the variable is `at`.
|
||||
fn point_at(vals: &BTreeMap<String, Value>, eq: &EquationCurve, at: f64, d: Defaults) -> Result<Vec2> {
|
||||
let variable = eq.variable();
|
||||
let x = match &eq.x {
|
||||
Some(e) => formula(vals, e, variable, at, d).map_err(|e| DocError::Expr(format!("x: {e}")))?,
|
||||
// The explicit form: x is the variable, in the design's unit.
|
||||
None => {
|
||||
let x = at * d.len;
|
||||
if !(x.is_finite() && x.abs() < MAX_COORD) {
|
||||
return Err(DocError::Expr("the curve goes out of range".into()));
|
||||
}
|
||||
x
|
||||
}
|
||||
};
|
||||
let y = formula(vals, &eq.y, variable, at, d).map_err(|e| DocError::Expr(format!("y: {e}")))?;
|
||||
Ok(Vec2::new(x, y))
|
||||
}
|
||||
|
||||
/// The formulas' points at `count` evenly spaced values of the variable, from start to end.
|
||||
fn samples(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), count: usize, d: Defaults) -> Result<Vec<Vec2>> {
|
||||
let last = count.saturating_sub(1).max(1) as f64;
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let at = a + (b - a) * (i as f64 / last);
|
||||
point_at(vals, eq, at, d).map_err(|e| DocError::Expr(format!("at {} = {}: {e}", eq.variable(), short(at))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A number short enough for a message.
|
||||
fn short(v: f64) -> String {
|
||||
let s = format!("{v:.6}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The size of the curve: the larger side of its box (for closing tolerances).
|
||||
fn extent(points: &[Vec2]) -> f64 {
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
for p in points {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
(hi.x - lo.x).max(hi.y - lo.y).max(0.0)
|
||||
}
|
||||
|
||||
/// Does a curve through these samples end where it starts?
|
||||
fn closes(points: &[Vec2]) -> bool {
|
||||
match (points.first(), points.last()) {
|
||||
(Some(a), Some(b)) => points.len() >= 4 && a.dist(*b) <= 1e-7 * extent(points).max(1.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse samples a spline cannot pass through: two in a row on the same spot, or all of them.
|
||||
fn check_samples(points: &[Vec2], closed: bool) -> Result<()> {
|
||||
let size = extent(points);
|
||||
if size <= 1e-9 {
|
||||
return Err(DocError::Invalid("the formulas give the same point all along the range".into()));
|
||||
}
|
||||
let spans = if closed { points.len().saturating_sub(1) } else { points.len() };
|
||||
for w in points.windows(2).take(spans) {
|
||||
if let [p, q] = w
|
||||
&& p.dist(*q) <= 1e-9 * size.max(1.0)
|
||||
{
|
||||
return Err(DocError::Invalid("the curve stands still between two of its points: narrow the range or use fewer points".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Distance from `q` to the cubic Bézier `b`, sampled finely.
|
||||
fn bezier_distance(b: &[Vec2; 4], q: Vec2) -> f64 {
|
||||
const STEPS: usize = 32;
|
||||
let at = |t: f64| {
|
||||
let s = 1.0 - t;
|
||||
b[0] * (s * s * s) + b[1] * (3.0 * t * s * s) + b[2] * (3.0 * t * t * s) + b[3] * (t * t * t)
|
||||
};
|
||||
let mut best = f64::INFINITY;
|
||||
let mut prev = at(0.0);
|
||||
for i in 1..=STEPS {
|
||||
let next = at(i as f64 / STEPS as f64);
|
||||
let d = next - prev;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 0.0 { ((q - prev).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min(q.dist(prev + d * t));
|
||||
prev = next;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// How far the spline through `points` strays from the formulas: the largest distance from the
|
||||
/// formulas' points a quarter, a half and three quarters of the way between two samples to the
|
||||
/// spline's piece between them.
|
||||
fn deviation(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), points: &[Vec2], closed: bool, d: Defaults) -> Result<f64> {
|
||||
let pieces = fit_beziers_mode(points, closed).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
let step = (b - a) / points.len().saturating_sub(1).max(1) as f64;
|
||||
let mut worst: f64 = 0.0;
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
for k in [0.25, 0.5, 0.75] {
|
||||
let q = point_at(vals, eq, a + step * (i as f64 + k), d)?;
|
||||
worst = worst.max(bezier_distance(piece, q));
|
||||
}
|
||||
}
|
||||
Ok(worst)
|
||||
}
|
||||
|
||||
/// Check an equation's text and tolerance before any evaluation.
|
||||
pub fn check(eq: &EquationCurve) -> Result<()> {
|
||||
if let Some(x) = &eq.x {
|
||||
checked_text("x(t)", x)?;
|
||||
}
|
||||
checked_text(if eq.x.is_some() { "y(t)" } else { "y(x)" }, &eq.y)?;
|
||||
checked_text("the start", &eq.start)?;
|
||||
checked_text("the end", &eq.end)?;
|
||||
if !(eq.tolerance.is_finite() && (MIN_TOLERANCE..=MAX_TOLERANCE).contains(&eq.tolerance)) {
|
||||
return Err(DocError::Invalid(format!("the tolerance must be from {MIN_TOLERANCE} mm to {MAX_TOLERANCE} mm")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The points of `count` samples (a closed curve's last one is its first), whether it closes,
|
||||
/// and how far the spline through them strays.
|
||||
pub fn sample(vals: &BTreeMap<String, Value>, eq: &EquationCurve, count: usize, closed: Option<bool>) -> Result<EquationFit> {
|
||||
check(eq)?;
|
||||
if !(2..=MAX_SPLINE_POINTS).contains(&count) {
|
||||
return Err(DocError::Invalid(format!("an equation curve takes 2 to {MAX_SPLINE_POINTS} points")));
|
||||
}
|
||||
let d = defaults(vals);
|
||||
let span = range(vals, eq, d)?;
|
||||
let mut points = samples(vals, eq, span, count, d)?;
|
||||
let closed = closed.unwrap_or_else(|| closes(&points)) && count >= 4;
|
||||
if closed
|
||||
&& let Some(first) = points.first().copied()
|
||||
&& let Some(last) = points.last_mut()
|
||||
{
|
||||
*last = first;
|
||||
}
|
||||
check_samples(&points, closed)?;
|
||||
let deviation = deviation(vals, eq, span, &points, closed, d)?;
|
||||
Ok(EquationFit { points, closed, deviation })
|
||||
}
|
||||
|
||||
/// The points of an equation curve, as few as keep the spline within its tolerance of the
|
||||
/// formulas. Refused when even the most points a spline takes are not close enough.
|
||||
pub fn fit(vals: &BTreeMap<String, Value>, eq: &EquationCurve) -> Result<EquationFit> {
|
||||
let mut count = FIRST_COUNT;
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let fit = sample(vals, eq, count, None)?;
|
||||
if fit.deviation <= eq.tolerance {
|
||||
return Ok(fit);
|
||||
}
|
||||
if count >= MAX_SPLINE_POINTS {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"even {MAX_SPLINE_POINTS} points stray {} mm from the formulas, more than the tolerance of {} mm: raise the tolerance or narrow the range",
|
||||
short(fit.deviation),
|
||||
short(eq.tolerance)
|
||||
)));
|
||||
}
|
||||
// A cubic fit's error falls with the fourth power of the spacing: aim a little past
|
||||
// what that asks for, at least half as many points again.
|
||||
let ratio = (fit.deviation / eq.tolerance).powf(0.25) * 1.2;
|
||||
let next = (count as f64 * ratio.max(1.5)).ceil();
|
||||
count = if next.is_finite() && next < MAX_SPLINE_POINTS as f64 { next as usize } else { MAX_SPLINE_POINTS };
|
||||
}
|
||||
Err(DocError::Invalid("the equation curve's points could not be fitted".into()))
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Place the points of the sketch's equation curves where their formulas put them now (in
|
||||
/// place), with the number of points each has. Their points are fixed, so the solve keeps
|
||||
/// them there. A curve that is gone or is no longer a fit point spline is left as it is.
|
||||
pub fn apply_equation_values(&self, vals: &BTreeMap<String, Value>, sk: &mut Sketch) -> Result<()> {
|
||||
for k in 0..sk.equations.len() {
|
||||
let Some(eq) = sk.equations.get(k).cloned() else { continue };
|
||||
let Some(ci) = sk.curve_index(&eq.curve) else { continue };
|
||||
let Some(solidcraft_sketch::CurveKind::Spline { pts, control: false, periodic, .. }) = sk.curves.get(ci).map(|c| &c.kind) else {
|
||||
continue;
|
||||
};
|
||||
let (count, closed) = (pts.len(), *periodic);
|
||||
let fit = sample(vals, &eq, count, Some(closed)).map_err(|e| DocError::Expr(format!("equation curve {}: {e}", eq.curve)))?;
|
||||
sk.place_equation_points(ci, &fit.points)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eq(x: Option<&str>, y: &str, start: &str, end: &str) -> EquationCurve {
|
||||
EquationCurve {
|
||||
curve: String::new(),
|
||||
x: x.map(str::to_string),
|
||||
y: y.into(),
|
||||
start: start.into(),
|
||||
end: end.into(),
|
||||
tolerance: DEFAULT_TOLERANCE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_closes_and_stays_within_the_tolerance() {
|
||||
let vals = BTreeMap::new();
|
||||
let fit = fit(&vals, &eq(Some("10 * cos(t)"), "10 * sin(t)", "0", "2 * pi")).unwrap();
|
||||
assert!(fit.closed);
|
||||
assert!(fit.deviation <= DEFAULT_TOLERANCE, "{}", fit.deviation);
|
||||
for p in &fit.points {
|
||||
assert!((p.len() - 10.0).abs() < 1e-9, "{p:?}");
|
||||
}
|
||||
assert_eq!(fit.points.first(), fit.points.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variable_hides_a_parameter_and_parameters_drive_the_curve() {
|
||||
let mut vals = BTreeMap::new();
|
||||
vals.insert("t".to_string(), Value::length(1000.0));
|
||||
vals.insert("amp".to_string(), Value::length(5.0));
|
||||
let fit = fit(&vals, &eq(None, "amp * sin(x / 10)", "0 mm", "100 mm")).unwrap();
|
||||
assert!(!fit.closed);
|
||||
let (first, last) = (fit.points[0], *fit.points.last().unwrap());
|
||||
assert!((first - Vec2::new(0.0, 0.0)).len() < 1e-9, "{first:?}");
|
||||
assert!((last.x - 100.0).abs() < 1e-9 && (last.y - 5.0 * 10f64.sin()).abs() < 1e-9, "{last:?}");
|
||||
assert!(fit.points.iter().all(|p| p.y.abs() <= 5.0 + 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_in_inches_reads_bare_numbers_in_inches() {
|
||||
let mut vals = BTreeMap::new();
|
||||
vals.insert(crate::eval::DESIGN_LEN.to_string(), Value::num(25.4));
|
||||
let fit = fit(&vals, &eq(Some("t"), "2", "0", "1")).unwrap();
|
||||
let last = *fit.points.last().unwrap();
|
||||
assert!((last - Vec2::new(25.4, 50.8)).len() < 1e-9, "{last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_points_where_the_tolerance_is_tighter() {
|
||||
let vals = BTreeMap::new();
|
||||
let mut e = eq(Some("t * cos(t)"), "t * sin(t)", "0", "6 * pi");
|
||||
e.tolerance = 0.1;
|
||||
let loose = fit(&vals, &e).unwrap();
|
||||
e.tolerance = 0.005;
|
||||
let tight = fit(&vals, &e).unwrap();
|
||||
assert!(tight.points.len() > loose.points.len(), "{} vs {}", tight.points.len(), loose.points.len());
|
||||
assert!(tight.deviation <= 0.005 && loose.deviation <= 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_formulas_and_ranges_are_refused() {
|
||||
let vals = BTreeMap::new();
|
||||
for (x, y, a, b, tol) in [
|
||||
(Some("t"), "sqrt(t)", "-1", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "1 / t", "-1", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "0", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "1e300", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "nan", DEFAULT_TOLERANCE),
|
||||
(Some("t * 1 deg"), "t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("1e300 * t"), "t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("5"), "5", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some(""), "t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "1", f64::NAN),
|
||||
(Some("t"), "t", "0", "1", 0.0),
|
||||
(Some("t"), "unknown_name * t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "sin(1e5 * t)", "0", "1000", 1e-4),
|
||||
] {
|
||||
let mut e = eq(x, y, a, b);
|
||||
e.tolerance = tol;
|
||||
assert!(fit(&vals, &e).is_err(), "{x:?} {y} {a}..{b} {tol}");
|
||||
}
|
||||
let long = "t + ".repeat(400) + "t";
|
||||
assert!(fit(&vals, &eq(Some(&long), "t", "0", "1")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -486,8 +486,6 @@ fn hostile_expressions_never_panic() {
|
||||
"deg",
|
||||
"x",
|
||||
"sqrt",
|
||||
"cosh",
|
||||
"atanh",
|
||||
"pow",
|
||||
"min",
|
||||
"max",
|
||||
@@ -717,27 +715,3 @@ fn timeline_group_legacy_json_omits_empty_joint_membership() {
|
||||
assert!(value["timeline_groups"][0].get("joints").is_none());
|
||||
assert_eq!(Document::from_json(&saved).unwrap().to_json(), saved);
|
||||
}
|
||||
|
||||
/// A sketch whose touching curves are cut into more pieces than the profile search looks at
|
||||
/// says that some profiles may be missing, instead of leaving them out silently (#6896); an
|
||||
/// ordinary sketch says nothing.
|
||||
#[test]
|
||||
fn a_sketch_too_large_to_search_whole_says_profiles_may_be_missing() {
|
||||
let mut doc = Document::new("large");
|
||||
let mut sk = Sketch::new();
|
||||
// Concentric ellipses 100 m across, each sampled into thousands of pieces, all one island.
|
||||
let c = sk.add_point(Vec2::ZERO, None).unwrap();
|
||||
for k in 0..5 {
|
||||
let m = sk.add_point(Vec2::new(1e5 - k as f64 * 1e3, 0.0), None).unwrap();
|
||||
sk.add_curve(solidcraft_sketch::CurveKind::Ellipse { c, m, r: 6e4 - k as f64 * 1e3 }, None).unwrap();
|
||||
}
|
||||
let large = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
let (plate, plate_sketch) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
let warning = m.result(large).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("some profiles may be missing"), "{warning}");
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&plate);
|
||||
assert!(m.result(plate_sketch).and_then(|r| r.warning.as_deref()).is_none());
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ pub struct TextParam {
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
/// Is `unit` the text unit, ignoring ASCII case and surrounding whitespace?
|
||||
/// Is `unit` the text unit?
|
||||
pub fn is_text_unit(unit: &str) -> bool {
|
||||
unit.trim().eq_ignore_ascii_case(TEXT_UNIT)
|
||||
unit.trim() == TEXT_UNIT
|
||||
}
|
||||
|
||||
/// A text as typed for a text parameter: inside matching quotes, what they hold (a quote doubled
|
||||
|
||||
@@ -57,35 +57,19 @@ pub fn check(steps: &[Value], level: ParamCheck, host: &[&str]) -> Option<Refusa
|
||||
if host.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
if find_command(id).is_none() {
|
||||
let Some(spec) = find_command(id) else {
|
||||
return Some(Refusal { index, command: Some(id.to_string()), error: EngineError::UnknownCommand(id.to_string()).to_string() });
|
||||
}
|
||||
};
|
||||
if level == ParamCheck::Strict
|
||||
&& let Some(refusal) = contract_refusal(index, id, step)
|
||||
&& let Some(contract) = spec.contract
|
||||
&& let Some(first) = contract.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()
|
||||
{
|
||||
return Some(refusal);
|
||||
return Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The first of `steps` whose parameters break its command's typed contract, for a runner in
|
||||
/// another process that checks the rest itself (the MCP server bridged to an app at
|
||||
/// [`ParamCheck::Strict`]): a step naming no command, or one this registry lacks (the app may be
|
||||
/// another version), is left to it.
|
||||
pub fn contracts(steps: &[Value]) -> Option<Refusal> {
|
||||
steps.iter().enumerate().find_map(|(index, step)| {
|
||||
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)?;
|
||||
contract_refusal(index, id, step)
|
||||
})
|
||||
}
|
||||
|
||||
/// Step `index`'s refusal when its parameters break the typed contract of its command `id`.
|
||||
fn contract_refusal(index: usize, id: &str, step: &Value) -> Option<Refusal> {
|
||||
let first = crate::schema::issues(id, step.get("params").unwrap_or(&Value::Null)).into_iter().next()?;
|
||||
Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -146,9 +130,5 @@ mod tests {
|
||||
let mut s = crate::Session { param_check: ParamCheck::Strict, ..crate::Session::default() };
|
||||
let at_turn = s.execute("solid.box", &steps[1]["params"]).unwrap_err().to_string();
|
||||
assert_eq!(r.error, at_turn);
|
||||
// Another process's runner gets the same refusal, and keeps what it checks itself.
|
||||
assert_eq!(contracts(&steps), Some(r));
|
||||
let theirs = vec![json!({"params": {}}), step("app.only", json!({"x": 1})), step("view.pick", json!({}))];
|
||||
assert_eq!(contracts(&theirs), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1731,43 +1731,6 @@ mod tests {
|
||||
assert!(crate::view::sketch_shown(&s, wall) && !crate::view::sketch_shown(&s, base));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_at_sketch_points_hides_its_sketch_as_an_extrude_does() {
|
||||
// #9082: a Hole's points sketch stayed shown, and its points were drawn in the holes. A
|
||||
// Single hole snapped to one point (#2110) leaves its sketch shown: it was placed on a face,
|
||||
// and a hidden sketch offers no points for the next one to snap to.
|
||||
let mut s = Session::default();
|
||||
let plate = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "PlateSketch"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": plate, "distance": 6}));
|
||||
assert!(!crate::view::sketch_shown(&s, plate), "the Extrude used its sketch");
|
||||
let pts = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "HolePts", "offset": 6}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
run(&mut s, "sketch.point", json!({"point": [30, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let spot = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Spot", "offset": 6}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.point", json!({"point": [20, 15], "id": "c"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(crate::view::sketch_shown(&s, pts) && crate::view::sketch_shown(&s, spot), "unused sketches are shown");
|
||||
run(&mut s, "solid.hole", json!({"sketch": "HolePts", "diameter": 3, "name": "Holes"}));
|
||||
assert!(!crate::view::sketch_shown(&s, pts), "the Hole used its points sketch");
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Spot", "points": ["c"], "single": true, "diameter": 3, "depth": 3, "name": "One"}));
|
||||
assert!(crate::view::sketch_shown(&s, spot), "a Single hole leaves the sketch it snapped to shown");
|
||||
let d = run(&mut s, "document.inspect", json!({}));
|
||||
let visible = |id: u64| d["sketches"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap()["visible"].clone();
|
||||
assert_eq!((visible(pts), visible(spot)), (json!(false), json!(true)), "{d}");
|
||||
// While the Hole is edited it is drawn, then hidden again; shown on its own, it stays drawn.
|
||||
s.feature_edit_sketch = Some(pts);
|
||||
assert!(crate::view::sketch_shown(&s, pts));
|
||||
s.feature_edit_sketch = None;
|
||||
assert!(!crate::view::sketch_shown(&s, pts));
|
||||
run(&mut s, "browser.visibility", json!({"items": [pts], "visible": true}));
|
||||
assert!(crate::view::sketch_shown(&s, pts) && !crate::view::sketch_shown(&s, plate));
|
||||
run(&mut s, "browser.visibility", json!({"items": [pts], "visible": false}));
|
||||
assert!(!crate::view::sketch_shown(&s, pts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sketches_hide_and_show() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -39,7 +39,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Origin planes become explicit planes so a translation can move them.
|
||||
pub(in crate::cmd) fn explicit_planes(p: &mut PlaneRef) {
|
||||
fn explicit_planes(p: &mut PlaneRef) {
|
||||
match p {
|
||||
PlaneRef::Origin { name } => {
|
||||
if let Some(plane) = solidcraft_geom::Plane::named(name) {
|
||||
|
||||
@@ -12,35 +12,26 @@ use solidcraft_geom::Vec3;
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::schema::component::{
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, OccurrenceAlignParams,
|
||||
OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, OccurrenceAlignParams, OccurrenceAlignResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
|
||||
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
|
||||
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
|
||||
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
|
||||
|
||||
#[path = "component_align.rs"]
|
||||
mod align;
|
||||
#[path = "component_drag.rs"]
|
||||
mod drag;
|
||||
#[path = "component_foreign.rs"]
|
||||
mod foreign;
|
||||
#[path = "component_matrix.rs"]
|
||||
pub(super) mod matrix;
|
||||
#[path = "component_move_features.rs"]
|
||||
mod move_features;
|
||||
#[path = "component_placing.rs"]
|
||||
pub(super) mod placing;
|
||||
#[path = "component_properties.rs"]
|
||||
pub(super) mod properties;
|
||||
#[path = "component_reparent.rs"]
|
||||
mod reparent;
|
||||
#[path = "component_reparent_items.rs"]
|
||||
mod reparent_items;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
@@ -112,37 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
by (default: its only one, else the active one; a component placed more than once gets them in every instance). Each \
|
||||
copy stays where its body is in the world; the copies' names come back as `bodies`",
|
||||
),
|
||||
CommandSpec::new("component.paste_from_design", "Paste from Another Design", foreign::paste_from_design).icon("paste").params(
|
||||
"component?: where it goes (default: the active one); component_path?: its placement (default: the active one, else its \
|
||||
only one). Pastes what was copied in another design, at the same world coordinates: its bodies as imported bodies \
|
||||
(no history) in one Import feature, or its components as independent copies with their history, appearances, \
|
||||
joints and motion (as `component.paste_new`); the user parameters they use come along, renamed `w_2` when taken. \
|
||||
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
|
||||
`bodies` and `feature`, or the pasted components' `paths`",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
|
||||
.icon("move")
|
||||
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world)"),
|
||||
CommandSpec::new("component.move_features", "Move Features to Component", crate::checked!("component.move_features", move_features::move_features))
|
||||
.icon("move")
|
||||
.contract(&MOVE_FEATURES_CONTRACT)
|
||||
.output(output_schema_for::<MoveFeaturesResult>)
|
||||
.params(
|
||||
"features: [ids or names] (sketches too); component?: where they go (default: the active one) → moved, component, bodies. \
|
||||
Ids, names and timeline order stay; the geometry stays where it is in the world; the bodies the features made move with \
|
||||
them. Refused while the timeline is rolled back, for a component placed more than once, and for a move that would break \
|
||||
a feature (nothing changes)",
|
||||
),
|
||||
CommandSpec::new("browser.reparent", "Move to Component", crate::checked!("browser.reparent", reparent_items::reparent_items))
|
||||
.icon("move")
|
||||
.contract(&REPARENT_ITEMS_CONTRACT)
|
||||
.output(output_schema_for::<ReparentItemsResult>)
|
||||
.params(
|
||||
"Moves placements, features and bodies into a component. occurrences?: [ids or paths], features?: [ids or names], bodies?: [names], component?: where they go (default: the active \
|
||||
one), component_path?: its placement → component, occurrences, features, bodies (what moved). One undo step, all or \
|
||||
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
|
||||
still elsewhere (a body transfer, which leaves its features behind)",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).icon("move").params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
|
||||
@@ -83,10 +83,8 @@ fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64],
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
/// A name for a copy of `base` that isn't in `used` (and then is): numbered, or with `keep`
|
||||
/// (pasting into another design, where the original isn't) `base` itself while it is free.
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>, keep: bool) -> String {
|
||||
let name = if keep && !used.contains(base.trim()) { base.trim().to_string() } else { solidcraft_doc::copy_name(base, |n| used.contains(n)) };
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
let name = solidcraft_doc::copy_name(base, |n| used.contains(n));
|
||||
used.insert(name.clone());
|
||||
name
|
||||
}
|
||||
@@ -184,32 +182,6 @@ fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
/// The design Paste New copies from: its document, its evaluated model and its placements moved
|
||||
/// but not yet applied. The session pasted into is the target; they are the same design for a
|
||||
/// paste within one design.
|
||||
pub(super) struct Source<'a> {
|
||||
pub doc: &'a Document,
|
||||
pub model: &'a solidcraft_doc::Model,
|
||||
pub pending_moves: &'a BTreeMap<u64, Mat>,
|
||||
/// Another design than the target: the copy can't point at anything outside it (it is
|
||||
/// refused, saying what), and the user parameters it uses come along.
|
||||
pub other_design: bool,
|
||||
}
|
||||
|
||||
impl<'a> Source<'a> {
|
||||
/// The session's own design.
|
||||
pub fn of(s: &'a Session) -> Source<'a> {
|
||||
Source { doc: &s.doc, model: &s.model, pending_moves: &s.pending_moves, other_design: false }
|
||||
}
|
||||
|
||||
/// What was copied in another design.
|
||||
pub fn copied(copy: &'a crate::ForeignCopy) -> Source<'a> {
|
||||
Source { doc: ©.doc, model: ©.model, pending_moves: ©.pending_moves, other_design: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// An independent copy of `component` (and what it holds), from the session's own design, into
|
||||
/// `parent`.
|
||||
pub(super) fn paste(
|
||||
s: &mut Session,
|
||||
component: u64,
|
||||
@@ -218,26 +190,7 @@ pub(super) fn paste(
|
||||
parent: u64,
|
||||
params: &Value,
|
||||
) -> Result<Value> {
|
||||
let (scratch, out) = build(s, &Source::of(s), component, source_occurrence, transform, parent, None, params)?;
|
||||
s.accept_computed(scratch);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`paste`] from `origin` into `s`: the computed session to accept, and what the paste made.
|
||||
/// `parent_path`: the placement of `parent` the copy is placed by (default: the source
|
||||
/// placement's parent's).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build(
|
||||
s: &Session,
|
||||
origin: &Source<'_>,
|
||||
component: u64,
|
||||
source_occurrence: Option<Occurrence>,
|
||||
transform: Mat,
|
||||
parent: u64,
|
||||
parent_path: Option<Vec<u64>>,
|
||||
params: &Value,
|
||||
) -> Result<(Session, Value)> {
|
||||
let source = origin.doc;
|
||||
let source = s.doc.clone();
|
||||
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
let generated_parameters: BTreeSet<_> = source
|
||||
.params
|
||||
@@ -246,25 +199,22 @@ pub(super) fn build(
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
|
||||
.collect();
|
||||
let definitions = graph(source, component)?;
|
||||
let definitions = graph(&source, component)?;
|
||||
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
|
||||
let selected: BTreeSet<_> = definitions.iter().copied().collect();
|
||||
if source.marker.is_some_and(|m| m < source.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
|
||||
}
|
||||
if origin.other_design && s.doc.marker.is_some_and(|m| m < s.doc.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end in this design before pasting"));
|
||||
}
|
||||
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
|
||||
if feats.len() + source.features.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "too many features to copy"));
|
||||
}
|
||||
for f in &feats {
|
||||
if origin.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
|
||||
}
|
||||
}
|
||||
let state = origin.model.state();
|
||||
let state = s.model.state();
|
||||
if state.bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "body set exceeds copy work limit"));
|
||||
}
|
||||
@@ -278,12 +228,11 @@ pub(super) fn build(
|
||||
let mut names = BTreeMap::new();
|
||||
let mut bodies = BTreeMap::new();
|
||||
let mut parameters = BTreeMap::new();
|
||||
// Names are numbered among their own kind, against the names the target has: a component
|
||||
// named after its body takes the same number as the body's copy, and neither skips one the
|
||||
// other took.
|
||||
let mut used_components: BTreeSet<String> = s.doc.components.iter().map(|c| c.name.clone()).collect();
|
||||
let mut used_features: BTreeSet<String> = s.doc.features.iter().map(|f| f.name.clone()).collect();
|
||||
let mut used_bodies: BTreeSet<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
// Names are numbered among their own kind: a component named after its body takes the same
|
||||
// number as the body's copy, and neither skips one the other took.
|
||||
let mut used_components: BTreeSet<String> = source.components.iter().map(|c| c.name.clone()).collect();
|
||||
let mut used_features: BTreeSet<String> = source.features.iter().map(|f| f.name.clone()).collect();
|
||||
let mut used_bodies: BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut new_ids = Vec::new();
|
||||
let mut frozen = Vec::new();
|
||||
// Allocate definitions, discarding their automatic placeholder occurrence except root.
|
||||
@@ -294,7 +243,7 @@ pub(super) fn build(
|
||||
} else {
|
||||
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
|
||||
};
|
||||
let name = unique(&c.name, &mut used_components, origin.other_design);
|
||||
let name = unique(&c.name, &mut used_components);
|
||||
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
|
||||
// A copy keeps the definition's part properties.
|
||||
if let Some(added) = scratch.doc_mut().components.iter_mut().find(|x| x.id == new) {
|
||||
@@ -309,14 +258,12 @@ pub(super) fn build(
|
||||
}
|
||||
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
|
||||
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
|
||||
let mut clone_path = if let Some(path) = parent_path {
|
||||
path
|
||||
} else if let Some(o) = &source_occurrence {
|
||||
let mut clone_path = if let Some(o) = &source_occurrence {
|
||||
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
|
||||
path.pop();
|
||||
path
|
||||
} else {
|
||||
s.doc.first_path(parent)
|
||||
source.first_path(parent)
|
||||
};
|
||||
clone_path.push(root_occurrence);
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
|
||||
@@ -330,7 +277,7 @@ pub(super) fn build(
|
||||
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
|
||||
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
|
||||
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
|
||||
let pose = origin.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
if !super::component::valid_position(&pose) {
|
||||
return Err(bad(CMD, "invalid internal pending occurrence transform"));
|
||||
}
|
||||
@@ -345,10 +292,10 @@ pub(super) fn build(
|
||||
// The names the bodies of a feature have in the model, where they are its stored names, numbered.
|
||||
let mut shown: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in &feats {
|
||||
if let Some(result) = origin.model.result(f.id) {
|
||||
if let Some(result) = s.model.result(f.id) {
|
||||
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
|
||||
for name in &created {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies, origin.other_design));
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies));
|
||||
}
|
||||
if named_by(&f.body_names, &created) {
|
||||
shown.insert(f.id, created.clone());
|
||||
@@ -360,7 +307,7 @@ pub(super) fn build(
|
||||
if !shown.contains_key(&f.id) {
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies, origin.other_design));
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,32 +328,23 @@ pub(super) fn build(
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.iter().any(|b| *b == body.name)
|
||||
&& origin.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = origin.model.state_before(f.id);
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = origin.model.result(f.id)?.state.body(&body.name);
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let entered = entry.and_then(|f| origin.model.result(f.id)).map(|r| r.state.clone());
|
||||
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
let geometry = solidcraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solidcraft_kernel::step_export(&[&geometry], "SolidCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used_bodies, origin.other_design);
|
||||
let new_name = unique(&body.name, &mut used_bodies);
|
||||
bodies.insert(body.name.clone(), new_name.clone());
|
||||
// From another design it is named as what it is there, an imported body, numbered as
|
||||
// the timeline numbers its features ("BodyImport1").
|
||||
let feature_name = if origin.other_design {
|
||||
let name =
|
||||
(1..=MAX_WORK + 1).map(|n| format!("BodyImport{n}")).find(|n| !used_features.contains(n)).unwrap_or_else(|| "BodyImport".into());
|
||||
used_features.insert(name.clone());
|
||||
name
|
||||
} else {
|
||||
unique(&format!("{} Base", body.name), &mut used_features, false)
|
||||
};
|
||||
let feature_name = unique(&format!("{} Base", body.name), &mut used_features);
|
||||
let id = scratch.doc_mut().add_feature(
|
||||
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new(), skip: Vec::new() },
|
||||
Some(&feature_name),
|
||||
@@ -420,12 +358,8 @@ pub(super) fn build(
|
||||
}
|
||||
// Allocate all history IDs before rewriting any forward reference.
|
||||
for f in &feats {
|
||||
let name = unique(&f.name, &mut used_features, origin.other_design);
|
||||
// A transfer is checked as it is added: it names the copy's components (another design
|
||||
// has none of the original ones).
|
||||
let mut kind = f.kind.clone();
|
||||
transfer_into_copy(&mut kind, &components)?;
|
||||
let id = scratch.doc_mut().add_feature(kind, Some(&name))?;
|
||||
let name = unique(&f.name, &mut used_features);
|
||||
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
|
||||
if let Some(new) = scratch.doc.feature(id) {
|
||||
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
|
||||
parameters.insert(old.clone(), pn.clone());
|
||||
@@ -453,11 +387,6 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
// From another design, the user parameters the copy uses come along (and the ones they use),
|
||||
// renamed `w_2` where this design has a `w` already.
|
||||
if origin.other_design {
|
||||
copy_user_parameters(&mut scratch, source, &feats, &mut parameters)?;
|
||||
}
|
||||
for (old, new) in ¶meters {
|
||||
if let Some(comment) = source.param_comments.get(old) {
|
||||
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
|
||||
@@ -485,18 +414,26 @@ pub(super) fn build(
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
transfer_into_copy(&mut f.kind, &components)?;
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solidcraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
&& bodies.contains_key(old_face.body.as_ref())
|
||||
{
|
||||
face.path = cloned_path(source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.author_path.is_empty()
|
||||
{
|
||||
face.author_path = cloned_path(source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
|
||||
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
|
||||
@@ -504,9 +441,9 @@ pub(super) fn build(
|
||||
if bodies.contains_key(&original.target.body) {
|
||||
anchor.component =
|
||||
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
|
||||
anchor.path = cloned_path(source, &original.path, component, &clone_path, &occurrences)?;
|
||||
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
anchor.author_path = cloned_path(source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
@@ -545,7 +482,7 @@ pub(super) fn build(
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
let name = unique("Assigned-out bodies", &mut used_features, origin.other_design);
|
||||
let name = unique("Assigned-out bodies", &mut used_features);
|
||||
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out.into_iter().map(BodyName).collect() }, Some(&name))?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = root;
|
||||
@@ -577,20 +514,14 @@ pub(super) fn build(
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
// Typed configuration and assembly helpers preserve original targets (another design's
|
||||
// configurations stay with it: this design's table has its own targets).
|
||||
if !origin.other_design {
|
||||
scratch.doc_mut().configs.clone_targets(&solidcraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
}
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), source, &occurrences, &features, &bodies)?;
|
||||
// Joints and motion to the outside are left behind (those notices); what follows is what
|
||||
// the copied features point at outside it.
|
||||
let assembly_notices = notices.len();
|
||||
// Typed configuration and assembly helpers preserve original targets.
|
||||
scratch.doc_mut().configs.clone_targets(&solidcraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
|
||||
let mut reference_work = 0usize;
|
||||
for feature in &feats {
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
|
||||
@@ -674,22 +605,12 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
if origin.other_design {
|
||||
// What the copy points at outside itself isn't in this design.
|
||||
let mut outside = outside_references(notices.get(assembly_notices..).unwrap_or_default(), &feats, &bodies);
|
||||
if !outside.is_empty() {
|
||||
outside.sort();
|
||||
outside.dedup();
|
||||
let name = definition_records.get(&component).map(|c| c.name.clone()).unwrap_or_default();
|
||||
return Err(bad(CMD, format!("{name} uses what isn't in it, so it can't be pasted into another design: {}", outside.join("; "))));
|
||||
}
|
||||
}
|
||||
notices.sort();
|
||||
notices.dedup();
|
||||
scratch.refresh();
|
||||
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
if s.doc.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
return Err(bad(CMD, "copy would change an original feature's error state"));
|
||||
}
|
||||
// Base imports acquire new face identities. Resolve copied face overrides against the
|
||||
@@ -719,14 +640,14 @@ pub(super) fn build(
|
||||
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
|
||||
// Adding placements can invalidate cached source evaluation. Compare clone effects
|
||||
// against that same evaluation's source prefix, not older shared B-rep pointers.
|
||||
if scratch.doc.features.get(..s.doc.features.len()) != Some(s.doc.features.as_slice())
|
||||
|| scratch.doc.params.get(..s.doc.params.len()) != Some(s.doc.params.as_slice())
|
||||
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|
||||
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
|
||||
{
|
||||
return Err(bad(CMD, "copy would change original feature history or parameters"));
|
||||
}
|
||||
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
|
||||
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(s.doc.configs.columns.len()).cloned().collect();
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
|
||||
let style_validation = scratch.doc.configuration_style_status(&copied);
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
@@ -793,86 +714,10 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
|
||||
Ok((scratch, out))
|
||||
}
|
||||
|
||||
/// A copied body transfer moves its bodies between the copies of its components; one from
|
||||
/// outside the copy is where the copy starts (it stays in place, in its destination).
|
||||
fn transfer_into_copy(kind: &mut FeatureKind, components: &BTreeMap<u64, u64>) -> Result<()> {
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solidcraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What copied features point at outside the copy, in words: the external references Paste New
|
||||
/// keeps within one design (`notices`), and the bodies they work on that aren't copied.
|
||||
fn outside_references(notices: &[String], feats: &[Feature], bodies: &BTreeMap<String, String>) -> Vec<String> {
|
||||
let mut out: Vec<String> =
|
||||
notices.iter().map(|n| n.replace(" retains an external ", " uses a ").replace(" retains external ", " uses ")).collect();
|
||||
for f in feats {
|
||||
for body in f.kind.input_bodies() {
|
||||
if !bodies.contains_key(&body) {
|
||||
out.push(format!("{} uses body {body}", f.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The user parameters copied features use (their expressions and their dimensions'), and the
|
||||
/// ones those use, copied from `source` into `scratch`: named as they were where that name is
|
||||
/// free, else `w_2`, `w_3`…; `parameters` maps each to its copy.
|
||||
fn copy_user_parameters(scratch: &mut Session, source: &Document, feats: &[Feature], parameters: &mut BTreeMap<String, String>) -> Result<()> {
|
||||
let user: BTreeMap<&str, &solidcraft_doc::Param> = source.params.iter().filter(|p| !p.model).map(|p| (p.name.as_str(), p)).collect();
|
||||
let mut pending: Vec<String> = feats.iter().flat_map(|f| f.kind.expressions()).map(str::to_string).collect();
|
||||
pending.extend(parameters.keys().filter_map(|old| source.params.iter().find(|p| p.model && p.name == *old)).map(|p| p.expr.clone()));
|
||||
let mut used: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut work = 0usize;
|
||||
while let Some(expr) = pending.pop() {
|
||||
for name in solidcraft_doc::expr::references(&expr) {
|
||||
work += 1;
|
||||
if work > 100_000 {
|
||||
return Err(bad(CMD, "parameter references exceed copy work limit"));
|
||||
}
|
||||
if let Some((key, p)) = user.get_key_value(name.as_str())
|
||||
&& used.insert(key)
|
||||
{
|
||||
pending.push(p.expr.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let copied: Vec<&solidcraft_doc::Param> = source.params.iter().filter(|p| used.contains(p.name.as_str())).collect();
|
||||
if scratch.doc.params.len() + copied.len() > solidcraft_doc::MAX_PARAMS {
|
||||
return Err(bad(CMD, "too many copied parameters"));
|
||||
}
|
||||
let mut taken: BTreeSet<String> = scratch.doc.params.iter().map(|p| p.name.clone()).collect();
|
||||
for p in &copied {
|
||||
let mut name = p.name.clone();
|
||||
let mut n = 2;
|
||||
while taken.contains(&name) || Document::param_name_problem(&name).is_some() {
|
||||
name = format!("{}_{n}", p.name);
|
||||
n += 1;
|
||||
if n > 10_000 {
|
||||
return Err(bad(CMD, format!("no free name for parameter {}", p.name)));
|
||||
}
|
||||
}
|
||||
taken.insert(name.clone());
|
||||
parameters.insert(p.name.clone(), name);
|
||||
}
|
||||
for p in copied {
|
||||
let name = parameters.get(&p.name).cloned().unwrap_or_default();
|
||||
let expr = solidcraft_doc::clone_expression(&p.expr, parameters);
|
||||
scratch.doc_mut().params.push(solidcraft_doc::Param { name, expr, unit: p.unit.clone(), comment: p.comment.clone(), model: false });
|
||||
}
|
||||
Ok(())
|
||||
s.accept_computed(scratch);
|
||||
Ok(
|
||||
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Pasting what was copied in another design (`Session::foreign_copy`). Bodies come as an
|
||||
//! imported body each, without their history (that stays with the design they were made in), at
|
||||
//! the same world coordinates as in their design, in one Import base feature in the target
|
||||
//! component. Components come as a Paste New does: an independent copy of each with what it
|
||||
//! holds, its history, appearances, joints and motion, where it is in the world.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn paste_from_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.paste_from_design";
|
||||
let copy = s.foreign_copy.clone().ok_or_else(|| bad(cmd, "nothing was copied in another design"))?;
|
||||
if copy.bodies.is_empty() && copy.placed.is_empty() {
|
||||
return Err(bad(cmd, "nothing was copied in another design"));
|
||||
}
|
||||
let to = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let given = match p.get("component_path") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.filter(|a| a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
),
|
||||
};
|
||||
let path = match given {
|
||||
Some(path) => path,
|
||||
None if to == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(to)),
|
||||
None => placing::placement_of(s, &s.placed_document(), to, None, "component_path", cmd)?.0,
|
||||
};
|
||||
let frame = s.placed_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
|
||||
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
|
||||
if copy.bodies.is_empty() {
|
||||
return paste_components(s, ©, to, path, into_target, p);
|
||||
}
|
||||
// Each body at its world coordinates in its design, in the target's frame, named as in its
|
||||
// component (a body seen in another placement without that placement) where that name is
|
||||
// free here, else numbered.
|
||||
let state = s.model.state();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut shapes = Vec::new();
|
||||
for (i, seen) in copy.bodies.iter().enumerate() {
|
||||
let body = copy.world.body(seen).ok_or_else(|| bad(cmd, format!("no body `{seen}` in {}", copy.design)))?;
|
||||
let name = copy.names.get(i).unwrap_or(seen);
|
||||
let taken = |n: &str| state.body(n).is_some() || names.iter().any(|x| x == n);
|
||||
let new = if taken(name) { solidcraft_doc::copy_name(name, taken) } else { name.clone() };
|
||||
shapes.push(solidcraft_kernel::transform_matrix(&body.body, into_target)?);
|
||||
names.push(new);
|
||||
}
|
||||
drop(state);
|
||||
let list: Vec<solidcraft_kernel::ExportBody> =
|
||||
names.iter().zip(&shapes).map(|(name, body)| solidcraft_kernel::ExportBody { name: name.clone(), body, color: body.color() }).collect();
|
||||
let header = solidcraft_kernel::StepHeader { organization: "SolidCraft".into(), ..Default::default() };
|
||||
let step = solidcraft_kernel::step_export_bodies(&list, &header)?;
|
||||
let mut scratch = s.scratch();
|
||||
let kind = FeatureKind::Import { file: format!("{} (pasted)", copy.design), step: step.into(), components: Vec::new(), skip: Vec::new() };
|
||||
// Named as any import is ("Import1"); the design they came from stays on it as its file.
|
||||
let id = scratch.doc_mut().add_feature(kind, None)?;
|
||||
let feature = scratch.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = to;
|
||||
f.body_names = names.clone();
|
||||
}
|
||||
scratch.refresh();
|
||||
if let Some(error) = scratch.model.result(id).and_then(|r| r.error.as_ref()) {
|
||||
return Err(bad(cmd, format!("the pasted bodies didn't import: {error}")));
|
||||
}
|
||||
scratch.selection = names.iter().map(|name| crate::Sel::Body { name: name.clone() }).collect();
|
||||
s.accept_computed(scratch);
|
||||
Ok(json!({"bodies": names, "feature": feature, "component": to}))
|
||||
}
|
||||
|
||||
/// Independent copies of the components copied in another design, each with what it holds, in
|
||||
/// `to` (placed by `path`; `into_target` takes world coordinates into it), where each is in the
|
||||
/// world; one step to undo. They are selected.
|
||||
fn paste_components(
|
||||
s: &mut Session,
|
||||
copy: &crate::ForeignCopy,
|
||||
to: u64,
|
||||
path: Vec<u64>,
|
||||
into_target: solidcraft_doc::Mat,
|
||||
p: &Value,
|
||||
) -> Result<Value> {
|
||||
let origin = crate::cmd::component_clone::Source::copied(copy);
|
||||
let mut computed: Option<Session> = None;
|
||||
let mut made = Vec::new();
|
||||
for (component, occurrence, world) in ©.placed {
|
||||
let source_occurrence = copy.doc.occurrences.iter().find(|o| o.id == *occurrence).cloned();
|
||||
let transform = solidcraft_doc::mat_mul(&into_target, world);
|
||||
if !super::valid_position(&transform) {
|
||||
return Err(bad("component.paste_from_design", "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let target = computed.as_ref().unwrap_or(s);
|
||||
let (next, out) = crate::cmd::component_clone::build(target, &origin, *component, source_occurrence, transform, to, Some(path.clone()), p)?;
|
||||
if let Some(id) = out.get("occurrence").and_then(Value::as_u64) {
|
||||
made.push(path.iter().copied().chain([id]).collect::<Vec<u64>>());
|
||||
}
|
||||
computed = Some(next);
|
||||
}
|
||||
let Some(mut scratch) = computed else { return Err(bad("component.paste_from_design", "the copied components are gone")) };
|
||||
scratch.selection = made.iter().map(|path| crate::Sel::Occurrence { path: path.clone() }).collect();
|
||||
s.accept_computed(scratch);
|
||||
Ok(json!({"paths": made, "component": to}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_foreign_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,134 +0,0 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use crate::{ForeignCopy, Session};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
fn created(s: &mut Session, name: &str) -> (u64, u64) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap())
|
||||
}
|
||||
fn volume(s: &mut Session, id: u64) -> f64 {
|
||||
run(s, "component.properties", json!({"component": id}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
fn design(name: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.doc_mut().name = name.into();
|
||||
s
|
||||
}
|
||||
/// `s` copies its placement `path` for another design.
|
||||
fn copied(s: &Session, path: Vec<u64>) -> Option<Arc<ForeignCopy>> {
|
||||
Some(Arc::new(ForeignCopy::of(s, Vec::new(), vec![path])))
|
||||
}
|
||||
|
||||
/// A component copied in another design pastes as an independent copy of it and what it holds:
|
||||
/// its history, its appearances, a part placed twice in it still one component placed twice,
|
||||
/// where it is in the world, named "(2)" where the name is taken, as one step to undo.
|
||||
#[test]
|
||||
fn a_component_from_another_design_pastes_with_its_history() {
|
||||
let mut source = design("Bracket");
|
||||
let (assembly, at) = created(&mut source, "Assembly");
|
||||
let (child, _) = created(&mut source, "Child");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
run(&mut source, "appearance.assign", json!({"bodies": ["Cube"], "color": "#abcdef"}));
|
||||
run(&mut source, "occurrence.copy", json!({"component": child}));
|
||||
run(&mut source, "component.activate", json!({"component": 0}));
|
||||
run(&mut source, "occurrence.ground", json!({"occurrence": at, "grounded": false}));
|
||||
run(&mut source, "occurrence.move", json!({"occurrence": at, "translate": [0, 30, 0]}));
|
||||
let was = source.world_state().body("Cube").unwrap().mesh().bounds();
|
||||
let box_features = source.doc.features.len();
|
||||
|
||||
let mut s = design("Frame");
|
||||
created(&mut s, "Assembly");
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
let undo = s.undo.len();
|
||||
let out = run(&mut s, "component.paste_from_design", json!({}));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let new = s.doc.components.iter().find(|c| c.name == "Assembly (2)").expect("named (2)").id;
|
||||
assert_eq!(out["paths"], json!([[s.doc.occurrence_of(new).unwrap().id]]));
|
||||
let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].component, kids[1].component, "still one component placed twice");
|
||||
assert_eq!(s.doc.features.len(), box_features, "the box's history came along");
|
||||
assert!((volume(&mut s, new) - 16.0).abs() < 1e-6);
|
||||
assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]);
|
||||
let is = s.world_state().body("Cube").unwrap().mesh().bounds();
|
||||
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
|
||||
// Its history is its own: changing it here leaves the original as it was.
|
||||
let copy = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Box { .. })).unwrap();
|
||||
let parameter = copy.param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name": parameter, "expression": "3 mm"}));
|
||||
assert!((volume(&mut s, new) - 24.0).abs() < 1e-6);
|
||||
assert!((volume(&mut source, assembly) - 16.0).abs() < 1e-6);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.components.iter().all(|c| c.name != "Assembly (2)"));
|
||||
}
|
||||
|
||||
/// The user parameters a pasted component uses come along: as they were where the name is
|
||||
/// free, else renamed `w_2`, its features following; this design's own `w` stays as it is.
|
||||
#[test]
|
||||
fn user_parameters_come_along_renamed_where_taken() {
|
||||
let mut source = design("Bracket");
|
||||
run(&mut source, "parameters.add", json!({"name": "w", "expression": "4 mm"}));
|
||||
let (_, at) = created(&mut source, "Part");
|
||||
let feature = run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}))["feature"].as_u64().unwrap();
|
||||
let length = source.doc.feature(feature).unwrap().param_names[0].clone();
|
||||
run(&mut source, "parameters.change", json!({"name": length, "expression": "w"}));
|
||||
|
||||
let mut s = design("Frame");
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
run(&mut s, "component.paste_from_design", json!({}));
|
||||
let w2 = s.doc.params.iter().find(|p| p.name == "w_2").expect("w came along as w_2");
|
||||
assert_eq!(w2.expr, "4 mm");
|
||||
assert_eq!(s.doc.params.iter().find(|p| p.name == "w").unwrap().expr, "10 mm");
|
||||
let part = s.doc.components.iter().find(|c| c.name == "Part").unwrap().id;
|
||||
assert!((volume(&mut s, part) - 16.0).abs() < 1e-6);
|
||||
run(&mut s, "parameters.change", json!({"name": "w_2", "expression": "1 mm"}));
|
||||
assert!((volume(&mut s, part) - 4.0).abs() < 1e-6, "the copy follows w_2");
|
||||
}
|
||||
|
||||
/// A component whose features use what isn't in it (here a body outside it) isn't pasted into
|
||||
/// another design: it is refused, saying what, and the design is left as it was.
|
||||
#[test]
|
||||
fn a_component_using_what_is_outside_it_is_refused_saying_what() {
|
||||
let mut source = design("Bracket");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
let (_, at) = created(&mut source, "Part");
|
||||
run(&mut source, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Pin"}));
|
||||
run(&mut source, "solid.move", json!({"bodies": ["Cube"], "translate": [10, 0, 0]}));
|
||||
|
||||
let mut s = design("Frame");
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
let saved = s.doc.to_json();
|
||||
let error = s.execute("component.paste_from_design", &json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("uses body Cube"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
}
|
||||
|
||||
/// A component that bodies were moved into (a body transfer, as Create Components from Bodies
|
||||
/// makes) pastes into another design, which has none of the components the transfer names.
|
||||
#[test]
|
||||
fn a_component_bodies_were_moved_into_pastes_into_another_design() {
|
||||
let mut source = design("Bracket");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
let (part, at) = created(&mut source, "Part");
|
||||
run(&mut source, "component.move_bodies", json!({"bodies": ["Cube"], "component": part}));
|
||||
run(&mut source, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Pin"}));
|
||||
|
||||
let mut s = design("Frame");
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
run(&mut s, "component.paste_from_design", json!({}));
|
||||
let new = s.doc.components.iter().find(|c| c.name == "Part").unwrap().id;
|
||||
let state = s.model.state();
|
||||
assert_eq!(s.doc.model_body_component(state.body("Cube").unwrap()), new);
|
||||
assert_eq!(s.doc.model_body_component(state.body("Pin").unwrap()), new);
|
||||
// The body moved in from outside comes as an imported body.
|
||||
assert!(s.doc.find_feature("BodyImport1").is_some_and(|f| f.component == new));
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! Moving timeline features (sketches included) into another component.
|
||||
//!
|
||||
//! It is an organisational change: ids, names and timeline positions stay, nothing is copied, and
|
||||
//! the geometry stays where it is in the world (a moved feature's picks, planes and directions are
|
||||
//! re-expressed in the new component's frame). Bodies follow the feature that made them. The move
|
||||
//! is made on a scratch copy and the whole timeline is evaluated; the result is adopted only when
|
||||
//! no feature, moved or left behind, gained an error. Otherwise nothing changes.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_doc::{Feature, FeatureKind, is_identity, mat_inverse, mat_mul};
|
||||
|
||||
use super::super::clipboard::explicit_planes;
|
||||
use super::*;
|
||||
use crate::params::name_or_id_list;
|
||||
|
||||
pub(super) fn move_features(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_features";
|
||||
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let keys = name_or_id_list(cmd, p, "features")?;
|
||||
if keys.is_empty() || keys.len() > 1000 {
|
||||
return Err(bad(cmd, "`features` must list 1…1000 features"));
|
||||
}
|
||||
if s.doc.marker.is_some() {
|
||||
return Err(bad(cmd, "finish timeline rollback before moving features"));
|
||||
}
|
||||
if !s.pending_moves.is_empty() {
|
||||
return Err(bad(cmd, "finish the component move in progress first"));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for k in &keys {
|
||||
ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
|
||||
}
|
||||
let moving: BTreeSet<u64> = ids.into_iter().filter(|id| s.doc.feature(*id).is_some_and(|f| f.component != dest)).collect();
|
||||
if moving.is_empty() {
|
||||
return Err(bad(cmd, "already in that component"));
|
||||
}
|
||||
let moved: Vec<Feature> = s.doc.features.iter().filter(|f| moving.contains(&f.id)).cloned().collect();
|
||||
// One placement each: a component placed twice has no single frame to keep the geometry in.
|
||||
let placements = |c: u64| s.doc.occurrences.iter().filter(|o| o.component == c).count();
|
||||
let sources: BTreeSet<u64> = moved.iter().map(|f| f.component).collect();
|
||||
if sources.iter().chain([&dest]).any(|c| *c != 0 && placements(*c) > 1) {
|
||||
return Err(bad(cmd, "a component placed more than once can't take part in this move"));
|
||||
}
|
||||
if moved.iter().any(|f| matches!(f.kind, FeatureKind::BodyTransfer { .. })) {
|
||||
return Err(bad(cmd, "a recorded body transfer can't be moved"));
|
||||
}
|
||||
// The bodies that move with the features: those they are the origin of.
|
||||
let moved_bodies: BTreeSet<String> = s.body_origins().into_iter().filter(|(_, f)| moving.contains(f)).map(|(body, _)| body).collect();
|
||||
if moved_bodies.iter().any(|b| s.doc.body_components.contains_key(b) || s.doc.body_offsets.contains_key(b)) {
|
||||
return Err(bad(cmd, "a body that was transferred between components can't be moved with its features"));
|
||||
}
|
||||
// A join or boolean whose inputs would end up in two components.
|
||||
for f in &s.doc.features {
|
||||
let mut inputs: BTreeSet<String> = f.kind.input_bodies().into_iter().collect();
|
||||
inputs.extend(f.participants.iter().cloned());
|
||||
let (inside, outside) =
|
||||
(inputs.iter().filter(|b| moved_bodies.contains(*b)).count(), inputs.iter().filter(|b| !moved_bodies.contains(*b)).count());
|
||||
if inside > 0 && outside > 0 && inputs.len() > 1 {
|
||||
return Err(bad(cmd, format!("{} works on bodies that would be split between components", f.name)));
|
||||
}
|
||||
}
|
||||
let to_dest = mat_inverse(&s.doc.component_transform(dest)).ok_or_else(|| bad(cmd, "the target component's frame is not invertible"))?;
|
||||
let dest_path = s.doc.first_path(dest);
|
||||
let before_errors: BTreeMap<u64, bool> = s.doc.features.iter().map(|f| (f.id, s.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect();
|
||||
|
||||
let mut scratch = s.scratch();
|
||||
let doc = scratch.doc_mut();
|
||||
for old in &moved {
|
||||
let delta = mat_mul(&to_dest, &s.doc.component_transform(old.component));
|
||||
let Some(f) = doc.feature_mut(old.id) else { continue };
|
||||
f.component = dest;
|
||||
if !is_identity(&delta) {
|
||||
if let FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } = &mut f.kind {
|
||||
explicit_planes(plane);
|
||||
}
|
||||
f.kind.transform_geometry(&delta);
|
||||
}
|
||||
}
|
||||
// Attached sketches and embossed faces carry the placement of the body they sit on and of
|
||||
// the component they were authored in: those that moved are rewritten, nothing else changes.
|
||||
for f in &mut doc.features {
|
||||
let authored_here = moving.contains(&f.id);
|
||||
let rewrite = |body: &str, path: &mut Vec<u64>, author_path: &mut Vec<u64>| {
|
||||
if moved_bodies.contains(body) {
|
||||
*path = dest_path.clone();
|
||||
}
|
||||
if authored_here {
|
||||
*author_path = dest_path.clone();
|
||||
}
|
||||
};
|
||||
match &mut f.kind {
|
||||
FeatureKind::Sketch { plane, .. } => {
|
||||
if let Some(anchor) = crate::cmd::sketch::face::anchor_mut(plane) {
|
||||
let moved_body = moved_bodies.contains(&anchor.target.body);
|
||||
rewrite(&anchor.target.body, &mut anchor.path, &mut anchor.author_path);
|
||||
if moved_body {
|
||||
anchor.component = dest;
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { face: Some(face), .. } => {
|
||||
let body = face.body.as_ref().to_string();
|
||||
rewrite(&body, &mut face.path, &mut face.author_path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
scratch.refresh();
|
||||
// Any feature that gains an error, moved or left behind, stops the move.
|
||||
let broken = scratch
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| !before_errors.get(&f.id).copied().unwrap_or(false))
|
||||
.find_map(|f| scratch.model.result(f.id).and_then(|r| r.error.clone()).map(|e| (f.name.clone(), e)));
|
||||
if let Some((name, error)) = broken {
|
||||
return Err(bad(cmd, format!("moving would break {name}: {error}")));
|
||||
}
|
||||
s.accept_computed(scratch);
|
||||
let names: Vec<&str> = moved.iter().map(|f| f.name.as_str()).collect();
|
||||
Ok(json!({"moved": names, "component": dest, "bodies": moved_bodies}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The feature each body depends on first, by body name: the earliest feature whose own result
|
||||
/// holds a body of that name (a converted mesh keeps its import), else the creator the body
|
||||
/// carries (the pieces of a split). Moving that feature moves the body.
|
||||
pub fn body_origins(&self) -> BTreeMap<String, u64> {
|
||||
let mut origins = BTreeMap::new();
|
||||
for f in &self.doc.features {
|
||||
if let Some(r) = self.model.result(f.id) {
|
||||
for b in r.state.bodies.iter().filter(|b| b.feature == f.id) {
|
||||
origins.entry(b.name.clone()).or_insert(f.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in &self.model.state().bodies {
|
||||
origins.entry(b.name.clone()).or_insert(b.feature);
|
||||
}
|
||||
origins
|
||||
}
|
||||
}
|
||||
@@ -92,22 +92,16 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.accept_computed(scratch);
|
||||
follow_visibility(s, &old_path, &new_path);
|
||||
Ok(json!({"occurrence": id, "parent": to, "path": new_path, "transform": transform}))
|
||||
}
|
||||
|
||||
/// Hidden placements and folders under `old_path` follow it to `new_path`. `accept_computed` does
|
||||
/// not carry them, so whoever accepts a computed move into a session does this after.
|
||||
pub(super) fn follow_visibility(s: &mut Session, old_path: &[u64], new_path: &[u64]) {
|
||||
for hidden in &mut s.visibility.hidden_occurrences {
|
||||
if let Some(moved) = rebased(hidden, old_path, new_path) {
|
||||
if let Some(moved) = rebased(hidden, &old_path, &new_path) {
|
||||
*hidden = moved;
|
||||
}
|
||||
}
|
||||
let (old_scope, new_scope) = (Session::folder_scope(old_path), Session::folder_scope(new_path));
|
||||
let (old_scope, new_scope) = (Session::folder_scope(&old_path), Session::folder_scope(&new_path));
|
||||
for key in &mut s.visibility.hidden_folders {
|
||||
if let Some(rest) = key.strip_prefix(&old_scope).filter(|rest| rest.starts_with('/')) {
|
||||
*key = format!("{new_scope}{rest}");
|
||||
}
|
||||
}
|
||||
Ok(json!({"occurrence": id, "parent": to, "path": new_path, "transform": transform}))
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Moving placements, timeline features and bodies into a component in one undo step
|
||||
//! (`browser.reparent`: the owner of each changes, whatever its kind).
|
||||
//!
|
||||
//! What Paste does after Cut. Each kind moves by its own command (`occurrence.reparent`,
|
||||
//! `component.move_features`, `component.move_bodies`), in that order, on a scratch copy, so what
|
||||
//! an earlier step carried along is left alone by a later one: a moved component takes its
|
||||
//! features and bodies with it, moved features take the bodies they made. The result is adopted
|
||||
//! only when every step succeeds, as one undo step; otherwise nothing changes.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::move_features::move_features;
|
||||
use super::reparent::{follow_visibility, reparent};
|
||||
use super::transfer;
|
||||
use super::*;
|
||||
use crate::params::{name_or_id_list, string_list};
|
||||
|
||||
/// Most items of each kind in one move.
|
||||
const MAX_ITEMS: usize = 1000;
|
||||
|
||||
/// A placement named by its occurrence id, or by its path from the top of the design.
|
||||
fn placements(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(u64, Option<Vec<u64>>)>> {
|
||||
let list = match p.get("occurrences") {
|
||||
None | Some(Value::Null) => return Ok(Vec::new()),
|
||||
Some(Value::Array(a)) if a.len() <= MAX_ITEMS => a,
|
||||
Some(_) => return Err(bad(cmd, format!("`occurrences` must list at most {MAX_ITEMS} placements"))),
|
||||
};
|
||||
let mut out: Vec<(u64, Option<Vec<u64>>)> = Vec::new();
|
||||
for entry in list {
|
||||
let found = match entry {
|
||||
Value::Number(n) => n.as_u64().map(|id| (id, None)),
|
||||
Value::Array(a) if !a.is_empty() && a.len() <= 64 => {
|
||||
a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>().and_then(|path| path.last().copied().map(|id| (id, Some(path))))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (id, path) = found.ok_or_else(|| bad(cmd, "`occurrences` must list occurrence ids or paths of 1..64 occurrence ids"))?;
|
||||
if !s.doc.occurrences.iter().any(|o| o.id == id) {
|
||||
return Err(bad(cmd, format!("no occurrence {id}")));
|
||||
}
|
||||
if !out.iter().any(|(seen, _)| *seen == id) {
|
||||
out.push((id, path));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn reparent_items(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.reparent";
|
||||
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let dest_path = p.get("component_path").cloned();
|
||||
let occurrences = placements(s, p, cmd)?;
|
||||
let features = name_or_id_list(cmd, p, "features")?;
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
if features.len() > MAX_ITEMS || bodies.len() > MAX_ITEMS {
|
||||
return Err(bad(cmd, format!("`features` and `bodies` must list at most {MAX_ITEMS} items each")));
|
||||
}
|
||||
if occurrences.is_empty() && features.is_empty() && bodies.is_empty() {
|
||||
return Err(bad(cmd, "give `occurrences`, `features` or `bodies` to move"));
|
||||
}
|
||||
if s.doc.marker.is_some() {
|
||||
return Err(bad(cmd, "finish timeline rollback before moving"));
|
||||
}
|
||||
let mut feature_ids = BTreeSet::new();
|
||||
for k in &features {
|
||||
feature_ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
|
||||
}
|
||||
{
|
||||
let state = s.model.state();
|
||||
if let Some(missing) = bodies.iter().find(|b| state.body(b).is_none()) {
|
||||
return Err(bad(cmd, format!("no body `{missing}`")));
|
||||
}
|
||||
}
|
||||
|
||||
let mut sc = s.scratch();
|
||||
sc.active_component = s.active_component;
|
||||
sc.active_occurrence_path = s.active_occurrence_path.clone();
|
||||
|
||||
// Components first: each takes everything it holds.
|
||||
let mut moved_placements = Vec::new();
|
||||
let mut moved_components = Vec::new();
|
||||
let mut followed: Vec<(Vec<u64>, Vec<u64>)> = Vec::new();
|
||||
for (id, path) in &occurrences {
|
||||
let Some(o) = sc.doc.occurrences.iter().find(|o| o.id == *id).cloned() else { continue };
|
||||
if o.parent == dest {
|
||||
continue;
|
||||
}
|
||||
let mut params = json!({"occurrence": id, "parent": dest});
|
||||
if let Some(path) = path {
|
||||
params["path"] = json!(path);
|
||||
}
|
||||
if let Some(parent_path) = &dest_path {
|
||||
params["parent_path"] = parent_path.clone();
|
||||
}
|
||||
let old_path = occurrence_path(&sc, ¶ms, *id, cmd)?;
|
||||
let out = reparent(&mut sc, ¶ms)?;
|
||||
let new_path = out.get("path").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect::<Vec<_>>());
|
||||
followed.extend(new_path.map(|new| (old_path, new)));
|
||||
moved_placements.push(*id);
|
||||
moved_components.push(o.component);
|
||||
}
|
||||
// What a moved component holds travels with it.
|
||||
let travels = |sc: &Session, component: u64| moved_components.iter().any(|c| sc.doc.inside_component(component, *c));
|
||||
|
||||
// Then features, which take the bodies they made.
|
||||
let features_to_move: Vec<u64> =
|
||||
feature_ids.iter().copied().filter(|id| sc.doc.feature(*id).is_some_and(|f| f.component != dest && !travels(&sc, f.component))).collect();
|
||||
let mut moved_features = Vec::new();
|
||||
if !features_to_move.is_empty() {
|
||||
let mut params = json!({"features": features_to_move, "component": dest});
|
||||
if let Some(path) = &dest_path {
|
||||
params["component_path"] = path.clone();
|
||||
}
|
||||
let out = move_features(&mut sc, ¶ms)?;
|
||||
moved_features =
|
||||
out.get("moved").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
}
|
||||
|
||||
// Last the bodies still elsewhere: one a moved component or feature carried is already there.
|
||||
let bodies_to_move: Vec<String> = {
|
||||
let state = sc.model.state();
|
||||
bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
state.body(b).is_some_and(|body| {
|
||||
let at = sc.doc.model_body_component(body);
|
||||
at != dest && !travels(&sc, at)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
if !bodies_to_move.is_empty() {
|
||||
let mut params = json!({"bodies": bodies_to_move, "component": dest});
|
||||
if let Some(path) = &dest_path {
|
||||
params["component_path"] = path.clone();
|
||||
}
|
||||
transfer::move_bodies(&mut sc, ¶ms)?;
|
||||
}
|
||||
|
||||
if moved_placements.is_empty() && moved_features.is_empty() && bodies_to_move.is_empty() {
|
||||
return Err(bad(cmd, "already in that component"));
|
||||
}
|
||||
// The steps above are this command's one undo step.
|
||||
sc.undo.clear();
|
||||
s.accept_computed(sc);
|
||||
for (old, new) in &followed {
|
||||
follow_visibility(s, old, new);
|
||||
}
|
||||
Ok(json!({"component": dest, "occurrences": moved_placements, "features": moved_features, "bodies": bodies_to_move}))
|
||||
}
|
||||
@@ -290,205 +290,3 @@ fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
|
||||
assert_eq!(reopened.model.state().body("Block (2)").unwrap().component, copied);
|
||||
}
|
||||
|
||||
/// A 10 × 10 × 5 block (sketch `S`, extrude `Extrude1`, body `Block`) in the design with a sketch
|
||||
/// `Top` on its top face, and a component `Part` placed 100 mm along X. The design is active.
|
||||
fn block_and_part() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [5, 5, 5]}, "name": "Top"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
|
||||
let first = s.doc.occurrence_of(part).map(|o| o.id).unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": first, "translate": [100, 0, 0], "capture": true, "move_grounded": true}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
(s, part)
|
||||
}
|
||||
|
||||
/// The lowest corner of every body in the world, rounded.
|
||||
fn world_corners(s: &Session) -> Vec<Vec<f64>> {
|
||||
s.world_state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let min = b.mesh().bounds().min;
|
||||
vec![min.x.round(), min.y.round(), min.z.round()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_features_keeps_the_world_and_the_attachment_in_the_active_component() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let ids: Vec<u64> = s.doc.features.iter().map(|f| f.id).collect();
|
||||
let names: Vec<String> = s.doc.features.iter().map(|f| f.name.clone()).collect();
|
||||
let corners = world_corners(&s);
|
||||
// No `component`: the features go to the active one.
|
||||
run(&mut s, "component.activate", json!({"component": part}));
|
||||
let r = run(&mut s, "component.move_features", json!({"features": ["S", "Extrude1"]}));
|
||||
assert_eq!(r["moved"], json!(["S", "Extrude1"]), "{r}");
|
||||
// Nothing was added or renamed.
|
||||
assert_eq!(s.doc.features.iter().map(|f| f.id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(s.doc.features.iter().map(|f| f.name.clone()).collect::<Vec<_>>(), names);
|
||||
for name in ["S", "Extrude1"] {
|
||||
assert_eq!(s.doc.find_feature(name).map(|f| f.component), Some(part));
|
||||
}
|
||||
assert_eq!(s.doc.find_feature("Top").map(|f| f.component), Some(0));
|
||||
assert_eq!(world_corners(&s), corners, "the block is where it was in the world");
|
||||
// The sketch left behind follows the body into its new component and still evaluates.
|
||||
let top = s.doc.find_feature("Top").unwrap();
|
||||
assert!(s.model.result(top.id).is_some_and(|r| r.error.is_none()));
|
||||
let anchor = match top.kind.clone() {
|
||||
FeatureKind::Sketch { mut plane, .. } => crate::cmd::sketch::face::anchor_mut(&mut plane).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(anchor.map(|a| (a.component, a.path)), Some((part, s.doc.first_path(part))));
|
||||
}
|
||||
|
||||
/// Moved without its body, or with the body moved from under it, a feature would break: the move
|
||||
/// is refused and nothing changes.
|
||||
#[test]
|
||||
fn move_features_refuses_a_move_that_would_break_a_feature() {
|
||||
for moves in [["S2", "Cut1"], ["S1", "Extrude1"]] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S1", "distance": 10, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S2"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S2", "distance": 4, "operation": "cut", "name": "Cut1"}));
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part", "activate": false}))["component"].as_u64().unwrap();
|
||||
let (before, steps) = (s.doc.to_json(), s.undo.len());
|
||||
let e = s.execute("component.move_features", &json!({"features": moves, "component": part})).unwrap_err().to_string();
|
||||
assert!(e.contains("moving would"), "{moves:?}: {e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps), "{moves:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_features_refusals_change_nothing() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let refused = |s: &mut Session, params: Value, why: &str| {
|
||||
let (before, steps) = (s.doc.to_json(), s.undo.len());
|
||||
let e = s.execute("component.move_features", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
|
||||
};
|
||||
refused(&mut s, json!({"features": ["S"], "component": 0}), "already in that component");
|
||||
refused(&mut s, json!({"features": ["Nope"], "component": part}), "no feature");
|
||||
refused(&mut s, json!({"features": [], "component": part}), "1…1000");
|
||||
refused(&mut s, json!({"features": ["S"], "component": "Nowhere"}), "no component");
|
||||
// A component placed twice.
|
||||
run(&mut s, "occurrence.copy", json!({"component": part}));
|
||||
refused(&mut s, json!({"features": ["S", "Extrude1"], "component": part}), "more than once");
|
||||
// Rolled back.
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
refused(&mut s, json!({"features": ["S"], "component": part}), "rollback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_features_refuses_a_boolean_whose_inputs_would_be_split() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A", "name": "BoxA"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B", "name": "BoxB"}),
|
||||
);
|
||||
run(&mut s, "solid.combine", json!({"target": "A", "tools": ["B"], "operation": "join", "name": "Joined"}));
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part", "activate": false}))["component"].as_u64().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let e = s.execute("component.move_features", &json!({"features": ["BoxA"], "component": part})).unwrap_err().to_string();
|
||||
assert!(e.contains("split between components"), "{e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
/// Each kind moves by its own step, all or nothing, as one undo step: a block's features without
|
||||
/// the cut that works on it break, so the first call changes nothing; with it, the second moves
|
||||
/// the placement, the features and the body that is not made by them, and skips the block they carry.
|
||||
#[test]
|
||||
fn reparent_items_moves_each_kind_all_or_nothing() {
|
||||
let (mut s, part) = block_and_part();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S2"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S2", "distance": 4, "operation": "cut", "name": "Cut1"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "operation": "new_body", "body_name": "Plate", "name": "PlateBox"}),
|
||||
);
|
||||
let sub = run(&mut s, "component.create", json!({"name": "Sub", "activate": false}));
|
||||
let sub_occurrence = sub["occurrence"].as_u64().unwrap();
|
||||
let (before, steps, corners) = (s.doc.to_json(), s.undo.len(), world_corners(&s));
|
||||
let moves = |features: Value| json!({"occurrences": [sub_occurrence], "features": features, "bodies": ["Block", "Plate"], "component": part});
|
||||
// Cut1 would be left without its body: nothing moves, the placement included.
|
||||
let e = s.execute("browser.reparent", &moves(json!(["S", "Extrude1"]))).unwrap_err().to_string();
|
||||
assert!(e.contains("moving would"), "{e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
|
||||
let r = run(&mut s, "browser.reparent", moves(json!(["S", "Extrude1", "S2", "Cut1"])));
|
||||
assert_eq!(r["occurrences"], json!([sub_occurrence]), "{r}");
|
||||
assert_eq!(r["features"], json!(["S", "Extrude1", "S2", "Cut1"]), "{r}");
|
||||
assert_eq!(r["bodies"], json!(["Plate"]), "Block went with its features: {r}");
|
||||
assert_eq!(s.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == sub_occurrence).map(|o| o.parent), Some(part));
|
||||
assert_eq!(world_corners(&s), corners, "everything is where it was in the world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_items_leaves_the_contents_of_a_moved_component_alone() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let dest = run(&mut s, "component.create", json!({"name": "Dest", "activate": false}))["component"].as_u64().unwrap();
|
||||
let occurrence = s.doc.occurrence_of(part).map(|o| o.id).unwrap();
|
||||
// A feature inside Part is named as well as Part: it travels with Part, not on its own.
|
||||
run(&mut s, "component.activate", json!({"component": part}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "Inner", "name": "InnerBox"}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
let r = run(&mut s, "browser.reparent", json!({"occurrences": [occurrence], "features": ["InnerBox"], "bodies": ["Inner"], "component": dest}));
|
||||
assert_eq!(r["features"], json!([]), "{r}");
|
||||
assert_eq!(r["bodies"], json!([]), "{r}");
|
||||
assert_eq!(s.doc.find_feature("InnerBox").map(|f| f.component), Some(part));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_items_refusals_change_nothing() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let refused = |s: &mut Session, params: Value, why: &str| {
|
||||
let (before, steps) = (s.doc.to_json(), s.undo.len());
|
||||
let e = s.execute("browser.reparent", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
|
||||
};
|
||||
refused(&mut s, json!({"component": part}), "give `occurrences`");
|
||||
refused(&mut s, json!({"features": ["S"], "component": 0}), "already in that component");
|
||||
refused(&mut s, json!({"bodies": ["Nope"], "component": part}), "no body");
|
||||
refused(&mut s, json!({"occurrences": [9999], "component": part}), "no occurrence");
|
||||
refused(&mut s, json!({"occurrences": ["x"], "component": part}), "occurrence ids");
|
||||
let many: Vec<u64> = (0..1001).collect();
|
||||
refused(&mut s, json!({"occurrences": many, "component": part}), "at most 1000");
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
refused(&mut s, json!({"features": ["S"], "component": part}), "rollback");
|
||||
}
|
||||
|
||||
/// Placements hidden inside a moved component are still hidden under their new path.
|
||||
#[test]
|
||||
fn reparent_items_keeps_hidden_placements_hidden() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let made = run(&mut s, "component.create", json!({"name": "Dest", "activate": false}));
|
||||
let (dest, dest_occurrence) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
|
||||
let outer = run(&mut s, "component.create", json!({"name": "Outer", "activate": false}));
|
||||
let (outer_component, outer_occurrence) = (outer["component"].as_u64().unwrap(), outer["occurrence"].as_u64().unwrap());
|
||||
let inner =
|
||||
run(&mut s, "component.create", json!({"name": "Inner", "parent": outer_component, "activate": false}))["occurrence"].as_u64().unwrap();
|
||||
s.visibility.hidden_occurrences.push(vec![outer_occurrence, inner]);
|
||||
run(&mut s, "browser.reparent", json!({"occurrences": [outer_occurrence], "component": dest}));
|
||||
assert_eq!(s.visibility.hidden_occurrences, vec![vec![dest_occurrence, outer_occurrence, inner]]);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == outer_occurrence).map(|o| o.parent), Some(dest));
|
||||
let _ = part;
|
||||
}
|
||||
|
||||
@@ -14,267 +14,135 @@ use solidcraft_geom::Vec3;
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, plane_param, sketch_id};
|
||||
use crate::params::{bad, req_expr, str_, vec3};
|
||||
use crate::schema::construct::{
|
||||
AxisEdgeParams, AxisNormalToFaceParams, AxisThroughCylinderParams, AxisTwoPlanesParams, AxisTwoPointsParams, MidplaneParams,
|
||||
PlaneAlongPathParams, PlanePerpendicularParams, PlaneTangentParams, PlaneThreePointsParams, PlaneTwoEdgesParams, PointAlongPathParams,
|
||||
PointCenterParams, PointEdgePlaneParams, PointThreePlanesParams, PointTwoEdgesParams, PointVertexParams,
|
||||
};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(crate) const REF: &str =
|
||||
const REF: &str =
|
||||
"{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}";
|
||||
|
||||
const AXIS_THROUGH_CYLINDER_CONTRACT: TypedContract<AxisThroughCylinderParams> = TypedContract::new();
|
||||
const AXIS_NORMAL_TO_FACE_CONTRACT: TypedContract<AxisNormalToFaceParams> = TypedContract::new();
|
||||
const AXIS_TWO_PLANES_CONTRACT: TypedContract<AxisTwoPlanesParams> = TypedContract::new();
|
||||
const AXIS_TWO_POINTS_CONTRACT: TypedContract<AxisTwoPointsParams> = TypedContract::new();
|
||||
const AXIS_EDGE_CONTRACT: TypedContract<AxisEdgeParams> = TypedContract::new();
|
||||
const POINT_VERTEX_CONTRACT: TypedContract<PointVertexParams> = TypedContract::new();
|
||||
const POINT_TWO_EDGES_CONTRACT: TypedContract<PointTwoEdgesParams> = TypedContract::new();
|
||||
const POINT_THREE_PLANES_CONTRACT: TypedContract<PointThreePlanesParams> = TypedContract::new();
|
||||
const POINT_CENTER_CONTRACT: TypedContract<PointCenterParams> = TypedContract::new();
|
||||
const POINT_EDGE_PLANE_CONTRACT: TypedContract<PointEdgePlaneParams> = TypedContract::new();
|
||||
const POINT_ALONG_PATH_CONTRACT: TypedContract<PointAlongPathParams> = TypedContract::new();
|
||||
const PLANE_MIDPLANE_CONTRACT: TypedContract<MidplaneParams> = TypedContract::new();
|
||||
const PLANE_TWO_EDGES_CONTRACT: TypedContract<PlaneTwoEdgesParams> = TypedContract::new();
|
||||
const PLANE_THREE_POINTS_CONTRACT: TypedContract<PlaneThreePointsParams> = TypedContract::new();
|
||||
const PLANE_TANGENT_CONTRACT: TypedContract<PlaneTangentParams> = TypedContract::new();
|
||||
const PLANE_ALONG_PATH_CONTRACT: TypedContract<PlaneAlongPathParams> = TypedContract::new();
|
||||
const PLANE_PERPENDICULAR_CONTRACT: TypedContract<PlanePerpendicularParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", crate::checked!("construct.axis.through_cylinder", axis_face))
|
||||
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis_through_cylinder")
|
||||
.contract(&AXIS_THROUGH_CYLINDER_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"),
|
||||
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", crate::checked!("construct.axis.normal_to_face", axis_normal))
|
||||
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis_normal_to_face")
|
||||
.contract(&AXIS_NORMAL_TO_FACE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("face: ref; point: ref (the axis goes through it, square to the face); name?"),
|
||||
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", crate::checked!("construct.axis.two_planes", axis_planes))
|
||||
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis_two_planes")
|
||||
.contract(&AXIS_TWO_PLANES_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b: plane or planar face refs; name?"),
|
||||
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", crate::checked!("construct.axis.two_points", axis_points))
|
||||
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis_two_points")
|
||||
.contract(&AXIS_TWO_POINTS_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b: point refs (vertices, sketch points, points); name?"),
|
||||
CommandSpec::new("construct.axis.edge", "Axis Through Edge", crate::checked!("construct.axis.edge", axis_edge))
|
||||
CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis_edge")
|
||||
.contract(&AXIS_EDGE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("edge: ref (a straight edge or sketch line); name?"),
|
||||
CommandSpec::new("construct.point.vertex", "Point At Vertex", crate::checked!("construct.point.vertex", point_at))
|
||||
CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point_vertex")
|
||||
.contract(&POINT_VERTEX_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("point: ref (vertex, sketch point, point); name?"),
|
||||
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", crate::checked!("construct.point.two_edges", point_edges))
|
||||
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point_two_edges")
|
||||
.contract(&POINT_TWO_EDGES_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b: edge or line refs (where they meet, or come closest); name?"),
|
||||
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", crate::checked!("construct.point.three_planes", point_planes))
|
||||
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point_three_planes")
|
||||
.contract(&POINT_THREE_PLANES_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b, c: plane or planar face refs; name?"),
|
||||
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", crate::checked!("construct.point.center", point_center))
|
||||
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point_center")
|
||||
.contract(&POINT_CENTER_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"),
|
||||
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", crate::checked!("construct.point.edge_plane", point_edge_plane))
|
||||
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point_edge_plane")
|
||||
.contract(&POINT_EDGE_PLANE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("edge: edge or line ref; plane: plane or planar face ref; name?"),
|
||||
CommandSpec::new("construct.point.along_path", "Point Along Path", crate::checked!("construct.point.along_path", point_along))
|
||||
CommandSpec::new("construct.point.along_path", "Point Along Path", point_along)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point_along_path")
|
||||
.contract(&POINT_ALONG_PATH_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"),
|
||||
CommandSpec::new("construct.plane.midplane", "Midplane", crate::checked!("construct.plane.midplane", plane_mid))
|
||||
CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_midplane")
|
||||
.contract(&PLANE_MIDPLANE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"),
|
||||
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", crate::checked!("construct.plane.two_edges", plane_edges))
|
||||
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_two_edges")
|
||||
.contract(&PLANE_TWO_EDGES_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b: straight edge or line refs in one plane; name?"),
|
||||
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", crate::checked!("construct.plane.three_points", plane_points))
|
||||
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_three_points")
|
||||
.contract(&PLANE_THREE_POINTS_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("a, b, c: point refs; name?"),
|
||||
CommandSpec::new("construct.plane.tangent", "Tangent Plane", crate::checked!("construct.plane.tangent", plane_tangent))
|
||||
CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_tangent")
|
||||
.contract(&PLANE_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"),
|
||||
CommandSpec::new("construct.plane.along_path", "Plane Along Path", crate::checked!("construct.plane.along_path", plane_along))
|
||||
CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_along_path")
|
||||
.contract(&PLANE_ALONG_PATH_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
|
||||
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", crate::checked!("construct.plane.perpendicular", plane_perpendicular))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_perpendicular")
|
||||
.contract(&PLANE_PERPENDICULAR_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
|
||||
),
|
||||
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane_perpendicular").params(
|
||||
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
|
||||
),
|
||||
];
|
||||
|
||||
/// Whether [`model_axis_param`] reads `v` as an edge or face of the model: `{edge: …}`,
|
||||
/// `{face: …}`, or as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it
|
||||
/// kept.
|
||||
pub(crate) fn is_model_axis(v: &Value) -> bool {
|
||||
v.get("edge").is_some() || v.get("face").is_some() || matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face"))
|
||||
}
|
||||
|
||||
/// An axis on an edge or round face of the model (`{edge: [x,y,z]}` or `{face: [x,y,z]}`, the
|
||||
/// point on it), with the line it gives now (`solidcraft_doc::construct::model_axis`); `None`
|
||||
/// for any other form of `axis`.
|
||||
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
|
||||
let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) };
|
||||
// Also as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it kept.
|
||||
let saved = |v: &Value| matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face"));
|
||||
let Some(v) = v.filter(|v| v.get("edge").is_some() || v.get("face").is_some() || saved(v)) else { return Ok(None) };
|
||||
let g = geo(s, Some(v), cmd, "axis")?;
|
||||
let (o, d) = solidcraft_doc::construct::model_axis(&s.world_state(), &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
|
||||
Ok(Some((g, o, d)))
|
||||
}
|
||||
|
||||
/// A reference as [`geo`] reads it before looking anything up in the design: which form it has.
|
||||
pub(crate) enum GeoForm<'a> {
|
||||
/// As saved (`{type, …}`), read whole.
|
||||
Saved(GeoRef),
|
||||
Face(Vec3),
|
||||
Edge(Vec3),
|
||||
Vertex(Vec3),
|
||||
/// `{sketch, point|curve}`: the sketch as given, and the point or curve, when one is given.
|
||||
Sketch {
|
||||
sketch: &'a Value,
|
||||
part: Option<SketchPart<'a>>,
|
||||
},
|
||||
Point(Vec3),
|
||||
/// `{plane: …}`'s value, or a bare string: a plane, as `plane_param` reads it.
|
||||
Plane(&'a Value),
|
||||
/// `{axis: X|Y|Z|name}`.
|
||||
Axis(&'a str),
|
||||
/// `{construct_point: name}`.
|
||||
ConstructPoint(&'a str),
|
||||
}
|
||||
|
||||
/// A sketch reference's point or curve id.
|
||||
pub(crate) enum SketchPart<'a> {
|
||||
Point(&'a str),
|
||||
Curve(&'a str),
|
||||
}
|
||||
|
||||
/// Why [`geo_form`] refuses a reference.
|
||||
pub(crate) enum GeoFormError {
|
||||
/// A saved form serde refuses, with its message.
|
||||
Saved(String),
|
||||
/// None of the forms.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// What [`geo`] says when a sketch reference has neither `point` nor `curve`.
|
||||
pub(crate) const SKETCH_PART: &str = "a sketch reference needs `point` or `curve`";
|
||||
|
||||
/// The form [`geo`] reads `v` as. The first that fits wins, in this order: `type` (read whole), a
|
||||
/// point at `face`, `edge` or `vertex`, `sketch` (whatever else it has), a point at `point`,
|
||||
/// `plane` (whatever it holds), `axis` and `construct_point` as text, and a bare string.
|
||||
pub(crate) fn geo_form(v: &Value) -> std::result::Result<GeoForm<'_>, GeoFormError> {
|
||||
let p3 = |k: &str| v.get(k).and_then(vec3);
|
||||
Ok(if v.get("type").is_some() {
|
||||
GeoForm::Saved(serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| GeoFormError::Saved(e.to_string()))?)
|
||||
} else if let Some(at) = p3("face") {
|
||||
GeoForm::Face(at)
|
||||
} else if let Some(at) = p3("edge") {
|
||||
GeoForm::Edge(at)
|
||||
} else if let Some(at) = p3("vertex") {
|
||||
GeoForm::Vertex(at)
|
||||
} else if let Some(sketch) = v.get("sketch") {
|
||||
let part = match (str_(v, "point"), str_(v, "curve")) {
|
||||
(Some(point), _) => Some(SketchPart::Point(point)),
|
||||
(None, Some(curve)) => Some(SketchPart::Curve(curve)),
|
||||
_ => None,
|
||||
};
|
||||
GeoForm::Sketch { sketch, part }
|
||||
} else if let Some(p) = p3("point") {
|
||||
GeoForm::Point(p)
|
||||
} else if let Some(plane) = v.get("plane") {
|
||||
GeoForm::Plane(plane)
|
||||
} else if let Some(a) = str_(v, "axis") {
|
||||
GeoForm::Axis(a)
|
||||
} else if let Some(n) = str_(v, "construct_point") {
|
||||
GeoForm::ConstructPoint(n)
|
||||
} else if v.is_string() {
|
||||
GeoForm::Plane(v)
|
||||
} else {
|
||||
return Err(GeoFormError::Unknown);
|
||||
})
|
||||
}
|
||||
|
||||
/// A reference from its JSON form.
|
||||
pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
|
||||
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
|
||||
let mut g = match geo_form(v) {
|
||||
Ok(GeoForm::Saved(g)) => g,
|
||||
Ok(GeoForm::Face(at)) => GeoRef::Face { at, name: None },
|
||||
Ok(GeoForm::Edge(at)) => GeoRef::Edge { at, name: None },
|
||||
Ok(GeoForm::Vertex(at)) => GeoRef::Vertex { at, edges: None },
|
||||
Ok(GeoForm::Sketch { sketch, part }) => {
|
||||
let sketch = sketch_id(s, Some(sketch), cmd, what)?;
|
||||
match part {
|
||||
Some(SketchPart::Point(point)) => GeoRef::SketchPoint { sketch, point: point.to_string() },
|
||||
Some(SketchPart::Curve(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
|
||||
None => return Err(bad(cmd, format!("`{what}`: {SKETCH_PART}"))),
|
||||
let p3 = |k: &str| v.get(k).and_then(vec3);
|
||||
let mut g = if v.get("type").is_some() {
|
||||
serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))?
|
||||
} else if let Some(at) = p3("face") {
|
||||
GeoRef::Face { at, name: None }
|
||||
} else if let Some(at) = p3("edge") {
|
||||
GeoRef::Edge { at, name: None }
|
||||
} else if let Some(at) = p3("vertex") {
|
||||
GeoRef::Vertex { at, edges: None }
|
||||
} else if let Some(sk) = v.get("sketch") {
|
||||
let sketch = sketch_id(s, Some(sk), cmd, what)?;
|
||||
match (str_(v, "point"), str_(v, "curve")) {
|
||||
(Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() },
|
||||
(None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
|
||||
_ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))),
|
||||
}
|
||||
} else if let Some(p) = p3("point") {
|
||||
GeoRef::Point { p }
|
||||
} else if v.get("plane").is_some() {
|
||||
GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? }
|
||||
} else if let Some(a) = str_(v, "axis") {
|
||||
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
|
||||
GeoRef::World { axis: a.to_ascii_uppercase() }
|
||||
} else {
|
||||
match s.doc.find_feature(a).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
|
||||
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
|
||||
}
|
||||
}
|
||||
Ok(GeoForm::Point(p)) => GeoRef::Point { p },
|
||||
Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
|
||||
Ok(GeoForm::Axis(a)) => {
|
||||
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
|
||||
GeoRef::World { axis: a.to_ascii_uppercase() }
|
||||
} else {
|
||||
match s.doc.find_feature(a).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
|
||||
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
|
||||
} else if let Some(n) = str_(v, "construct_point") {
|
||||
match s.doc.find_feature(n).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
|
||||
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
|
||||
},
|
||||
Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))),
|
||||
Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))),
|
||||
}
|
||||
} else if v.as_str().is_some() {
|
||||
// A bare string: a plane.
|
||||
GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? }
|
||||
} else {
|
||||
return Err(bad(cmd, format!("`{what}` must be {REF}")));
|
||||
};
|
||||
g.name_picks(&s.model.state());
|
||||
Ok(g)
|
||||
|
||||
@@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
@@ -1773,23 +1773,9 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
// A feature behind the history marker is not built, so the edit changes nothing in the model
|
||||
// yet: the reply says so, or the edit would look as if it had silently failed (#8100). Its
|
||||
// warning is the one the constructor met building it in the trial, at its place.
|
||||
let rolled_back = s.doc.marker.is_some_and(|marker| s.doc.feature_index(id).is_some_and(|index| index >= marker));
|
||||
let warning =
|
||||
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
|
||||
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
|
||||
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
|
||||
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
|
||||
if let Some(warning) = warning {
|
||||
reply["warning"] = json!(warning);
|
||||
}
|
||||
if rolled_back {
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
|
||||
}
|
||||
Ok(reply)
|
||||
Ok(json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused}))
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||