Release 0.13.0

The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
This commit is contained in:
bherbruck committed 2026-10-15 19:59:50 +00:00
commit e006dbf1af
233 files changed
+25075 -1833

No files matched your search

+4 -1
View File
@@ -365,12 +365,15 @@ jobs:
with:
key: windows-startup
- name: Backend order and probing on Windows
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
- name: Build the app
run: cargo build -p solidcraft --profile ci --locked
- name: Start the app under different settings
shell: pwsh
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
- name: Resize border from the first frame
shell: pwsh
run: tools/windows-resize-smoke.ps1 -Exe target/ci/solidcraft.exe
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
+9 -3
View File
@@ -27,11 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers).
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
@@ -79,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
+5 -3
View File
@@ -27,11 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers).
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
+6
View File
@@ -116,6 +116,12 @@ The [README](README.md#architecture) has the layer table. The short version:
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
`NavCaps` saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
`hand_rolled_camera_input_is_rejected` test fails if a page does.
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style.
Generated
+424 -37
View File
@@ -24,7 +24,7 @@ dependencies = [
"atspi-common",
"phf",
"serde",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
@@ -86,7 +86,7 @@ dependencies = [
"futures-lite",
"futures-util",
"serde",
"zbus",
"zbus 5.19.0",
]
[[package]]
@@ -123,6 +123,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -280,6 +291,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -398,11 +420,11 @@ dependencies = [
"enumflags2",
"serde",
"static_assertions",
"zbus",
"zbus 5.19.0",
"zbus-lockstep",
"zbus-lockstep-macros",
"zbus_names",
"zvariant",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
@@ -413,7 +435,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
dependencies = [
"atspi-common",
"serde",
"zbus",
"zbus 5.19.0",
]
[[package]]
@@ -467,6 +489,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -533,6 +564,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -595,6 +632,15 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.6.0"
@@ -652,6 +698,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -821,6 +877,35 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dbus"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
@@ -853,6 +938,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1322,6 +1408,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -1335,8 +1427,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
@@ -1594,6 +1689,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -1764,6 +1877,16 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1878,6 +2001,23 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zbus 4.4.0",
"zeroize",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -1913,6 +2053,15 @@ version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
@@ -2123,12 +2272,49 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -2138,6 +2324,36 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3098,7 +3314,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -3125,7 +3341,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -3184,6 +3400,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand",
"serde",
"sha2",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3282,6 +3530,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3445,7 +3704,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -3453,12 +3712,14 @@ dependencies = [
"embed-resource",
"getrandom 0.3.4",
"hidapi",
"keyring",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"quick-xml",
"raw-window-handle",
"rfd",
"ron",
"rustls",
@@ -3472,7 +3733,7 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3481,7 +3742,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"proc-macro2",
"quote",
@@ -3490,7 +3751,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"log",
"serde",
@@ -3507,7 +3768,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"log",
"serde",
@@ -3523,7 +3784,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde",
"serde_json",
@@ -3532,7 +3793,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"log",
"naga",
@@ -3545,7 +3806,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"quick-xml",
"serde",
@@ -3560,7 +3821,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"log",
"rayon",
@@ -3578,7 +3839,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3586,18 +3847,20 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"image",
"moxcms",
"png",
"serde",
"serde_json",
"sha2",
"solidcraft-geom",
]
[[package]]
name = "solidcraft-sketch"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde",
"serde_json",
@@ -3608,7 +3871,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3629,7 +3892,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"eframe",
"egui",
@@ -3964,6 +4227,7 @@ dependencies = [
"spade",
"truck-base",
"truck-geometry",
"truck-modeling",
"truck-polymesh",
"truck-topology",
]
@@ -5080,6 +5344,16 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "xkbcommon-dl"
version = "0.4.2"
@@ -5107,7 +5381,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde_json",
"sha2",
@@ -5136,6 +5410,44 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.19.0"
@@ -5166,9 +5478,9 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.19.0",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
@@ -5178,7 +5490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
dependencies = [
"zbus_xml",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
@@ -5192,7 +5504,20 @@ dependencies = [
"syn 2.0.119",
"zbus-lockstep",
"zbus_xml",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -5205,9 +5530,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.4",
"zvariant 5.15.0",
"zvariant_utils 4.2.0",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -5218,7 +5554,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
@@ -5229,8 +5565,8 @@ checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
dependencies = [
"serde",
"winnow",
"zbus_names",
"zvariant",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
@@ -5288,6 +5624,20 @@ name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
@@ -5375,6 +5725,19 @@ dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.15.0"
@@ -5386,8 +5749,21 @@ dependencies = [
"serde",
"winnow",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.15.0",
"zvariant_utils 4.2.0",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -5400,7 +5776,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"zvariant_utils",
"zvariant_utils 4.2.0",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
+2 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,6 +36,7 @@ syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
sha2 = "0.10"
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
semver = "1"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
+22
View File
@@ -66,6 +66,13 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
## Interactive modelling
- New Body extrusion and face-offset previews show completed intermediate geometry while a
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
follow the current frame's input.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
@@ -96,6 +103,12 @@ retain their saved two-finger scroll preference; camera motion is available thro
## Render foundation
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
gamut clipping only when explicitly requested. It is not a renderer/resource
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
@@ -209,6 +222,13 @@ retain their saved two-finger scroll preference; camera motion is available thro
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, …) over the
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
the agent's whole process group ends with the panel or the app. Docks left or right,
minimises to a strip. See [agent chat](docs/agent-chat.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
@@ -286,6 +306,8 @@ retain their saved two-finger scroll preference; camera motion is available thro
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
curved chamfer chains with straight planar ends can terminate across two existing planar
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
+1000 -259
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -29,5 +29,5 @@ 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",
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
] }
+13
View File
@@ -23,6 +23,15 @@
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
<meta name="theme-color" content="#343840">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
@@ -32,6 +41,10 @@
if ("serviceWorker" in navigator) {
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
}
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
addEventListener("appinstalled", () => { window.solidcraftInstall = null; });
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "SolidCraft",
"short_name": "SolidCraft",
"description": "Open-source parametric CAD built in Rust.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#e8ebf0",
"theme_color": "#343840",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" },
{ "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" }
]
}
+67
View File
@@ -0,0 +1,67 @@
//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a
//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event
//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for
//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it
//! gets its steps, unless the app is already running installed.
use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps};
use wasm_bindgen::{JsCast as _, JsValue};
/// Where index.html keeps the browser's offer.
const OFFER: &str = "solidcraftInstall";
/// The browser's offer, kept by index.html (None: none, or it was used).
fn offer() -> Option<js_sys::Object> {
let window = web_sys::window()?;
js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok()
}
/// Show the browser's install prompt (from the File menu click, so the page may).
fn prompt() {
let Some(offer) = offer() else { return };
if let Some(window) = web_sys::window() {
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
}
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
.and_then(|f| f.call0(&offer))
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
match shown {
// Accepted or dismissed, the browser handles it; a refusal is only logged.
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
log::warn!("install prompt: {e:?}");
}
}),
Err(e) => log::warn!("install prompt: {e:?}"),
}
}
/// The page runs as the installed app (its own window, or from the Home Screen on iOS).
fn running_installed(window: &web_sys::Window) -> bool {
let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches());
let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
standalone || home_screen
}
/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser
/// makes or withdraws its offer (even with nothing else happening).
pub fn service(ctx: &egui::Context) -> InstallApp {
// Safari's steps, decided once: the browser and the way the page runs don't change.
let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| {
let nav = w.navigator();
let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default();
safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points())
});
if let Some(window) = web_sys::window() {
for event in ["beforeinstallprompt", "appinstalled"] {
let ctx = ctx.clone();
let redraw = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref());
// The listener lives as long as the page.
redraw.forget();
}
}
let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) };
InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) }
}
+29 -3
View File
@@ -2,12 +2,14 @@
//!
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
//! sample part.
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
//! `?webgpu`), `?sample` opens the sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(target_arch = "wasm32")]
mod web {
@@ -398,6 +400,29 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
/// has no query, so the choice has to outlive the page it was made on.
const WEBGL_KEY: &str = "solidcraft.webgl";
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
fn webgl() -> bool {
let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
let q = query();
if q.contains("webgl") {
if let Some(s) = &storage {
let _ = s.set_item(WEBGL_KEY, "1");
}
return true;
}
if q.contains("webgpu") {
if let Some(s) = &storage {
let _ = s.remove_item(WEBGL_KEY);
}
return false;
}
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
@@ -422,7 +447,7 @@ mod web {
return;
};
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
if query().contains("webgl")
if webgl()
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -447,6 +472,7 @@ mod web {
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
})),
install_app: Some(crate::install::service(&cc.egui_ctx)),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
+10 -2
View File
@@ -8,8 +8,8 @@ publish = false
default-run = "solidcraft"
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither
# has a safe API. Keep the rest in sync with [workspace.lints].
# (macOS file-open events), src/native_crash.rs (Windows exception filter) and src/native_frame.rs
# (Windows resize border) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
[lints.rust]
unsafe_code = "deny"
@@ -36,13 +36,19 @@ getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
# API keys of the agent chat's API agents (docs/agent-chat.md) live in the system keychain:
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit).
raw-window-handle = "0.6"
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
@@ -53,6 +59,8 @@ windows-sys = { version = "0.61", features = [
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Threading",
"Win32_UI_HiDpi",
"Win32_UI_WindowsAndMessaging",
] }
[target.'cfg(target_os = "macos")'.dependencies]
+40 -14
View File
@@ -18,8 +18,9 @@ const MAX_CONNECTIONS: usize = 16;
const MAX_BAD_LINES: usize = 8;
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token file this launch wrote, removed when the app ends.
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed
/// when the app ends.
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
@@ -30,30 +31,55 @@ fn new_token() -> Option<String> {
/// Remove this launch's token file (at exit).
pub fn remove_token_file() {
if let Some(p) = TOKEN_FILE.get() {
let _ = std::fs::remove_file(p);
if let Ok(files) = TOKEN_FILES.lock() {
for p in files.iter() {
let _ = std::fs::remove_file(p);
}
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
let Some(token) = control_auth::env_token().or_else(new_token) else {
eprintln!("solidcraft: control server not started: no random token available");
return rx;
match control_auth::env_token().or_else(new_token) {
Some(token) => {
listen(port, token, tx, ctx, false);
}
None => eprintln!("solidcraft: control server not started: no random token available"),
}
rx
}
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
/// port with a fresh token of its own (never `SOLIDCRAFT_CONTROL_TOKEN`), written to the usual
/// token file, where the MCP server finds it, so the token never passes through the agent. Its
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
let Some(token) = new_token() else {
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
return None;
};
listen(0, token, tx, ctx, true)
}
/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on.
/// `patient`: every request may wait as long as a batch (a person decides on it).
fn listen(port: u16, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
return None;
}
};
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
let _ = TOKEN_FILE.set(path);
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push(path);
}
}
Err(e) => eprintln!(
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
@@ -78,12 +104,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let ctx = ctx.clone();
let token = token.clone();
std::thread::spawn(move || {
serve(stream, &token, tx, ctx);
serve(stream, &token, tx, ctx, patient);
OPEN.fetch_sub(1, Ordering::Relaxed);
});
}
});
rx
Some(port)
}
/// One line without its newline, or `None` at the end of the stream. A client that sends
@@ -134,7 +160,7 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -168,7 +194,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
let wait = Duration::from_secs(if patient || method == "engine.batch" { 3600 } else { 60 });
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
@@ -223,7 +249,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default(), false));
}
});
let session = |lines: &[String]| {
+86
View File
@@ -21,6 +21,8 @@ mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
@@ -84,6 +86,8 @@ impl eframe::App for App {
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
// The agent chat's agent and everything it started end with the app.
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
// An update asked to restart into the new version or to run its installer.
@@ -155,6 +159,10 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
install_app: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
secrets: Some(std::sync::Arc::new(Keychain)),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
@@ -211,6 +219,71 @@ fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<
Ok(())
}
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints, docs/agent-chat.md): to the
/// URL the user configured, which may be plain HTTP on this computer (Ollama, LM Studio). No
/// redirects (a key must not follow one elsewhere), at most 16 MB back.
fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Result<(u16, Vec<u8>), String> {
use std::time::Duration;
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls)
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900))))
.timeout_connect(Some(Duration::from_secs(10)))
.user_agent("SolidCraft")
.max_redirects(0)
.build()
.into();
let mut response = if r.method == "POST" {
let mut req = agent.post(&r.url);
for (k, v) in &r.headers {
req = req.header(k, v);
}
req.send(r.body.as_deref().unwrap_or_default())
} else {
let mut req = agent.get(&r.url);
for (k, v) in &r.headers {
req = req.header(k, v);
}
req.call()
}
.map_err(|e| e.to_string())?;
let status = response.status().as_u16();
let body = response.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| e.to_string())?;
Ok((status, body))
}
/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent).
struct Keychain;
impl Keychain {
fn entry(id: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new("SolidCraft agent chat", id).map_err(|e| e.to_string())
}
}
impl solidcraft_ui_egui::agent_chat::backend::Secrets for Keychain {
fn get(&self, id: &str) -> Result<Option<String>, String> {
match Self::entry(id)?.get_password() {
Ok(p) => Ok(Some(p)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
fn set(&self, id: &str, secret: &str) -> Result<(), String> {
Self::entry(id)?.set_password(secret).map_err(|e| e.to_string())
}
fn delete(&self, id: &str) -> Result<(), String> {
match Self::entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
}
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
@@ -465,6 +538,15 @@ fn main() -> eframe::Result {
let mut app = SolveApp::new(Session::default(), services(cc));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
// Windows: the OS hit test offers the resize border, at the window's DPI, from the
// first message (#1381).
#[cfg(target_os = "windows")]
{
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
if CUSTOM_TITLEBAR && !app.native_resize_border {
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
@@ -490,6 +572,10 @@ fn main() -> eframe::Result {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
// on a free port the first time the chat connects (docs/agent-chat.md).
let ctx = cc.egui_ctx.clone();
app.agent_chat.control_starter = Some(Box::new(move |tx| control_server::listen_agent(tx, ctx.clone())));
if sample {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
+175
View File
@@ -0,0 +1,175 @@
//! Windows: the resize border of the window without OS decorations (#1381).
//!
//! The window keeps its native thick frame (`WS_THICKFRAME`, so Aero Snap, Win+arrows and the
//! OS's own move and size loops work) but winit draws no frame: its `WM_NCCALCSIZE` makes the
//! whole window client area, and every point then hit-tests as `HTCLIENT`. This module puts the
//! resize border back by answering `WM_NCHITTEST` itself: within Windows' resize-border width
//! for the window's current DPI from each edge (`solidcraft_ui_egui::titlebar::resize_hit`;
//! 8 px at 100 %, corners win), it returns `HTLEFT`, `HTTOPRIGHT`, … So from the first message
//! the OS shows its resize cursors and runs its own resize, whatever egui has laid out, whatever
//! the scale; nothing waits for a first frame or a first size change.
//!
//! It also answers `WM_GETDPISCALEDSIZE` with the size the window will really have at the new
//! DPI (the same logical size, as winit computes it), so the rect Windows suggests while the
//! title bar is dragged onto a monitor with another scale is the rect winit then applies: the
//! window rescales once at the crossing and stays under the pointer, instead of the OS's move
//! loop and winit disagreeing about a frame the window doesn't have.
//!
//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this
//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others).
//! The procedure is installed once, on the thread that owns the window, for the main window only,
//! and calls the previous one (winit's) for everything it doesn't answer.
#![allow(unsafe_code)]
use std::sync::atomic::{AtomicIsize, Ordering};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use solidcraft_ui_egui::titlebar;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE,
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
};
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
/// Answer the main window's hit test and DPI-size questions from now on. Call it once, on the
/// thread that created the window (eframe's app creator). `false` if it isn't a Win32 window.
pub fn install(window: &impl HasWindowHandle) -> bool {
let Ok(handle) = window.window_handle() else { return false };
let RawWindowHandle::Win32(w) = handle.as_raw() else { return false };
let hwnd = w.hwnd.get() as HWND;
if PREVIOUS.load(Ordering::SeqCst) != 0 {
return false;
}
// SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the
// WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns
// the previous procedure, which `frame_proc` calls for every other message.
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize) };
if previous == 0 {
return false;
}
PREVIOUS.store(previous, Ordering::SeqCst);
true
}
/// The procedure ours replaced.
fn previous() -> WNDPROC {
let p = PREVIOUS.load(Ordering::SeqCst);
// SAFETY: a nonzero value is the WNDPROC that SetWindowLongPtrW returned in `install`.
(p != 0).then(|| unsafe { std::mem::transmute::<isize, unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT>(p) })
}
unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let prev = previous();
// SAFETY: the arguments are the message as Windows delivered it to this window.
let call_previous = || unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) };
match msg {
WM_NCHITTEST => {
let default = call_previous();
// Screen coordinates, signed (monitors left of or above the primary one).
let (x, y) = (i32::from(lparam as u16 as i16), i32::from((lparam >> 16) as u16 as i16));
// The border, else what winit and the system say (`HTCLIENT` everywhere else).
border_hit(hwnd, x, y).map_or(default, |hit| hit as LRESULT)
}
WM_GETDPISCALEDSIZE => {
let new_dpi = wparam as u32;
// SAFETY: for this message lParam points to a SIZE the system reads back.
match (resizable(hwnd), client_size(hwnd), unsafe { (lparam as *mut SIZE).as_mut() }) {
(true, Some(size), Some(out)) => {
// SAFETY: a window handle the system passed in.
let old_dpi = unsafe { GetDpiForWindow(hwnd) };
match titlebar::dpi_scaled_size(size, old_dpi, new_dpi) {
Some((cx, cy)) => {
*out = SIZE { cx, cy };
1
}
None => call_previous(),
}
}
_ => call_previous(),
}
}
WM_NCDESTROY => {
// Hand the window back to winit's procedure for its last message.
if prev.is_some() {
// SAFETY: restores the procedure this window had before `install`.
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst)) };
}
call_previous()
}
_ => call_previous(),
}
}
/// The window can be resized by its border now: it has the thick frame (not fullscreen, which
/// drops it) and is neither maximized nor minimized.
fn resizable(hwnd: HWND) -> bool {
// SAFETY: plain queries on a window handle the system passed in.
unsafe {
let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32;
style & WS_THICKFRAME != 0 && IsZoomed(hwnd) == 0 && IsIconic(hwnd) == 0
}
}
/// The client area's size in pixels (the whole window: winit gives it no frame).
fn client_size(hwnd: HWND) -> Option<(i32, i32)> {
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
// SAFETY: `r` is a valid RECT to fill.
(unsafe { GetClientRect(hwnd, &mut r) } != 0).then_some((r.right - r.left, r.bottom - r.top))
}
/// Windows' resize border at the window's DPI: the sizing frame plus its padding, as Windows
/// itself sizes it, or 8 px scaled when the system has no answer.
fn border_px(hwnd: HWND) -> f32 {
// SAFETY: plain queries.
let (dpi, metric) = unsafe {
let dpi = GetDpiForWindow(hwnd);
(dpi, GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi))
};
let scale = if dpi > 0 { dpi as f32 / 96.0 } else { 1.0 };
if metric > 0 { metric as f32 } else { titlebar::resize_border_px(scale) }
}
/// The `HT*` resize code at screen point (`x`, `y`), if it is on the border.
fn border_hit(hwnd: HWND, x: i32, y: i32) -> Option<u32> {
if !resizable(hwnd) {
return None;
}
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
// SAFETY: `r` is a valid RECT to fill.
if unsafe { GetWindowRect(hwnd, &mut r) } == 0 {
return None;
}
let w = egui::Rect::from_min_max(egui::pos2(r.left as f32, r.top as f32), egui::pos2(r.right as f32, r.bottom as f32));
titlebar::resize_hit(w, egui::pos2(x as f32, y as f32), border_px(hwnd)).map(hit_code)
}
fn hit_code(d: egui::ResizeDirection) -> u32 {
use egui::ResizeDirection::*;
match d {
North => HTTOP,
South => HTBOTTOM,
West => HTLEFT,
East => HTRIGHT,
NorthWest => HTTOPLEFT,
NorthEast => HTTOPRIGHT,
SouthWest => HTBOTTOMLEFT,
SouthEast => HTBOTTOMRIGHT,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_direction_has_its_own_hit_code() {
use egui::ResizeDirection::*;
let codes: Vec<u32> = [North, South, West, East, NorthWest, NorthEast, SouthWest, SouthEast].into_iter().map(hit_code).collect();
assert_eq!(codes, vec![HTTOP, HTBOTTOM, HTLEFT, HTRIGHT, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT]);
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
## Code and provenance
+7
View File
@@ -271,6 +271,13 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink);
pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink);
}
// Agent chat: a speech bubble with three dots.
"agent_chat" => {
pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink);
for x in [8.5, 12.0, 15.5] {
pen.dot(x, 10.5, ink);
}
}
// Help: a question mark in a ring.
"help" => {
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
+218 -2
View File
@@ -26,6 +26,31 @@
"dialog.selection_guidance": "Wählen Sie eine Eingabe aus, um sie zu ändern. Klicken Sie auf ausgewählte Objekte, um sie zu entfernen; Strg/Cmd-Klick schaltet sie ebenfalls um. Prüfen Sie die Vorschau und drücken Sie dann OK.",
"document.save_changes": "Änderungen in {name} speichern?",
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
"home.documentation": "Dokumentation",
"home.in_this_browser": "in diesem Browser",
"home.missing": "fehlt",
"home.open": "Öffnen…",
"home.opened_days_ago": "Vor {count} Tag(en) geöffnet",
"home.opened_hours_ago": "Vor {count} Std. geöffnet",
"home.opened_just_now": "Gerade eben geöffnet",
"home.opened_minutes_ago": "Vor {count} Min. geöffnet",
"home.opening": "wird geöffnet…",
"home.recent": "Zuletzt verwendet",
"home.report_issue": "Problem melden",
"home.samples": "Beispiele",
"home.whats_new": "Neuigkeiten",
"sample.bracket.about": "Grundflansch mit Kantenflanschen und einer Umkantung nach einer 2-mm-Blechregel",
"sample.bracket.name": "Blechwinkel",
"sample.enclosure.about": "Ausgehöhlter Block mit Lippe, Schraubenerhöhungen, Auflage und Rastverbindung",
"sample.enclosure.name": "Kunststoffgehäuse",
"sample.flange.about": "Nabe und Flansch mit einem parametergesteuerten radialen Muster",
"sample.flange.name": "Schraubflansch",
"sample.hinge.about": "Zwei Komponenten und ein Drehgelenk, auf 60° angetrieben",
"sample.hinge.name": "Scharnierbaugruppe",
"sample.plate.about": "Parametrische Platte mit Vollrundungen, Bohrungen und einer aufgebohrten Erhöhung",
"sample.plate.name": "Beispielplatte",
"sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter",
"sample.vents.name": "Lüftungsplatte",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
"settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).",
"settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt",
@@ -41,6 +66,147 @@
"ui.active_component": "Aktive Komponente",
"ui.agent_cursor": "Agent-Cursor",
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
"ui.agent_chat": "Agenten-Chat",
"ui.agent_chat_command": "Agentenbefehl",
"ui.agent_chat_desktop_only": "Der Agenten-Chat braucht die Desktop-App.",
"ui.agent_chat_side": "Seite des Agenten-Chats",
"ui.agent_chat_left": "Links",
"ui.agent_chat_right": "Rechts",
"ui.agent_chat_turn_limit": "Änderungen pro Nachricht",
"ui.agent_chat_privacy_note": "SolidCraft sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. SolidCraft sieht und speichert nie einen Schlüssel oder ein Passwort.",
"ui.agent_chat_connect": "Verbinden",
"ui.agent_chat_stop": "Beenden",
"ui.agent_chat_stop_tip": "Den Agenten und alles, was er gestartet hat, beenden",
"ui.agent_chat_close": "Schließen",
"ui.agent_chat_minimize": "Zu einer Leiste minimieren",
"ui.agent_chat_dock_left": "Links andocken",
"ui.agent_chat_dock_right": "Rechts andocken",
"ui.agent_chat_send": "Senden",
"ui.agent_chat_cancel": "Abbrechen",
"ui.agent_chat_refresh": "Aktualisieren",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_custom_hint": "Jedes Programm, das das Agent Client Protocol über stdin und stdout spricht. Es läuft ohne Shell.",
"ui.agent_chat_not_installed": "Nicht installiert",
"ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:",
"ui.agent_chat_detecting": "Suche nach Agenten…",
"ui.agent_chat_signed_in": "angemeldet",
"ui.agent_chat_key_in_env": "Schlüssel gesetzt",
"ui.agent_chat_not_signed_in": "nicht angemeldet",
"ui.agent_chat_state_off": "Nicht verbunden",
"ui.agent_chat_state_starting": "Startet…",
"ui.agent_chat_state_ready": "Bereit",
"ui.agent_chat_state_busy": "Arbeitet…",
"ui.agent_chat_state_auth": "Anmeldung nötig",
"ui.agent_chat_state_exited": "Agent beendet",
"ui.agent_chat_changes_of": "{n}/{limit} Änderungen",
"ui.agent_chat_auth_title": "Beim Agenten anmelden",
"ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; SolidCraft sieht Ihr Passwort oder Ihre Schlüssel nie.",
"ui.agent_chat_auth_none": "Der Agent bietet hier keine Anmeldung an: Melden Sie sich so an, wie es seine Dokumentation beschreibt.",
"ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:",
"ui.agent_chat_auth_agent": "Mit {name} anmelden",
"ui.agent_chat_auth_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie SolidCraft neu.",
"ui.agent_chat_auth_retry": "Ich bin angemeldet: erneut verbinden",
"ui.agent_chat_copy": "Kopieren",
"ui.agent_chat_permission": "Der Agent möchte ausführen: {title}",
"ui.agent_chat_answered": "Antwort: {option}",
"ui.agent_chat_approval": "Darf der Agent das tun?",
"ui.agent_chat_allow": "Erlauben",
"ui.agent_chat_deny": "Ablehnen",
"ui.agent_chat_allowed": "Erlaubt",
"ui.agent_chat_denied": "Abgelehnt",
"ui.agent_chat_changes": "Änderungen: {n}, ein Rückgängig-Schritt",
"ui.agent_chat_undo_changes": "Diese Änderungen rückgängig machen",
"ui.agent_chat_undone": "(rückgängig gemacht)",
"ui.agent_chat_undo_label": "Agenten-Chat: {message}",
"ui.agent_chat_limit": "Der Agent hat die Grenze von {limit} Änderungen für eine Nachricht erreicht. Senden Sie eine weitere Nachricht, damit er weitermacht.",
"ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}",
"ui.agent_chat_wait_turn": "Der Agent arbeitet noch an der letzten Nachricht: Warten Sie oder klicken Sie auf Abbrechen.",
"ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…",
"ui.agent_chat_attach_view": "Ansicht anhängen",
"ui.agent_chat_attach_view_tip": "Ein Bild der aktuellen Ansicht mit der Nachricht senden",
"ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an",
"ui.agent_chat_view_attached": "Ansicht angehängt",
"ui.agent_chat_no_cli": "solidcraft-cli wurde neben der App nicht gefunden, daher kann der Agent das Design nicht erreichen.",
"ui.agent_chat_no_control": "Der Endpunkt des Agenten-Chats ist nicht gestartet, daher kann der Agent das Design nicht erreichen.",
"ui.agent_chat_exited": "Der Agent wurde beendet.",
"ui.agent_chat_failed_start": "Der Agent konnte nicht starten: {error}",
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
"ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.",
"ui.agent_chat_none_found_short": "Kein Agent gefunden",
"ui.ai_add": "Hinzufügen",
"ui.ai_add_agent": "Agent hinzufügen…",
"ui.ai_add_agent_title": "Agent hinzufügen",
"ui.ai_add_api": "OpenAI-kompatible API",
"ui.ai_add_custom": "Eigener Befehl",
"ui.ai_added": "hinzugefügt",
"ui.ai_add_installed": "Installierter Agent",
"ui.ai_add_installed_note": "Ein eigener Eintrag für einen installierten Agenten, zum Beispiel mit anderen Argumenten. Installierte Agenten erscheinen von selbst.",
"ui.ai_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von SolidCraft folgen den Berechtigungen unter Einstellungen > KI.",
"ui.ai_agents": "Agenten",
"ui.ai_always_allow": "Immer erlauben",
"ui.ai_always_allowed": "Immer erlaubt",
"ui.ai_always_allowed_none": "Noch nichts ist immer erlaubt. Antworten „Immer erlauben“ im Chat erscheinen hier.",
"ui.ai_always_allow_tip": "{tool} für diesen Agenten ab jetzt ohne Nachfrage erlauben",
"ui.ai_auto_allowed": "Automatisch erlaubt: {title} ({why})",
"ui.ai_auto_mode": "Ihre Berechtigungseinstellung",
"ui.ai_auto_ours": "SolidCraft prüft seine eigenen Werkzeuge selbst",
"ui.ai_auto_remembered": "Sie haben „Immer erlauben“ gewählt",
"ui.ai_bad_url": "Geben Sie eine URL ein, die mit http:// oder https:// beginnt",
"ui.ai_base_url": "Basis-URL",
"ui.ai_button": "KI",
"ui.ai_copy_suffix": "Kopie",
"ui.ai_custom_url": "Eigene URL",
"ui.ai_default": "Standard",
"ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später",
"ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI",
"ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen",
"ui.ai_key": "API-Schlüssel",
"ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}",
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
"ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.",
"ui.ai_mode_all": "Alles erlauben",
"ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.",
"ui.ai_mode_all_warning": "Der Agent kann Ihr Design ohne Nachfrage speichern, überschreiben, exportieren und ersetzen. Nutzen Sie das nur mit einem Agenten, dem Sie vertrauen, und mit gespeicherten Designs.",
"ui.ai_mode_ask": "Jedes Mal fragen",
"ui.ai_mode_ask_note": "Jede Änderung am Design und jede Dateiaktion wartet auf Ihr Erlauben.",
"ui.ai_mode_edits": "Modelländerungen automatisch erlauben",
"ui.ai_mode_edits_note": "Modellierbefehle laufen ohne Nachfrage; Speichern, Öffnen, Exportieren und andere Datei- oder destruktive Aktionen fragen weiterhin.",
"ui.ai_mode_global": "Wie oben eingestellt",
"ui.ai_model": "Modell",
"ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von SolidCraft. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.",
"ui.ai_name": "Name",
"ui.ai_need_command": "Geben Sie den Befehl ein, der den Agenten startet",
"ui.ai_need_model": "Geben Sie den Modellnamen ein",
"ui.ai_no_answer": "Der Agent hat nicht rechtzeitig geantwortet",
"ui.ai_no_http": "Dieser Build kann keine API-Endpunkte erreichen",
"ui.ai_no_keychain": "Der Schlüsselbund des Systems ist nicht verfügbar, daher kann kein Schlüssel gespeichert werden. Ein lokaler Server ohne Schlüssel funktioniert trotzdem.",
"ui.ai_panel": "Panel",
"ui.ai_permissions": "Berechtigungen",
"ui.ai_preset": "Vorlage",
"ui.ai_remove": "Entfernen",
"ui.ai_request_limit": "nach 24 Anfragen für eine Nachricht angehalten; senden Sie eine weitere Nachricht, um fortzufahren",
"ui.ai_reset": "Zurücksetzen",
"ui.ai_settings": "KI-Einstellungen…",
"ui.ai_show_features": "KI-Funktionen anzeigen",
"ui.ai_show_features_note": "Der Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.",
"ui.ai_switch_tip": "Den Agenten für dieses Gespräch wechseln",
"ui.ai_test": "Testen",
"ui.ai_test_acp_ok": "Antwortet: {agent}",
"ui.ai_test_acp_tip": "Startet den Agenten kurz und bittet um eine Sitzung; beendet ihn wieder",
"ui.ai_test_api_tip": "Fragt den Endpunkt nach seinen Modellen",
"ui.ai_testing": "Teste…",
"ui.ai_test_no_model": "Erreicht: {count} Modelle, aber nicht {model}",
"ui.ai_test_ok": "Erreicht: {count} Modelle",
"ui.ai_toolbar_button": "Schaltfläche in der Werkzeugleiste anzeigen",
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
"ui.ai_tool_done": "fertig",
"ui.ai_tool_failed": "fehlgeschlagen",
"ui.ai_tool_running": "läuft",
"ui.ai_tool_unfold": "Klicken, um das Ergebnis zu zeigen",
"ui.ai_tool_waiting": "wartet",
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
"AI": "KI",
"ui.all": "Alle",
"ui.analysis": "Analyse",
"ui.angle": "Winkel",
@@ -85,6 +251,28 @@
"ui.clear_recent": "Zuletzt verwendet leeren",
"ui.clear_the_selection": "Auswahl aufheben",
"ui.close": "Schließen",
"ui.material_review.title": "Physikalische Materialdaten prüfen",
"ui.material_review.scope": "Nur Kandidat: nicht zugewiesen oder gespeichert. Numerische Prüfung ist keine technische Zertifizierung.",
"ui.material_review.input": "Materialkarte mit Schema 1 einfügen (JSON)",
"ui.material_review.review": "Karte prüfen",
"ui.material_review.limit": "Die Karte überschreitet die Eingabegrenze von 256 KiB.",
"ui.material_review.unknown": "Unbekannt",
"ui.material_review.properties": "Eigenschaften",
"ui.material_review.conditions": "Angegebene Bedingungen",
"ui.material_review.source": "Quelle",
"ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.",
"ui.material_review.derived": "Abgeleitete isotrope Moduln",
"ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.",
"ui.material_review.density": "Dichte",
"ui.material_review.elasticity": "Elastizität",
"ui.material_review.tensile_strength": "Zugfestigkeit",
"ui.material_review.compressive_strength": "Druckfestigkeit",
"ui.material_review.yield_strength": "Streckgrenze",
"ui.material_review.shear_strength": "Scherfestigkeit",
"ui.material_review.thermal_conductivity": "Wärmeleitfähigkeit",
"ui.material_review.thermal_expansion": "Wärmeausdehnung",
"ui.material_review.specific_heat": "Spezifische Wärmekapazität",
"ui.material_review.moisture_unit": "kg Wasser / kg Trockenmaterial",
"ui.coil": "Schraube",
"ui.coincident": "Koinzident",
"ui.collapse": "Einklappen",
@@ -483,6 +671,7 @@
"cmd.component_capture_position": "Position erfassen",
"cmd.component_create": "Neue Komponente",
"cmd.component_delete": "Komponente löschen",
"cmd.component_unlock": "Inhalt entsperren",
"cmd.component_from_bodies": "Komponenten aus Körpern erstellen",
"cmd.component_insert_design": "Konstruktion einfügen",
"cmd.component_links": "Verknüpfungen auflisten",
@@ -554,6 +743,7 @@
"cmd.file_slicers": "Installierte Slicer",
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_compare": "Körper vergleichen",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
"cmd.inspect_curvature_map": "Krümmungskartenanalyse",
"cmd.inspect_draft": "Auswinkelanalyse",
@@ -650,6 +840,8 @@
"cmd.sketch_constraint_fix": "Fixieren/Lösen",
"cmd.sketch_constraint_midpoint": "Mittelpunkt",
"cmd.sketch_construction": "Normal/Konstruktion",
"cmd.sketch_copy": "Skizzenelemente kopieren",
"cmd.sketch_cut": "Skizzenelemente ausschneiden",
"cmd.sketch_delete": "Skizzenentitäten löschen",
"cmd.sketch_dimension_text": "Maßtext verschieben",
"cmd.sketch_drag": "Skizzengeometrie ziehen",
@@ -674,6 +866,7 @@
"cmd.sketch_move_point": "Skizzenpunkt ziehen",
"cmd.sketch_offset_break": "Versatzverknüpfung trennen",
"cmd.sketch_options": "Skizzenpalettenoptionen",
"cmd.sketch_paste": "Skizzenelemente einfügen",
"cmd.sketch_point3d": "3D-Punkt",
"cmd.sketch_polygon_circumscribed": "Umgeschriebenes Polygon",
"cmd.sketch_polygon_edge": "Polygon über eine Kante",
@@ -803,6 +996,18 @@
"Profiles/Curves": "Profile/Kurven",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} ist verfügbar",
"install.menu": "App installieren…",
"install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline",
"install.title": "SolidCraft installieren",
"install.steps_intro": "Safari installiert Web-Apps über seine eigenen Menüs:",
"install.home_screen_1": "Tippen Sie auf „Teilen“ (im Menü …, falls es nicht in der Symbolleiste ist).",
"install.home_screen_2": "Wählen Sie „Zum Home-Bildschirm“.",
"install.home_screen_3": "Tippen Sie auf „Hinzufügen“.",
"install.home_screen_after": "SolidCraft öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.",
"install.dock_1": "Wählen Sie Ablage › Zum Dock hinzufügen.",
"install.dock_2": "Klicken Sie auf „Hinzufügen“.",
"install.dock_after": "SolidCraft öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.",
"install.done": "Fertig",
"update.title": "Update verfügbar",
"update.available": "SolidCraft {version} ist verfügbar",
"update.you_have": "Installiert ist {version}.",
@@ -853,7 +1058,7 @@
"render.reload_settings": "Gespeicherte Einstellungen laden",
"render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.",
"render.use_preview": "Integrierte Vorschau verwenden",
"render.navigation_help": "Ziehen zum Drehen; mit mittlerer Taste ziehen zum Verschieben; scrollen zum Zoomen. Setup speichern übernimmt die aktuelle Kamera.",
"render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.",
"render.return_design": "Zurück zu Design",
"render.image_title": "Bild rendern",
"render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz",
@@ -872,6 +1077,8 @@
"render.image_failed": "Rendering fehlgeschlagen",
"render.image_unknown": "Renderstatus nicht verfügbar",
"render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.",
"render.image_too_large": "Über dem Bildlimit: 4096 Pixel pro Seite und 4.194.304 Pixel insgesamt.",
"render.image_use_largest": "Größte Größe mit diesem Seitenverhältnis verwenden:",
"render.image_cancel": "Rendering abbrechen",
"render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.",
"render.image_start": "Rendering starten",
@@ -906,5 +1113,14 @@
"Type": "Typ",
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist."
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.",
"Circumference": "Umfang",
"Arc length": "Bogenlänge",
"Slot width": "Langlochbreite",
"Centre to centre": "Mitte zu Mitte",
"Overall length": "Gesamtlänge",
"Min": "Min",
"Max": "Max",
"Length": "Länge",
"Area": "Fläche"
}
+216 -2
View File
@@ -36,6 +36,7 @@
"cmd.component_capture_position": "Capture Position",
"cmd.component_create": "New Component",
"cmd.component_delete": "Delete Component",
"cmd.component_unlock": "Unlock Contents",
"cmd.component_from_bodies": "Create Components from Bodies",
"cmd.component_insert_design": "Insert Design",
"cmd.component_links": "List Links",
@@ -107,6 +108,7 @@
"cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
"cmd.inspect_curvature_map": "Curvature Map Analysis",
"cmd.inspect_draft": "Draft Analysis",
@@ -201,6 +203,8 @@
"cmd.sketch_constraint_fix": "Fix/UnFix",
"cmd.sketch_constraint_midpoint": "MidPoint",
"cmd.sketch_construction": "Normal/Construction",
"cmd.sketch_copy": "Copy Sketch Entities",
"cmd.sketch_cut": "Cut Sketch Entities",
"cmd.sketch_delete": "Delete Sketch Entities",
"cmd.sketch_dimension_text": "Move Dimension Text",
"cmd.sketch_drag": "Drag Sketch Geometry",
@@ -225,6 +229,7 @@
"cmd.sketch_move_point": "Drag Sketch Point",
"cmd.sketch_offset_break": "Break Offset Association",
"cmd.sketch_options": "Sketch Palette Options",
"cmd.sketch_paste": "Paste Sketch Entities",
"cmd.sketch_point3d": "3D Point",
"cmd.sketch_polygon_circumscribed": "Circumscribed Polygon",
"cmd.sketch_polygon_edge": "Edge Polygon",
@@ -304,6 +309,31 @@
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
"document.save_changes": "Save changes to {name}?",
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
"home.documentation": "Documentation",
"home.in_this_browser": "in this browser",
"home.missing": "missing",
"home.open": "Open…",
"home.opened_days_ago": "Opened {count} days ago",
"home.opened_hours_ago": "Opened {count} h ago",
"home.opened_just_now": "Opened just now",
"home.opened_minutes_ago": "Opened {count} min ago",
"home.opening": "opening…",
"home.recent": "Recent",
"home.report_issue": "Report an Issue",
"home.samples": "Samples",
"home.whats_new": "What's new",
"sample.bracket.about": "Base flange with edge flanges and a hem on a 2 mm rule",
"sample.bracket.name": "Sheet Metal Bracket",
"sample.enclosure.about": "Shelled box with a lip, screw bosses, a rest and a snap fit",
"sample.enclosure.name": "Plastic Enclosure",
"sample.flange.about": "Hub and flange with a circular pattern driven by a parameter",
"sample.flange.name": "Bolted Flange",
"sample.hinge.about": "Two components and a revolute joint, driven to 60°",
"sample.hinge.name": "Hinge Assembly",
"sample.plate.about": "Parametric plate with fillets, holes and a bored boss",
"sample.plate.name": "Sample Plate",
"sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters",
"sample.vents.name": "Vent Plate",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
"settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).",
"settings.msaa_unsupported": "Not supported by this graphics device",
@@ -321,6 +351,147 @@
"ui.active_component": "Active component",
"ui.agent_cursor": "Agent cursor",
"ui.agent_cursor_speed": "Agent cursor speed",
"ui.agent_chat": "Agent chat",
"ui.agent_chat_command": "Agent command",
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
"ui.agent_chat_side": "Agent chat side",
"ui.agent_chat_left": "Left",
"ui.agent_chat_right": "Right",
"ui.agent_chat_turn_limit": "Changes per message",
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
"ui.agent_chat_connect": "Connect",
"ui.agent_chat_stop": "Stop",
"ui.agent_chat_stop_tip": "Stop the agent and everything it started",
"ui.agent_chat_close": "Close",
"ui.agent_chat_minimize": "Minimise to a strip",
"ui.agent_chat_dock_left": "Dock on the left",
"ui.agent_chat_dock_right": "Dock on the right",
"ui.agent_chat_send": "Send",
"ui.agent_chat_cancel": "Cancel",
"ui.agent_chat_refresh": "Refresh",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_custom_hint": "Any program that speaks the Agent Client Protocol on stdin and stdout. No shell runs it.",
"ui.agent_chat_not_installed": "Not installed",
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
"ui.agent_chat_detecting": "Looking for agents…",
"ui.agent_chat_signed_in": "signed in",
"ui.agent_chat_key_in_env": "key set",
"ui.agent_chat_not_signed_in": "not signed in",
"ui.agent_chat_state_off": "Not connected",
"ui.agent_chat_state_starting": "Starting…",
"ui.agent_chat_state_ready": "Ready",
"ui.agent_chat_state_busy": "Working…",
"ui.agent_chat_state_auth": "Sign-in needed",
"ui.agent_chat_state_exited": "Agent stopped",
"ui.agent_chat_changes_of": "{n}/{limit} changes",
"ui.agent_chat_auth_title": "Sign in to the agent",
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
"ui.agent_chat_auth_none": "The agent offers no sign-in method here: sign in the way its own documentation describes.",
"ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:",
"ui.agent_chat_auth_agent": "Sign in with {name}",
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
"ui.agent_chat_auth_retry": "I've signed in: connect again",
"ui.agent_chat_copy": "Copy",
"ui.agent_chat_permission": "The agent asks to run: {title}",
"ui.agent_chat_answered": "Answer: {option}",
"ui.agent_chat_approval": "Allow the agent to do this?",
"ui.agent_chat_allow": "Allow",
"ui.agent_chat_deny": "Deny",
"ui.agent_chat_allowed": "Allowed",
"ui.agent_chat_denied": "Denied",
"ui.agent_chat_changes": "Changes: {n}, one undo step",
"ui.agent_chat_undo_changes": "Undo these changes",
"ui.agent_chat_undone": "(undone)",
"ui.agent_chat_undo_label": "Agent chat: {message}",
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
"ui.agent_chat_refused": "Not allowed for the agent: {reason}",
"ui.agent_chat_wait_turn": "The agent is still working on the last message: wait for it or press Cancel.",
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
"ui.agent_chat_attach_view": "Attach view",
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
"ui.agent_chat_no_images": "This agent doesn't take pictures",
"ui.agent_chat_view_attached": "View attached",
"ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.",
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
"ui.agent_chat_exited": "The agent stopped.",
"ui.agent_chat_failed_start": "The agent could not start: {error}",
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
"ui.agent_chat_none_found_short": "No agent found",
"ui.ai_add": "Add",
"ui.ai_add_agent": "Add agent…",
"ui.ai_add_agent_title": "Add agent",
"ui.ai_add_api": "OpenAI-compatible API",
"ui.ai_add_custom": "Custom command",
"ui.ai_added": "added",
"ui.ai_add_installed": "Installed agent",
"ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.",
"ui.ai_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. SolidCraft's tools follow the permissions in Preferences > AI.",
"ui.ai_agents": "Agents",
"ui.ai_always_allow": "Always allow",
"ui.ai_always_allowed": "Always allowed",
"ui.ai_always_allowed_none": "Nothing is always allowed yet. \"Always allow\" answers you give in the chat are listed here.",
"ui.ai_always_allow_tip": "Allow {tool} for this agent from now on, without asking",
"ui.ai_auto_allowed": "Auto-allowed: {title} ({why})",
"ui.ai_auto_mode": "your permission setting",
"ui.ai_auto_ours": "SolidCraft checks its own tools itself",
"ui.ai_auto_remembered": "you chose Always allow",
"ui.ai_bad_url": "Enter a URL that starts with http:// or https://",
"ui.ai_base_url": "Base URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "copy",
"ui.ai_custom_url": "Custom URL",
"ui.ai_default": "Default",
"ui.ai_http_busy": "the endpoint is busy or over its limit; try again later",
"ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI",
"ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name",
"ui.ai_key": "API key",
"ui.ai_keychain_error": "The system keychain refused: {error}",
"ui.ai_key_hint": "optional; kept in the system keychain",
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
"ui.ai_mode_all": "Allow everything",
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
"ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.",
"ui.ai_mode_ask": "Ask every time",
"ui.ai_mode_ask_note": "Every change to the design, and every file action, waits for your Allow.",
"ui.ai_mode_edits": "Auto-allow model edits",
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
"ui.ai_mode_global": "As set above",
"ui.ai_model": "Model",
"ui.ai_modes_scope": "These settings cover SolidCraft's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.",
"ui.ai_name": "Name",
"ui.ai_need_command": "Enter the command that starts the agent",
"ui.ai_need_model": "Enter the model name",
"ui.ai_no_answer": "The agent did not answer in time",
"ui.ai_no_http": "This build can't reach API endpoints",
"ui.ai_no_keychain": "The system keychain isn't available, so a key can't be stored. A local server without a key still works.",
"ui.ai_panel": "Panel",
"ui.ai_permissions": "Permissions",
"ui.ai_preset": "Preset",
"ui.ai_remove": "Remove",
"ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on",
"ui.ai_reset": "Reset",
"ui.ai_settings": "AI settings…",
"ui.ai_show_features": "Show AI features",
"ui.ai_show_features_note": "The agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.",
"ui.ai_switch_tip": "Change the agent for this conversation",
"ui.ai_test": "Test",
"ui.ai_test_acp_ok": "Answers: {agent}",
"ui.ai_test_acp_tip": "Starts the agent briefly and asks it for a session; ends it again",
"ui.ai_test_api_tip": "Asks the endpoint for its models",
"ui.ai_testing": "Testing…",
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
"ui.ai_test_ok": "Reached: {count} models",
"ui.ai_toolbar_button": "Show button in toolbar",
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
"ui.ai_tool_done": "done",
"ui.ai_tool_failed": "failed",
"ui.ai_tool_running": "running",
"ui.ai_tool_unfold": "Click to show what it returned",
"ui.ai_tool_waiting": "waiting",
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
"AI": "AI",
"ui.all": "All",
"ui.analysis": "Analysis",
"ui.angle": "Angle",
@@ -368,6 +539,28 @@
"ui.click_selects_whole_bodies": "Click selects whole bodies",
"ui.click_then_press_key": "Click, then press the new key",
"ui.close": "Close",
"ui.material_review.title": "Review Physical Material Data",
"ui.material_review.scope": "Candidate only: not assigned or saved. Numerical validation is not engineering certification.",
"ui.material_review.input": "Paste a schema-1 material card (JSON)",
"ui.material_review.review": "Review card",
"ui.material_review.limit": "The card exceeds the 256 KiB input limit.",
"ui.material_review.unknown": "Unknown",
"ui.material_review.properties": "Properties",
"ui.material_review.conditions": "Stated conditions",
"ui.material_review.source": "Source",
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
"ui.material_review.derived": "Derived isotropic moduli",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
"ui.material_review.density": "Density",
"ui.material_review.elasticity": "Elasticity",
"ui.material_review.tensile_strength": "Tensile strength",
"ui.material_review.compressive_strength": "Compressive strength",
"ui.material_review.yield_strength": "Yield strength",
"ui.material_review.shear_strength": "Shear strength",
"ui.material_review.thermal_conductivity": "Thermal conductivity",
"ui.material_review.thermal_expansion": "Thermal expansion",
"ui.material_review.specific_heat": "Specific heat",
"ui.material_review.moisture_unit": "kg water / kg dry material",
"ui.coil": "Coil",
"ui.coincident": "Coincident",
"ui.collapse": "Collapse",
@@ -1061,6 +1254,18 @@
"cmd.material_properties_directional": "Directional Elastic Modulus",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} is available",
"install.menu": "Install app…",
"install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline",
"install.title": "Install SolidCraft",
"install.steps_intro": "Safari installs web apps from its own menus:",
"install.home_screen_1": "Tap Share (in the … menu if it isn't on the toolbar).",
"install.home_screen_2": "Choose Add to Home Screen.",
"install.home_screen_3": "Tap Add.",
"install.home_screen_after": "SolidCraft then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.",
"install.dock_1": "Choose File › Add to Dock.",
"install.dock_2": "Click Add.",
"install.dock_after": "SolidCraft then opens in its own window from the Dock, and works offline.",
"install.done": "Done",
"update.title": "Update available",
"update.available": "SolidCraft {version} is available",
"update.you_have": "You have {version}.",
@@ -1111,7 +1316,7 @@
"render.reload_settings": "Reload Saved Settings",
"render.unavailable_preview": "This setup requests an unavailable preview profile.",
"render.use_preview": "Use Integrated Preview",
"render.navigation_help": "Drag to orbit; middle-drag to pan; scroll to zoom. Save Setup records the current camera.",
"render.navigation_help": "The camera moves as in Design (Preferences › Navigation). Save Setup records the current camera.",
"render.return_design": "Return to Design",
"render.image_title": "Render Image",
"render.image_help": "CPU preview image · PNG · approximate transparency",
@@ -1130,6 +1335,8 @@
"render.image_failed": "Render failed",
"render.image_unknown": "Render status unavailable",
"render.image_reduce": "Try a smaller image or simpler scene.",
"render.image_too_large": "Over the image limit: 4096 pixels per side and 4,194,304 pixels in all.",
"render.image_use_largest": "Use the largest size at this aspect:",
"render.image_cancel": "Cancel Render",
"render.image_stale": "This image uses the scene captured before subsequent changes.",
"render.image_start": "Start Render",
@@ -1163,5 +1370,12 @@
"Round": "Round",
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is."
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
"Circumference": "Circumference",
"Arc length": "Arc length",
"Slot width": "Slot width",
"Centre to centre": "Centre to centre",
"Overall length": "Overall length",
"Min": "Min",
"Max": "Max"
}
+67 -1
View File
@@ -36,6 +36,7 @@
"cmd.component_capture_position": "Capturer la position",
"cmd.component_create": "Nouveau composant",
"cmd.component_delete": "Supprimer le composant",
"cmd.component_unlock": "Déverrouiller le contenu",
"cmd.component_from_bodies": "Créer des composants à partir des corps",
"cmd.component_insert_design": "Insérer une conception",
"cmd.component_links": "Lister les liens",
@@ -107,6 +108,7 @@
"cmd.file_slicers": "Slicers installés",
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
"cmd.inspect_curvature_map": "Analyse par carte de courbure",
"cmd.inspect_draft": "Analyse de dépouille",
@@ -201,6 +203,8 @@
"cmd.sketch_constraint_fix": "Fixer/Libérer",
"cmd.sketch_constraint_midpoint": "Point milieu",
"cmd.sketch_construction": "Normal/Construction",
"cmd.sketch_copy": "Copier les entités d'esquisse",
"cmd.sketch_cut": "Couper les entités d'esquisse",
"cmd.sketch_delete": "Supprimer des entités d'esquisse",
"cmd.sketch_dimension_text": "Déplacer le texte de cote",
"cmd.sketch_drag": "Faire glisser la géométrie d'esquisse",
@@ -225,6 +229,7 @@
"cmd.sketch_move_point": "Faire glisser un point d'esquisse",
"cmd.sketch_offset_break": "Rompre l'association du décalage",
"cmd.sketch_options": "Options de la palette d'esquisse",
"cmd.sketch_paste": "Coller les entités d'esquisse",
"cmd.sketch_point3d": "Point 3D",
"cmd.sketch_polygon_circumscribed": "Polygone circonscrit",
"cmd.sketch_polygon_edge": "Polygone par côté",
@@ -304,6 +309,31 @@
"dialog.selection_guidance": "Sélectionner une entrée pour la modifier. Cliquer sur un objet sélectionné pour le retirer ; Ctrl/Cmd-clic le bascule aussi. Vérifier l'aperçu, puis appuyer sur OK.",
"document.save_changes": "Enregistrer les modifications de {name} ?",
"document.unsaved_changes": "{name} contient des modifications non enregistrées. Si vous n'enregistrez pas, elles seront perdues.",
"home.documentation": "Documentation",
"home.in_this_browser": "dans ce navigateur",
"home.missing": "introuvable",
"home.open": "Ouvrir…",
"home.opened_days_ago": "Ouvert il y a {count} j",
"home.opened_hours_ago": "Ouvert il y a {count} h",
"home.opened_just_now": "Ouvert à l'instant",
"home.opened_minutes_ago": "Ouvert il y a {count} min",
"home.opening": "ouverture…",
"home.recent": "Récents",
"home.report_issue": "Signaler un problème",
"home.samples": "Exemples",
"home.whats_new": "Nouveautés",
"sample.bracket.about": "Tôle de base avec bords tombés et un ourlet, selon une règle de 2 mm",
"sample.bracket.name": "Équerre en tôle",
"sample.enclosure.about": "Boîte en coque avec lèvre, bossages de vis, appui et clip",
"sample.enclosure.name": "Boîtier plastique",
"sample.flange.about": "Moyeu et bride avec un réseau circulaire piloté par un paramètre",
"sample.flange.name": "Bride boulonnée",
"sample.hinge.about": "Deux composants et une liaison pivot, entraînée à 60°",
"sample.hinge.name": "Assemblage de charnière",
"sample.plate.about": "Plaque paramétrique avec congés, perçages et un bossage alésé",
"sample.plate.name": "Plaque d'exemple",
"sample.vents.about": "Rainures en réseau rectangulaire : lignes, colonnes et pas sont des paramètres",
"sample.vents.name": "Plaque d'aération",
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft",
"settings.msaa_reset": "{setting} : {requested}× n'est pas pris en charge par ce périphérique graphique ; la valeur est désormais {reset}× (pris en charge : {supported}).",
"settings.msaa_unsupported": "Non pris en charge par ce périphérique graphique",
@@ -368,6 +398,28 @@
"ui.click_selects_whole_bodies": "Un clic sélectionne des corps entiers",
"ui.click_then_press_key": "Cliquer, puis appuyer sur la nouvelle touche",
"ui.close": "Fermer",
"ui.material_review.title": "Examiner les données physiques du matériau",
"ui.material_review.scope": "Candidat uniquement : ni attribué ni enregistré. La validation numérique ne constitue pas une certification technique.",
"ui.material_review.input": "Coller une fiche matériau de schéma 1 (JSON)",
"ui.material_review.review": "Examiner la fiche",
"ui.material_review.limit": "La fiche dépasse la limite de saisie de 256 Kio.",
"ui.material_review.unknown": "Inconnu",
"ui.material_review.properties": "Propriétés",
"ui.material_review.conditions": "Conditions déclarées",
"ui.material_review.source": "Source",
"ui.material_review.axes": "Axes locaux au matériau ; aucune orientation du corps attribuée.",
"ui.material_review.derived": "Modules isotropes dérivés",
"ui.material_review.claims": "Les sources et licences sont des déclarations non vérifiées. Les valeurs absentes restent inconnues.",
"ui.material_review.density": "Masse volumique",
"ui.material_review.elasticity": "Élasticité",
"ui.material_review.tensile_strength": "Résistance à la traction",
"ui.material_review.compressive_strength": "Résistance à la compression",
"ui.material_review.yield_strength": "Limite élastique",
"ui.material_review.shear_strength": "Résistance au cisaillement",
"ui.material_review.thermal_conductivity": "Conductivité thermique",
"ui.material_review.thermal_expansion": "Dilatation thermique",
"ui.material_review.specific_heat": "Capacité thermique massique",
"ui.material_review.moisture_unit": "kg eau / kg matériau sec",
"ui.coil": "Hélice",
"ui.coincident": "Coïncidence",
"ui.collapse": "Réduire",
@@ -1061,6 +1113,18 @@
"cmd.material_properties_directional": "Module d'élasticité directionnel",
"update.button": "Mettre à jour",
"update.button_tip": "SolidCraft {version} est disponible",
"install.menu": "Installer l'app…",
"install.button_tip": "Installer SolidCraft comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne",
"install.title": "Installer SolidCraft",
"install.steps_intro": "Safari installe les apps web depuis ses propres menus :",
"install.home_screen_1": "Touchez Partager (dans le menu … s'il n'est pas dans la barre d'outils).",
"install.home_screen_2": "Choisissez Sur l'écran d'accueil.",
"install.home_screen_3": "Touchez Ajouter.",
"install.home_screen_after": "SolidCraft s'ouvre ensuite depuis son icône sur l'écran d'accueil, sans les barres de Safari, et fonctionne hors ligne.",
"install.dock_1": "Choisissez Fichier › Ajouter au Dock.",
"install.dock_2": "Cliquez sur Ajouter.",
"install.dock_after": "SolidCraft s'ouvre ensuite dans sa propre fenêtre depuis le Dock, et fonctionne hors ligne.",
"install.done": "Terminé",
"update.title": "Mise à jour disponible",
"update.available": "SolidCraft {version} est disponible",
"update.you_have": "Vous avez la version {version}.",
@@ -1115,7 +1179,7 @@
"render.reload_settings": "Recharger les réglages enregistrés",
"render.unavailable_preview": "Ce préréglage demande un profil d'aperçu indisponible.",
"render.use_preview": "Utiliser l'aperçu intégré",
"render.navigation_help": "Faire glisser pour orbiter ; glisser avec le bouton du milieu pour un panoramique ; molette pour zoomer. Enregistrer le préréglage mémorise la caméra actuelle.",
"render.navigation_help": "La caméra se déplace comme dans Conception (Préférences › Navigation). Enregistrer le préréglage mémorise la caméra actuelle.",
"render.return_design": "Revenir à la conception",
"render.image_title": "Image de rendu",
"render.image_help": "Image d'aperçu CPU · PNG · transparence approximative",
@@ -1134,6 +1198,8 @@
"render.image_failed": "Échec du rendu",
"render.image_unknown": "État du rendu indisponible",
"render.image_reduce": "Essayez une image plus petite ou une scène plus simple.",
"render.image_too_large": "Au-delà de la limite d'image : 4096 pixels par côté et 4 194 304 pixels au total.",
"render.image_use_largest": "Utiliser la plus grande taille à ce format :",
"render.image_cancel": "Annuler le rendu",
"render.image_stale": "Cette image utilise la scène capturée avant les dernières modifications.",
"render.image_start": "Lancer le rendu",
+216 -2
View File
@@ -36,6 +36,7 @@
"cmd.component_capture_position": "位置をキャプチャ",
"cmd.component_create": "新規コンポーネント",
"cmd.component_delete": "コンポーネントを削除",
"cmd.component_unlock": "内容のロックを解除",
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
"cmd.component_insert_design": "デザインを挿入",
"cmd.component_links": "リンクを一覧表示",
@@ -107,6 +108,7 @@
"cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_curvature_comb": "曲率コーム解析",
"cmd.inspect_curvature_map": "曲率マップ解析",
"cmd.inspect_draft": "勾配解析",
@@ -201,6 +203,8 @@
"cmd.sketch_constraint_fix": "固定/固定解除",
"cmd.sketch_constraint_midpoint": "中点",
"cmd.sketch_construction": "標準/コンストラクション",
"cmd.sketch_copy": "スケッチ要素をコピー",
"cmd.sketch_cut": "スケッチ要素を切り取り",
"cmd.sketch_delete": "スケッチ要素を削除",
"cmd.sketch_dimension_text": "寸法テキストを移動",
"cmd.sketch_drag": "スケッチ ジオメトリをドラッグ",
@@ -225,6 +229,7 @@
"cmd.sketch_move_point": "スケッチ点をドラッグ",
"cmd.sketch_offset_break": "オフセット関連付けを解除",
"cmd.sketch_options": "スケッチ パレット オプション",
"cmd.sketch_paste": "スケッチ要素を貼り付け",
"cmd.sketch_point3d": "3D 点",
"cmd.sketch_polygon_circumscribed": "外接ポリゴン",
"cmd.sketch_polygon_edge": "エッジ ポリゴン",
@@ -304,6 +309,31 @@
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
"document.save_changes": "{name} への変更を保存しますか?",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"home.documentation": "ドキュメント",
"home.in_this_browser": "このブラウザ内",
"home.missing": "見つかりません",
"home.open": "開く…",
"home.opened_days_ago": "{count} 日前に開きました",
"home.opened_hours_ago": "{count} 時間前に開きました",
"home.opened_just_now": "たった今開きました",
"home.opened_minutes_ago": "{count} 分前に開きました",
"home.opening": "開いています…",
"home.recent": "最近使用した項目",
"home.report_issue": "問題を報告",
"home.samples": "サンプル",
"home.whats_new": "新機能",
"sample.bracket.about": "2 mm の板金ルールによるベース フランジ、エッジ フランジ、ヘミング",
"sample.bracket.name": "板金ブラケット",
"sample.enclosure.about": "リップ、ねじボス、レスト、スナップ フィットを備えたシェル化した直方体",
"sample.enclosure.name": "プラスチック筐体",
"sample.flange.about": "パラメータで制御する円形パターンを持つハブとフランジ",
"sample.flange.name": "ボルト締めフランジ",
"sample.hinge.about": "2 つのコンポーネントと回転ジョイント、60° まで駆動",
"sample.hinge.name": "ヒンジ アセンブリ",
"sample.plate.about": "フィレット、穴、穴あきボスを備えたパラメトリック プレート",
"sample.plate.name": "サンプル プレート",
"sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ",
"sample.vents.name": "通気プレート",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
"settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません",
@@ -321,6 +351,147 @@
"ui.active_component": "アクティブなコンポーネント",
"ui.agent_cursor": "エージェントカーソル",
"ui.agent_cursor_speed": "エージェントカーソル速度",
"ui.agent_chat": "エージェントチャット",
"ui.agent_chat_command": "エージェントのコマンド",
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
"ui.agent_chat_side": "エージェントチャットの位置",
"ui.agent_chat_left": "左",
"ui.agent_chat_right": "右",
"ui.agent_chat_turn_limit": "1メッセージあたりの変更数",
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
"ui.agent_chat_connect": "接続",
"ui.agent_chat_stop": "停止",
"ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します",
"ui.agent_chat_close": "閉じる",
"ui.agent_chat_minimize": "細いバーに最小化",
"ui.agent_chat_dock_left": "左にドッキング",
"ui.agent_chat_dock_right": "右にドッキング",
"ui.agent_chat_send": "送信",
"ui.agent_chat_cancel": "キャンセル",
"ui.agent_chat_refresh": "再検索",
"ui.agent_chat_pick": "エージェント",
"ui.agent_chat_custom_hint": "標準入出力で Agent Client Protocol を話す任意のプログラム。シェルは使いません。",
"ui.agent_chat_not_installed": "未インストール",
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
"ui.agent_chat_detecting": "エージェントを検索中…",
"ui.agent_chat_signed_in": "サインイン済み",
"ui.agent_chat_key_in_env": "キー設定済み",
"ui.agent_chat_not_signed_in": "未サインイン",
"ui.agent_chat_state_off": "未接続",
"ui.agent_chat_state_starting": "起動中…",
"ui.agent_chat_state_ready": "準備完了",
"ui.agent_chat_state_busy": "作業中…",
"ui.agent_chat_state_auth": "サインインが必要",
"ui.agent_chat_state_exited": "エージェント停止",
"ui.agent_chat_changes_of": "変更 {n}/{limit}",
"ui.agent_chat_auth_title": "エージェントにサインイン",
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
"ui.agent_chat_auth_none": "このエージェントはここでのサインイン方法を提示していません。エージェントのドキュメントに従ってサインインしてください。",
"ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:",
"ui.agent_chat_auth_agent": "{name} でサインイン",
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
"ui.agent_chat_auth_retry": "サインインしました: 再接続",
"ui.agent_chat_copy": "コピー",
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
"ui.agent_chat_answered": "回答: {option}",
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
"ui.agent_chat_allow": "許可",
"ui.agent_chat_deny": "拒否",
"ui.agent_chat_allowed": "許可しました",
"ui.agent_chat_denied": "拒否しました",
"ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)",
"ui.agent_chat_undo_changes": "これらの変更を元に戻す",
"ui.agent_chat_undone": "(元に戻しました)",
"ui.agent_chat_undo_label": "エージェントチャット: {message}",
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
"ui.agent_chat_refused": "エージェントには許可されていません: {reason}",
"ui.agent_chat_wait_turn": "エージェントはまだ前のメッセージを処理中です。待つか「キャンセル」を押してください。",
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
"ui.agent_chat_attach_view": "ビューを添付",
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
"ui.agent_chat_view_attached": "ビューを添付済み",
"ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_exited": "エージェントが停止しました。",
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
"ui.ai_add": "追加",
"ui.ai_add_agent": "エージェントを追加…",
"ui.ai_add_agent_title": "エージェントを追加",
"ui.ai_add_api": "OpenAI 互換 API",
"ui.ai_add_custom": "カスタムコマンド",
"ui.ai_added": "追加済み",
"ui.ai_add_installed": "インストール済みエージェント",
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。SolidCraft のツールは「環境設定 > AI」の権限に従います。",
"ui.ai_agents": "エージェント",
"ui.ai_always_allow": "常に許可",
"ui.ai_always_allowed": "常に許可されたもの",
"ui.ai_always_allowed_none": "まだ常に許可されたものはありません。チャットで「常に許可」と答えたものがここに表示されます。",
"ui.ai_always_allow_tip": "今後このエージェントに {tool} を確認なしで許可します",
"ui.ai_auto_allowed": "自動許可: {title}({why})",
"ui.ai_auto_mode": "権限の設定",
"ui.ai_auto_ours": "SolidCraft のツールは SolidCraft 自身が確認します",
"ui.ai_auto_remembered": "「常に許可」を選択済み",
"ui.ai_bad_url": "http:// または https:// で始まる URL を入力してください",
"ui.ai_base_url": "ベース URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "コピー",
"ui.ai_custom_url": "カスタム URL",
"ui.ai_default": "既定",
"ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください",
"ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください",
"ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください",
"ui.ai_key": "API キー",
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
"ui.ai_mode_all": "すべて許可",
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
"ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。",
"ui.ai_mode_ask": "毎回確認",
"ui.ai_mode_ask_note": "デザインへのすべての変更とファイル操作が、あなたの許可を待ちます。",
"ui.ai_mode_edits": "モデル編集を自動許可",
"ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。",
"ui.ai_mode_global": "上の設定に従う",
"ui.ai_model": "モデル",
"ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
"ui.ai_name": "名前",
"ui.ai_need_command": "エージェントを起動するコマンドを入力してください",
"ui.ai_need_model": "モデル名を入力してください",
"ui.ai_no_answer": "エージェントが時間内に応答しませんでした",
"ui.ai_no_http": "このビルドは API エンドポイントに接続できません",
"ui.ai_no_keychain": "システムのキーチェーンが使えないため、キーを保存できません。キー不要のローカルサーバーは利用できます。",
"ui.ai_panel": "パネル",
"ui.ai_permissions": "権限",
"ui.ai_preset": "プリセット",
"ui.ai_remove": "削除",
"ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください",
"ui.ai_reset": "リセット",
"ui.ai_settings": "AI 設定…",
"ui.ai_show_features": "AI 機能を表示",
"ui.ai_show_features_note": "エージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。",
"ui.ai_switch_tip": "この会話のエージェントを切り替え",
"ui.ai_test": "テスト",
"ui.ai_test_acp_ok": "応答あり: {agent}",
"ui.ai_test_acp_tip": "エージェントを短時間起動してセッションを要求し、終了します",
"ui.ai_test_api_tip": "エンドポイントにモデル一覧を問い合わせます",
"ui.ai_testing": "テスト中…",
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
"ui.ai_toolbar_button": "ツールバーにボタンを表示",
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
"ui.ai_tool_done": "完了",
"ui.ai_tool_failed": "失敗",
"ui.ai_tool_running": "実行中",
"ui.ai_tool_unfold": "クリックして結果を表示",
"ui.ai_tool_waiting": "待機中",
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
"AI": "AI",
"ui.all": "すべて",
"ui.analysis": "解析",
"ui.angle": "角度",
@@ -368,6 +539,28 @@
"ui.click_selects_whole_bodies": "クリックでボディ全体を選択",
"ui.click_then_press_key": "クリックした後、割り当てるキーを押してください",
"ui.close": "閉じる",
"ui.material_review.title": "物理材料データを確認",
"ui.material_review.scope": "候補のみ:割り当てや保存は行いません。数値検証は工学的認証ではありません。",
"ui.material_review.input": "スキーマ1の材料カードを貼り付け(JSON)",
"ui.material_review.review": "カードを確認",
"ui.material_review.limit": "カードが256 KiBの入力上限を超えています。",
"ui.material_review.unknown": "不明",
"ui.material_review.properties": "特性",
"ui.material_review.conditions": "記載された条件",
"ui.material_review.source": "出典",
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
"ui.material_review.derived": "導出された等方性弾性率",
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
"ui.material_review.density": "密度",
"ui.material_review.elasticity": "弾性",
"ui.material_review.tensile_strength": "引張強度",
"ui.material_review.compressive_strength": "圧縮強度",
"ui.material_review.yield_strength": "降伏強度",
"ui.material_review.shear_strength": "せん断強度",
"ui.material_review.thermal_conductivity": "熱伝導率",
"ui.material_review.thermal_expansion": "熱膨張",
"ui.material_review.specific_heat": "比熱",
"ui.material_review.moisture_unit": "kg 水 / kg 乾燥材料",
"ui.coil": "コイル",
"ui.coincident": "一致",
"ui.collapse": "折りたたむ",
@@ -1061,6 +1254,18 @@
"cmd.material_properties_directional": "方向別弾性率",
"update.button": "アップデート",
"update.button_tip": "SolidCraft {version} が利用できます",
"install.menu": "アプリをインストール…",
"install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
"install.title": "SolidCraft をインストール",
"install.steps_intro": "Safari では、Web アプリを Safari 自身のメニューからインストールします:",
"install.home_screen_1": "「共有」をタップします(ツールバーにない場合は … メニューの中にあります)。",
"install.home_screen_2": "「ホーム画面に追加」を選びます。",
"install.home_screen_3": "「追加」をタップします。",
"install.home_screen_after": "SolidCraft はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
"install.dock_1": "「ファイル」›「Dockに追加」を選びます。",
"install.dock_2": "「追加」をクリックします。",
"install.dock_after": "SolidCraft は Dock から専用のウィンドウで開き、オフラインでも使えます。",
"install.done": "完了",
"update.title": "アップデートがあります",
"update.available": "SolidCraft {version} が利用できます",
"update.you_have": "現在のバージョンは {version} です。",
@@ -1111,7 +1316,7 @@
"render.reload_settings": "保存した設定を再読み込み",
"render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。",
"render.use_preview": "統合プレビューを使用",
"render.navigation_help": "ドラッグで回転、中央ボタンのドラッグで移動、スクロールでズームします。「セットアップを保存」で現在のカメラを記録します。",
"render.navigation_help": "カメラはデザインと同じように動きます(環境設定 › ナビゲーション)。「セットアップを保存」で現在のカメラを記録します。",
"render.return_design": "設計に戻る",
"render.image_title": "画像をレンダリング",
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
@@ -1130,6 +1335,8 @@
"render.image_failed": "レンダリングに失敗しました",
"render.image_unknown": "レンダリング状態を取得できません",
"render.image_reduce": "画像を小さくするかシーンを簡略化してください。",
"render.image_too_large": "画像の上限を超えています:一辺 4096 ピクセル、合計 4,194,304 ピクセル。",
"render.image_use_largest": "この縦横比での最大サイズを使用:",
"render.image_cancel": "レンダリングをキャンセル",
"render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。",
"render.image_start": "レンダリングを開始",
@@ -1163,5 +1370,12 @@
"Round": "丸め",
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。"
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
"Circumference": "円周",
"Arc length": "円弧の長さ",
"Slot width": "長穴の幅",
"Centre to centre": "中心間",
"Overall length": "全長",
"Min": "最小",
"Max": "最大"
}
+218 -2
View File
@@ -26,6 +26,31 @@
"dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.",
"document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?",
"document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.",
"home.documentation": "문서",
"home.in_this_browser": "이 브라우저에 저장됨",
"home.missing": "찾을 수 없음",
"home.open": "열기…",
"home.opened_days_ago": "{count}일 전에 열림",
"home.opened_hours_ago": "{count}시간 전에 열림",
"home.opened_just_now": "방금 열림",
"home.opened_minutes_ago": "{count}분 전에 열림",
"home.opening": "여는 중…",
"home.recent": "최근 항목",
"home.report_issue": "문제 신고",
"home.samples": "샘플",
"home.whats_new": "새로운 기능",
"sample.bracket.about": "2 mm 판금 규칙을 적용한 베이스 플랜지, 에지 플랜지, 헴",
"sample.bracket.name": "판금 브래킷",
"sample.enclosure.about": "리프, 나사 보스, 레스트, 스냅핏이 있는 쉘 처리된 박스",
"sample.enclosure.name": "플라스틱 인클로저",
"sample.flange.about": "매개변수로 제어되는 원형 패턴이 있는 허브와 플랜지",
"sample.flange.name": "볼트 플랜지",
"sample.hinge.about": "두 구성 요소와 회전 관절, 60°까지 구동",
"sample.hinge.name": "힌지 어셈블리",
"sample.plate.about": "필렛, 구멍, 보어 가공된 보스가 있는 매개변수 기반 플레이트",
"sample.plate.name": "샘플 플레이트",
"sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수",
"sample.vents.name": "통풍 플레이트",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
"settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음",
@@ -41,6 +66,147 @@
"ui.active_component": "활성 컴포넌트",
"ui.agent_cursor": "에이전트 커서",
"ui.agent_cursor_speed": "에이전트 커서 속도",
"ui.agent_chat": "에이전트 채팅",
"ui.agent_chat_command": "에이전트 명령",
"ui.agent_chat_desktop_only": "에이전트 채팅은 데스크톱 앱이 필요합니다.",
"ui.agent_chat_side": "에이전트 채팅 위치",
"ui.agent_chat_left": "왼쪽",
"ui.agent_chat_right": "오른쪽",
"ui.agent_chat_turn_limit": "메시지당 변경 수",
"ui.agent_chat_privacy_note": "SolidCraft는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. SolidCraft는 키나 비밀번호를 보거나 저장하지 않습니다.",
"ui.agent_chat_connect": "연결",
"ui.agent_chat_stop": "중지",
"ui.agent_chat_stop_tip": "에이전트와 에이전트가 시작한 모든 것을 중지",
"ui.agent_chat_close": "닫기",
"ui.agent_chat_minimize": "얇은 막대로 최소화",
"ui.agent_chat_dock_left": "왼쪽에 고정",
"ui.agent_chat_dock_right": "오른쪽에 고정",
"ui.agent_chat_send": "보내기",
"ui.agent_chat_cancel": "취소",
"ui.agent_chat_refresh": "새로 고침",
"ui.agent_chat_pick": "에이전트",
"ui.agent_chat_custom_hint": "표준 입출력으로 Agent Client Protocol을 사용하는 모든 프로그램. 셸 없이 실행됩니다.",
"ui.agent_chat_not_installed": "설치되지 않음",
"ui.agent_chat_none_found": "코딩 에이전트를 찾지 못했습니다. 다음 중 하나를 설치한 뒤 새로 고침을 누르세요:",
"ui.agent_chat_detecting": "에이전트 찾는 중…",
"ui.agent_chat_signed_in": "로그인됨",
"ui.agent_chat_key_in_env": "키 설정됨",
"ui.agent_chat_not_signed_in": "로그인 안 됨",
"ui.agent_chat_state_off": "연결 안 됨",
"ui.agent_chat_state_starting": "시작하는 중…",
"ui.agent_chat_state_ready": "준비됨",
"ui.agent_chat_state_busy": "작업 중…",
"ui.agent_chat_state_auth": "로그인 필요",
"ui.agent_chat_state_exited": "에이전트 중지됨",
"ui.agent_chat_changes_of": "변경 {n}/{limit}",
"ui.agent_chat_auth_title": "에이전트에 로그인",
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, SolidCraft는 비밀번호나 키를 보지 않습니다.",
"ui.agent_chat_auth_none": "이 에이전트는 여기서 로그인 방법을 제공하지 않습니다. 에이전트 문서에 안내된 방법으로 로그인하세요.",
"ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:",
"ui.agent_chat_auth_agent": "{name}(으)로 로그인",
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 SolidCraft를 다시 시작하세요.",
"ui.agent_chat_auth_retry": "로그인했습니다: 다시 연결",
"ui.agent_chat_copy": "복사",
"ui.agent_chat_permission": "에이전트가 실행 허락을 요청합니다: {title}",
"ui.agent_chat_answered": "응답: {option}",
"ui.agent_chat_approval": "에이전트가 이 작업을 하도록 허용할까요?",
"ui.agent_chat_allow": "허용",
"ui.agent_chat_deny": "거부",
"ui.agent_chat_allowed": "허용함",
"ui.agent_chat_denied": "거부함",
"ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번",
"ui.agent_chat_undo_changes": "이 변경 사항 실행 취소",
"ui.agent_chat_undone": "(취소됨)",
"ui.agent_chat_undo_label": "에이전트 채팅: {message}",
"ui.agent_chat_limit": "에이전트가 메시지당 변경 한도 {limit}에 도달했습니다. 계속하게 하려면 메시지를 하나 더 보내세요.",
"ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}",
"ui.agent_chat_wait_turn": "에이전트가 아직 이전 메시지를 처리 중입니다. 기다리거나 취소를 누르세요.",
"ui.agent_chat_placeholder": "에이전트에게 디자인 변경을 요청하세요…",
"ui.agent_chat_attach_view": "뷰 첨부",
"ui.agent_chat_attach_view_tip": "현재 뷰의 이미지를 메시지와 함께 보냅니다",
"ui.agent_chat_no_images": "이 에이전트는 이미지를 받지 않습니다",
"ui.agent_chat_view_attached": "뷰 첨부됨",
"ui.agent_chat_no_cli": "앱 옆에서 solidcraft-cli를 찾지 못해 에이전트가 디자인에 접근할 수 없습니다.",
"ui.agent_chat_no_control": "에이전트 채팅 엔드포인트가 시작되지 않아 에이전트가 디자인에 접근할 수 없습니다.",
"ui.agent_chat_exited": "에이전트가 중지되었습니다.",
"ui.agent_chat_failed_start": "에이전트를 시작할 수 없습니다: {error}",
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
"ui.agent_chat_privacy": "사용자의 계정으로 실행하는 에이전트 외에는 아무 데도 전송되지 않습니다.",
"ui.agent_chat_none_found_short": "에이전트를 찾지 못함",
"ui.ai_add": "추가",
"ui.ai_add_agent": "에이전트 추가…",
"ui.ai_add_agent_title": "에이전트 추가",
"ui.ai_add_api": "OpenAI 호환 API",
"ui.ai_add_custom": "사용자 지정 명령",
"ui.ai_added": "추가됨",
"ui.ai_add_installed": "설치된 에이전트",
"ui.ai_add_installed_note": "설치된 에이전트에 대한 별도 항목(예: 다른 인수로 실행). 설치된 에이전트는 자동으로 표시됩니다.",
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. SolidCraft 도구는 환경 설정 > AI의 권한을 따릅니다.",
"ui.ai_agents": "에이전트",
"ui.ai_always_allow": "항상 허용",
"ui.ai_always_allowed": "항상 허용됨",
"ui.ai_always_allowed_none": "아직 항상 허용된 것이 없습니다. 채팅에서 \"항상 허용\"으로 답한 항목이 여기에 표시됩니다.",
"ui.ai_always_allow_tip": "이 에이전트에게 앞으로 {tool}을(를) 묻지 않고 허용",
"ui.ai_auto_allowed": "자동 허용: {title} ({why})",
"ui.ai_auto_mode": "권한 설정",
"ui.ai_auto_ours": "SolidCraft가 자체 도구를 직접 확인합니다",
"ui.ai_auto_remembered": "\"항상 허용\"을 선택함",
"ui.ai_bad_url": "http:// 또는 https://로 시작하는 URL을 입력하세요",
"ui.ai_base_url": "기본 URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "복사본",
"ui.ai_custom_url": "사용자 지정 URL",
"ui.ai_default": "기본값",
"ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요",
"ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요",
"ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요",
"ui.ai_key": "API 키",
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
"ui.ai_mode_all": "모두 허용",
"ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.",
"ui.ai_mode_all_warning": "에이전트가 묻지 않고 디자인을 저장, 덮어쓰기, 내보내기, 교체할 수 있습니다. 신뢰하는 에이전트와 저장된 디자인에서만 사용하세요.",
"ui.ai_mode_ask": "매번 묻기",
"ui.ai_mode_ask_note": "디자인의 모든 변경과 파일 작업이 사용자의 허용을 기다립니다.",
"ui.ai_mode_edits": "모델 편집 자동 허용",
"ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.",
"ui.ai_mode_global": "위 설정 따름",
"ui.ai_model": "모델",
"ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
"ui.ai_name": "이름",
"ui.ai_need_command": "에이전트를 시작하는 명령을 입력하세요",
"ui.ai_need_model": "모델 이름을 입력하세요",
"ui.ai_no_answer": "에이전트가 제시간에 응답하지 않았습니다",
"ui.ai_no_http": "이 빌드는 API 엔드포인트에 연결할 수 없습니다",
"ui.ai_no_keychain": "시스템 키체인을 사용할 수 없어 키를 저장할 수 없습니다. 키가 필요 없는 로컬 서버는 사용할 수 있습니다.",
"ui.ai_panel": "패널",
"ui.ai_permissions": "권한",
"ui.ai_preset": "프리셋",
"ui.ai_remove": "제거",
"ui.ai_request_limit": "메시지 하나에 24번 요청 후 멈췄습니다. 계속하려면 메시지를 하나 더 보내세요",
"ui.ai_reset": "재설정",
"ui.ai_settings": "AI 설정…",
"ui.ai_show_features": "AI 기능 표시",
"ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.",
"ui.ai_switch_tip": "이 대화의 에이전트 변경",
"ui.ai_test": "테스트",
"ui.ai_test_acp_ok": "응답: {agent}",
"ui.ai_test_acp_tip": "에이전트를 잠시 시작해 세션을 요청한 뒤 종료합니다",
"ui.ai_test_api_tip": "엔드포인트에 모델 목록을 요청합니다",
"ui.ai_testing": "테스트 중…",
"ui.ai_test_no_model": "연결됨: 모델 {count}개, {model} 없음",
"ui.ai_test_ok": "연결됨: 모델 {count}개",
"ui.ai_toolbar_button": "도구 모음에 버튼 표시",
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
"ui.ai_tool_done": "완료",
"ui.ai_tool_failed": "실패",
"ui.ai_tool_running": "실행 중",
"ui.ai_tool_unfold": "클릭하면 결과를 표시",
"ui.ai_tool_waiting": "대기 중",
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
"AI": "AI",
"ui.all": "모두",
"ui.analysis": "분석",
"ui.angle": "각도",
@@ -85,6 +251,28 @@
"ui.clear_recent": "최근 항목 지우기",
"ui.clear_the_selection": "선택 해제",
"ui.close": "닫기",
"ui.material_review.title": "물리적 재료 데이터 검토",
"ui.material_review.scope": "후보만 검토합니다. 지정하거나 저장하지 않습니다. 수치 검증은 공학적 인증이 아닙니다.",
"ui.material_review.input": "스키마 1 재료 카드 붙여넣기 (JSON)",
"ui.material_review.review": "카드 검토",
"ui.material_review.limit": "카드가 256 KiB 입력 한도를 초과합니다.",
"ui.material_review.unknown": "알 수 없음",
"ui.material_review.properties": "물성",
"ui.material_review.conditions": "명시된 조건",
"ui.material_review.source": "출처",
"ui.material_review.axes": "재료 로컬 축입니다. 바디 방향을 지정하지 않습니다.",
"ui.material_review.derived": "유도된 등방성 탄성 계수",
"ui.material_review.claims": "출처와 라이선스는 검증되지 않은 주장입니다. 누락된 값은 알 수 없는 상태로 유지됩니다.",
"ui.material_review.density": "밀도",
"ui.material_review.elasticity": "탄성",
"ui.material_review.tensile_strength": "인장 강도",
"ui.material_review.compressive_strength": "압축 강도",
"ui.material_review.yield_strength": "항복 강도",
"ui.material_review.shear_strength": "전단 강도",
"ui.material_review.thermal_conductivity": "열전도율",
"ui.material_review.thermal_expansion": "열팽창",
"ui.material_review.specific_heat": "비열",
"ui.material_review.moisture_unit": "kg 물 / kg 건조 재료",
"ui.coil": "코일",
"ui.coincident": "일치",
"ui.collapse": "접기",
@@ -483,6 +671,7 @@
"cmd.component_capture_position": "위치 캡처",
"cmd.component_create": "새 구성 요소",
"cmd.component_delete": "구성 요소 삭제",
"cmd.component_unlock": "내용 잠금 해제",
"cmd.component_from_bodies": "바디로 구성 요소 만들기",
"cmd.component_insert_design": "설계 삽입",
"cmd.component_links": "링크 목록",
@@ -554,6 +743,7 @@
"cmd.file_slicers": "설치된 슬라이서",
"cmd.inspect_accessibility": "접근성 분석",
"cmd.inspect_center_of_mass": "무게중심",
"cmd.inspect_compare": "바디 비교",
"cmd.inspect_curvature_comb": "곡률빗 분석",
"cmd.inspect_curvature_map": "곡률맵 분석",
"cmd.inspect_draft": "블록각 분석",
@@ -650,6 +840,8 @@
"cmd.sketch_constraint_fix": "고정/고정 해제",
"cmd.sketch_constraint_midpoint": "중간점",
"cmd.sketch_construction": "일반/구성선",
"cmd.sketch_copy": "스케치 요소 복사",
"cmd.sketch_cut": "스케치 요소 잘라내기",
"cmd.sketch_delete": "스케치 개체 삭제",
"cmd.sketch_dimension_text": "치수 텍스트 이동",
"cmd.sketch_drag": "스케치 기하 드래그",
@@ -674,6 +866,7 @@
"cmd.sketch_move_point": "스케치 점 드래그",
"cmd.sketch_offset_break": "오프셋 연결 끊기",
"cmd.sketch_options": "스케치 팔레트 옵션",
"cmd.sketch_paste": "스케치 요소 붙여넣기",
"cmd.sketch_point3d": "3D 점",
"cmd.sketch_polygon_circumscribed": "외접 다각형",
"cmd.sketch_polygon_edge": "변 다각형",
@@ -803,6 +996,18 @@
"Profiles/Curves": "프로파일/곡선",
"update.button": "업데이트",
"update.button_tip": "SolidCraft {version} 사용 가능",
"install.menu": "앱 설치…",
"install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
"install.title": "SolidCraft 설치",
"install.steps_intro": "Safari는 자체 메뉴에서 웹 앱을 설치합니다:",
"install.home_screen_1": "공유를 탭합니다(도구 막대에 없으면 … 메뉴 안에 있습니다).",
"install.home_screen_2": "홈 화면에 추가를 선택합니다.",
"install.home_screen_3": "추가를 탭합니다.",
"install.home_screen_after": "이후 SolidCraft는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
"install.dock_1": "파일 › Dock에 추가를 선택합니다.",
"install.dock_2": "추가를 클릭합니다.",
"install.dock_after": "이후 SolidCraft는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
"install.done": "완료",
"update.title": "업데이트 사용 가능",
"update.available": "SolidCraft {version} 사용 가능",
"update.you_have": "현재 버전은 {version}입니다.",
@@ -853,7 +1058,7 @@
"render.reload_settings": "저장된 설정 다시 불러오기",
"render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.",
"render.use_preview": "통합 미리보기 사용",
"render.navigation_help": "드래그하여 회전하고, 가운데 버튼으로 드래그하여 이동하며, 스크롤하여 확대/축소합니다. 설정 저장은 현재 카메라를 기록합니다.",
"render.navigation_help": "카메라는 디자인에서와 같이 움직입니다(환경설정 › 탐색). 설정 저장은 현재 카메라를 기록합니다.",
"render.return_design": "설계로 돌아가기",
"render.image_title": "이미지 렌더링",
"render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도",
@@ -872,6 +1077,8 @@
"render.image_failed": "렌더링 실패",
"render.image_unknown": "렌더 상태를 확인할 수 없습니다",
"render.image_reduce": "더 작은 이미지나 더 단순한 장면을 사용해 보세요.",
"render.image_too_large": "이미지 한도를 초과했습니다: 한 변 4096픽셀, 전체 4,194,304픽셀.",
"render.image_use_largest": "이 비율에서 가장 큰 크기 사용:",
"render.image_cancel": "렌더링 취소",
"render.image_stale": "이 이미지는 후속 변경 전에 캡처한 장면을 사용합니다.",
"render.image_start": "렌더링 시작",
@@ -906,5 +1113,14 @@
"Type": "유형",
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다."
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
"Circumference": "원주",
"Arc length": "호 길이",
"Slot width": "슬롯 폭",
"Centre to centre": "중심 간",
"Overall length": "전체 길이",
"Min": "최소",
"Max": "최대",
"Length": "길이",
"Area": "면적"
}
+67 -1
View File
@@ -25,6 +25,7 @@
"cmd.component_capture_position": "Зафиксировать положение",
"cmd.component_create": "Новый компонент",
"cmd.component_delete": "Удалить компонент",
"cmd.component_unlock": "Разблокировать содержимое",
"cmd.component_from_bodies": "Создать компоненты из тел",
"cmd.component_insert_design": "Вставить дизайн",
"cmd.component_links": "Список связей",
@@ -96,6 +97,7 @@
"cmd.file_slicers": "Установленные слайсеры",
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_compare": "Сравнить тела",
"cmd.inspect_curvature_comb": "Анализ кривизны (гребёнка)",
"cmd.inspect_curvature_map": "Карта кривизны",
"cmd.inspect_draft": "Анализ уклонов",
@@ -189,6 +191,8 @@
"cmd.sketch_constraint_fix": "Зафиксировать/Освободить",
"cmd.sketch_constraint_midpoint": "Середина",
"cmd.sketch_construction": "Обычная/Вспомогательная",
"cmd.sketch_copy": "Копировать элементы эскиза",
"cmd.sketch_cut": "Вырезать элементы эскиза",
"cmd.sketch_delete": "Удалить объекты эскиза",
"cmd.sketch_dimension_text": "Переместить текст размера",
"cmd.sketch_drag": "Перетащить геометрию эскиза",
@@ -213,6 +217,7 @@
"cmd.sketch_move_point": "Перетащить точку эскиза",
"cmd.sketch_offset_break": "Разорвать связь смещения",
"cmd.sketch_options": "Параметры палитры эскиза",
"cmd.sketch_paste": "Вставить элементы эскиза",
"cmd.sketch_point3d": "3D-точка",
"cmd.sketch_polygon_circumscribed": "Описанный многоугольник",
"cmd.sketch_polygon_edge": "Многоугольник по ребру",
@@ -289,6 +294,31 @@
"dialog.selection_guidance": "Щёлкните поле ввода, чтобы изменить его. Щелчок по выделенному объекту убирает его; Ctrl/Cmd+щелчок тоже переключает. Проверьте предпросмотр и нажмите OK.",
"document.save_changes": "Сохранить изменения в {name}?",
"document.unsaved_changes": "В {name} есть несохранённые изменения. Если их не сохранить, они будут потеряны.",
"home.documentation": "Документация",
"home.in_this_browser": "в этом браузере",
"home.missing": "отсутствует",
"home.open": "Открыть…",
"home.opened_days_ago": "Открыт {count} дн. назад",
"home.opened_hours_ago": "Открыт {count} ч назад",
"home.opened_just_now": "Открыт только что",
"home.opened_minutes_ago": "Открыт {count} мин назад",
"home.opening": "открывается…",
"home.recent": "Недавние",
"home.report_issue": "Сообщить о проблеме",
"home.samples": "Примеры",
"home.whats_new": "Что нового",
"sample.bracket.about": "Базовый фланец с краевыми фланцами и кромкой по правилу листового металла 2 мм",
"sample.bracket.name": "Кронштейн из листового металла",
"sample.enclosure.about": "Тонкостенный бокс с выступом, бобышками под винты, опорой и защёлкой",
"sample.enclosure.name": "Пластиковый корпус",
"sample.flange.about": "Ступица и фланец с круговым массивом, управляемым параметром",
"sample.flange.name": "Болтовой фланец",
"sample.hinge.about": "Два компонента и вращательное сопряжение, повёрнутое на 60°",
"sample.hinge.name": "Сборка шарнира",
"sample.plate.about": "Параметрическая пластина со скруглениями, отверстиями и расточенной бобышкой",
"sample.plate.name": "Образец пластины",
"sample.vents.about": "Пазы в прямоугольном массиве: число рядов, столбцов и шаг заданы параметрами",
"sample.vents.name": "Вентиляционная пластина",
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
"settings.msaa_reset": "{setting}: {requested}× не поддерживается этим графическим устройством; теперь {reset}× (поддерживается: {supported}).",
"settings.msaa_unsupported": "Не поддерживается этим графическим устройством",
@@ -343,6 +373,28 @@
"ui.click_selects_whole_bodies": "Щелчок выделяет тела целиком",
"ui.click_then_press_key": "Щёлкните, затем нажмите новую клавишу",
"ui.close": "Закрыть",
"ui.material_review.title": "Проверка физических данных материала",
"ui.material_review.scope": "Только кандидат: без назначения и сохранения. Численная проверка не является инженерной сертификацией.",
"ui.material_review.input": "Вставьте карточку материала по схеме 1 (JSON)",
"ui.material_review.review": "Проверить карточку",
"ui.material_review.limit": "Карточка превышает предел ввода 256 КиБ.",
"ui.material_review.unknown": "Неизвестно",
"ui.material_review.properties": "Свойства",
"ui.material_review.conditions": "Указанные условия",
"ui.material_review.source": "Источник",
"ui.material_review.axes": "Локальные оси материала; ориентация тела не назначается.",
"ui.material_review.derived": "Вычисленные изотропные модули",
"ui.material_review.claims": "Источники и лицензии указаны без проверки. Отсутствующие значения остаются неизвестными.",
"ui.material_review.density": "Плотность",
"ui.material_review.elasticity": "Упругость",
"ui.material_review.tensile_strength": "Прочность при растяжении",
"ui.material_review.compressive_strength": "Прочность при сжатии",
"ui.material_review.yield_strength": "Предел текучести",
"ui.material_review.shear_strength": "Прочность при сдвиге",
"ui.material_review.thermal_conductivity": "Теплопроводность",
"ui.material_review.thermal_expansion": "Тепловое расширение",
"ui.material_review.specific_heat": "Удельная теплоёмкость",
"ui.material_review.moisture_unit": "кг воды / кг сухого материала",
"ui.coil": "Спираль",
"ui.coincident": "Совпадение",
"ui.collapse": "Свернуть",
@@ -970,6 +1022,18 @@
"ui.removeFromToolbar": "Убрать с панели",
"ui.repeat": "Повторить",
"ui.report_a_bug": "Сообщить об ошибке",
"install.menu": "Установить приложение…",
"install.button_tip": "Установить SolidCraft как приложение: отдельное окно и работа без сети",
"install.title": "Установка SolidCraft",
"install.steps_intro": "Safari устанавливает веб-приложения через собственные меню:",
"install.home_screen_1": "Нажмите «Поделиться» (в меню …, если кнопки нет на панели).",
"install.home_screen_2": "Выберите «На экран „Домой“».",
"install.home_screen_3": "Нажмите «Добавить».",
"install.home_screen_after": "После этого SolidCraft открывается значком на экране «Домой», без панелей Safari, и работает без сети.",
"install.dock_1": "Выберите «Файл» › «Добавить в Dock».",
"install.dock_2": "Нажмите «Добавить».",
"install.dock_after": "После этого SolidCraft открывается из Dock в отдельном окне и работает без сети.",
"install.done": "Готово",
"ui.restore": "Восстановить",
"ui.heading_setup": "НАСТРОЙКА",
"ui.selectOther": "Выбрать другое…",
@@ -1036,7 +1100,7 @@
"render.reload_settings": "Загрузить сохранённые настройки",
"render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.",
"render.use_preview": "Использовать встроенный предпросмотр",
"render.navigation_help": "Перетаскивайте для вращения, используйте среднюю кнопку для перемещения и колесо для масштаба. Сохранение набора записывает текущую камеру.",
"render.navigation_help": "Камера управляется так же, как в проектировании (Настройки › Навигация). Сохранение набора записывает текущую камеру.",
"render.return_design": "Вернуться к проектированию",
"render.image_title": "Рендеринг изображения",
"render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность",
@@ -1055,6 +1119,8 @@
"render.image_failed": "Ошибка рендеринга",
"render.image_unknown": "Состояние рендеринга недоступно",
"render.image_reduce": "Попробуйте уменьшить изображение или упростить сцену.",
"render.image_too_large": "Превышен предел изображения: 4096 пикселей по стороне и 4 194 304 пикселя всего.",
"render.image_use_largest": "Использовать наибольший размер с этим соотношением сторон:",
"render.image_cancel": "Отменить рендеринг",
"render.image_stale": "Используется сцена, сохранённая до последующих изменений.",
"render.image_start": "Начать рендеринг",
+39
View File
@@ -30,6 +30,7 @@
"cmd.component_capture_position": "Зафіксувати положення",
"cmd.component_create": "Новий компонент",
"cmd.component_delete": "Видалити компонент",
"cmd.component_unlock": "Розблокувати вміст",
"cmd.component_from_bodies": "Створити компоненти з тіл",
"cmd.component_insert_design": "Вставити проєкт",
"cmd.component_links": "Список зв'язків",
@@ -97,6 +98,7 @@
"cmd.file_save_mesh": "Зберегти як сітку",
"cmd.inspect_accessibility": "Аналіз доступності",
"cmd.inspect_center_of_mass": "Центр мас",
"cmd.inspect_compare": "Порівняти тіла",
"cmd.inspect_curvature_comb": "Аналіз гребеня кривизни",
"cmd.inspect_curvature_map": "Аналіз карти кривизни",
"cmd.inspect_draft": "Аналіз ухилу",
@@ -191,6 +193,8 @@
"cmd.sketch_constraint_fix": "Закріпити/розкріпити",
"cmd.sketch_constraint_midpoint": "Середня точка",
"cmd.sketch_construction": "Звичайна/конструкційна",
"cmd.sketch_copy": "Копіювати елементи ескізу",
"cmd.sketch_cut": "Вирізати елементи ескізу",
"cmd.sketch_delete": "Видалити елементи ескізу",
"cmd.sketch_dimension_text": "Перемістити текст розміру",
"cmd.sketch_drag": "Перетягнути геометрію ескізу",
@@ -215,6 +219,7 @@
"cmd.sketch_move_point": "Перетягнути точку ескізу",
"cmd.sketch_offset_break": "Розірвати зв'язок зсуву",
"cmd.sketch_options": "Параметри палітри ескізу",
"cmd.sketch_paste": "Вставити елементи ескізу",
"cmd.sketch_point3d": "3D-точка",
"cmd.sketch_polygon_circumscribed": "Описаний багатокутник",
"cmd.sketch_polygon_edge": "Багатокутник за ребром",
@@ -347,6 +352,28 @@
"ui.click_selects_whole_bodies": "Клацання виділяє тіла цілком",
"ui.click_then_press_key": "Клацніть, а потім натисніть нову клавішу",
"ui.close": "Закрити",
"ui.material_review.title": "Перевірка фізичних даних матеріалу",
"ui.material_review.scope": "Лише кандидат: без призначення та збереження. Числова перевірка не є інженерною сертифікацією.",
"ui.material_review.input": "Вставте картку матеріалу за схемою 1 (JSON)",
"ui.material_review.review": "Перевірити картку",
"ui.material_review.limit": "Картка перевищує межу введення 256 КіБ.",
"ui.material_review.unknown": "Невідомо",
"ui.material_review.properties": "Властивості",
"ui.material_review.conditions": "Зазначені умови",
"ui.material_review.source": "Джерело",
"ui.material_review.axes": "Локальні осі матеріалу; орієнтація тіла не призначається.",
"ui.material_review.derived": "Обчислені ізотропні модулі",
"ui.material_review.claims": "Джерела та ліцензії зазначені без перевірки. Відсутні значення залишаються невідомими.",
"ui.material_review.density": "Густина",
"ui.material_review.elasticity": "Пружність",
"ui.material_review.tensile_strength": "Міцність на розтяг",
"ui.material_review.compressive_strength": "Міцність на стиск",
"ui.material_review.yield_strength": "Межа плинності",
"ui.material_review.shear_strength": "Міцність на зсув",
"ui.material_review.thermal_conductivity": "Теплопровідність",
"ui.material_review.thermal_expansion": "Теплове розширення",
"ui.material_review.specific_heat": "Питома теплоємність",
"ui.material_review.moisture_unit": "кг води / кг сухого матеріалу",
"ui.coil": "Спіраль",
"ui.coincident": "Співпадання",
"ui.collapse": "Згорнути",
@@ -987,6 +1014,18 @@
"cmd.material_properties_directional": "Спрямований модуль пружності",
"update.button": "Оновити",
"update.button_tip": "Доступна версія SolidCraft {version}",
"install.menu": "Встановити застосунок…",
"install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі",
"install.title": "Встановлення SolidCraft",
"install.steps_intro": "Safari встановлює вебзастосунки через власні меню:",
"install.home_screen_1": "Торкніться «Поширити» (у меню …, якщо цієї кнопки немає на панелі).",
"install.home_screen_2": "Виберіть «На початковий екран».",
"install.home_screen_3": "Торкніться «Додати».",
"install.home_screen_after": "Після цього SolidCraft відкривається значком на початковому екрані, без панелей Safari, і працює без мережі.",
"install.dock_1": "Виберіть «Файл» › «Додати в Dock».",
"install.dock_2": "Натисніть «Додати».",
"install.dock_after": "Після цього SolidCraft відкривається з Dock в окремому вікні й працює без мережі.",
"install.done": "Готово",
"update.title": "Доступне оновлення",
"update.available": "Доступна версія SolidCraft {version}",
"update.you_have": "У вас версія {version}.",
+216 -2
View File
@@ -36,6 +36,7 @@
"cmd.component_capture_position": "捕获位置",
"cmd.component_create": "新建组件",
"cmd.component_delete": "删除组件",
"cmd.component_unlock": "解锁内容",
"cmd.component_from_bodies": "由实体创建组件",
"cmd.component_insert_design": "插入设计",
"cmd.component_links": "列出链接",
@@ -107,6 +108,7 @@
"cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_curvature_comb": "曲率梳分析",
"cmd.inspect_curvature_map": "曲率图分析",
"cmd.inspect_draft": "拔模分析",
@@ -201,6 +203,8 @@
"cmd.sketch_constraint_fix": "固定/解除固定",
"cmd.sketch_constraint_midpoint": "中点",
"cmd.sketch_construction": "普通/构造",
"cmd.sketch_copy": "复制草图图元",
"cmd.sketch_cut": "剪切草图图元",
"cmd.sketch_delete": "删除草图图元",
"cmd.sketch_dimension_text": "移动尺寸文字",
"cmd.sketch_drag": "拖动草图几何",
@@ -225,6 +229,7 @@
"cmd.sketch_move_point": "拖动草图点",
"cmd.sketch_offset_break": "断开偏移关联",
"cmd.sketch_options": "草图选项板选项",
"cmd.sketch_paste": "粘贴草图图元",
"cmd.sketch_point3d": "三维点",
"cmd.sketch_polygon_circumscribed": "外切多边形",
"cmd.sketch_polygon_edge": "边长多边形",
@@ -304,6 +309,31 @@
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
"document.save_changes": "保存对 {name} 的更改?",
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
"home.documentation": "文档",
"home.in_this_browser": "在此浏览器中",
"home.missing": "文件不存在",
"home.open": "打开…",
"home.opened_days_ago": "{count} 天前打开",
"home.opened_hours_ago": "{count} 小时前打开",
"home.opened_just_now": "刚刚打开",
"home.opened_minutes_ago": "{count} 分钟前打开",
"home.opening": "正在打开…",
"home.recent": "最近使用",
"home.report_issue": "报告问题",
"home.samples": "示例",
"home.whats_new": "新功能",
"sample.bracket.about": "基于 2 mm 钣金规则的基础法兰、边线法兰和卷边",
"sample.bracket.name": "钣金支架",
"sample.enclosure.about": "带凸缘、螺钉凸台、垫块和卡扣的抽壳长方体",
"sample.enclosure.name": "塑料外壳",
"sample.flange.about": "带有由参数驱动的圆形阵列的轮毂和法兰",
"sample.flange.name": "螺栓法兰",
"sample.hinge.about": "两个组件和一个旋转接头,驱动到 60°",
"sample.hinge.name": "铰链装配体",
"sample.plate.about": "带圆角、孔和镗孔凸台的参数化板",
"sample.plate.name": "示例板",
"sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数",
"sample.vents.name": "通风板",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
"settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。",
"settings.msaa_unsupported": "此图形设备不支持",
@@ -321,6 +351,147 @@
"ui.active_component": "活动组件",
"ui.agent_cursor": "代理光标",
"ui.agent_cursor_speed": "代理光标速度",
"ui.agent_chat": "智能体聊天",
"ui.agent_chat_command": "智能体命令",
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
"ui.agent_chat_side": "智能体聊天位置",
"ui.agent_chat_left": "左侧",
"ui.agent_chat_right": "右侧",
"ui.agent_chat_turn_limit": "每条消息的更改数",
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
"ui.agent_chat_connect": "连接",
"ui.agent_chat_stop": "停止",
"ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程",
"ui.agent_chat_close": "关闭",
"ui.agent_chat_minimize": "最小化为细条",
"ui.agent_chat_dock_left": "停靠在左侧",
"ui.agent_chat_dock_right": "停靠在右侧",
"ui.agent_chat_send": "发送",
"ui.agent_chat_cancel": "取消",
"ui.agent_chat_refresh": "刷新",
"ui.agent_chat_pick": "智能体",
"ui.agent_chat_custom_hint": "任何通过标准输入输出使用 Agent Client Protocol 的程序。不经过 shell 运行。",
"ui.agent_chat_not_installed": "未安装",
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
"ui.agent_chat_detecting": "正在查找智能体…",
"ui.agent_chat_signed_in": "已登录",
"ui.agent_chat_key_in_env": "已设置密钥",
"ui.agent_chat_not_signed_in": "未登录",
"ui.agent_chat_state_off": "未连接",
"ui.agent_chat_state_starting": "正在启动…",
"ui.agent_chat_state_ready": "就绪",
"ui.agent_chat_state_busy": "工作中…",
"ui.agent_chat_state_auth": "需要登录",
"ui.agent_chat_state_exited": "智能体已停止",
"ui.agent_chat_changes_of": "更改 {n}/{limit}",
"ui.agent_chat_auth_title": "登录智能体",
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
"ui.agent_chat_auth_none": "该智能体未在此提供登录方式:请按照它自己的文档登录。",
"ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:",
"ui.agent_chat_auth_agent": "使用 {name} 登录",
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
"ui.agent_chat_auth_retry": "我已登录:重新连接",
"ui.agent_chat_copy": "复制",
"ui.agent_chat_permission": "智能体请求运行:{title}",
"ui.agent_chat_answered": "回答:{option}",
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
"ui.agent_chat_allow": "允许",
"ui.agent_chat_deny": "拒绝",
"ui.agent_chat_allowed": "已允许",
"ui.agent_chat_denied": "已拒绝",
"ui.agent_chat_changes": "{n} 项更改,一步撤销",
"ui.agent_chat_undo_changes": "撤销这些更改",
"ui.agent_chat_undone": "(已撤销)",
"ui.agent_chat_undo_label": "智能体聊天:{message}",
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
"ui.agent_chat_refused": "不允许智能体执行:{reason}",
"ui.agent_chat_wait_turn": "智能体仍在处理上一条消息:请等待或点击取消。",
"ui.agent_chat_placeholder": "让智能体修改设计…",
"ui.agent_chat_attach_view": "附加视图",
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
"ui.agent_chat_no_images": "该智能体不接收图片",
"ui.agent_chat_view_attached": "已附加视图",
"ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。",
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
"ui.agent_chat_exited": "智能体已停止。",
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
"ui.agent_chat_none_found_short": "未找到智能体",
"ui.ai_add": "添加",
"ui.ai_add_agent": "添加智能体…",
"ui.ai_add_agent_title": "添加智能体",
"ui.ai_add_api": "OpenAI 兼容 API",
"ui.ai_add_custom": "自定义命令",
"ui.ai_added": "已添加",
"ui.ai_add_installed": "已安装的智能体",
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。SolidCraft 的工具遵循“首选项 > AI”中的权限。",
"ui.ai_agents": "智能体",
"ui.ai_always_allow": "始终允许",
"ui.ai_always_allowed": "始终允许的项目",
"ui.ai_always_allowed_none": "尚无始终允许的项目。你在聊天中选择“始终允许”的回答会列在这里。",
"ui.ai_always_allow_tip": "从现在起允许该智能体使用 {tool},不再询问",
"ui.ai_auto_allowed": "已自动允许:{title}({why})",
"ui.ai_auto_mode": "你的权限设置",
"ui.ai_auto_ours": "SolidCraft 会自行检查它自己的工具",
"ui.ai_auto_remembered": "你选择了始终允许",
"ui.ai_bad_url": "请输入以 http:// 或 https:// 开头的 URL",
"ui.ai_base_url": "基础 URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "副本",
"ui.ai_custom_url": "自定义 URL",
"ui.ai_default": "默认",
"ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试",
"ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查",
"ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称",
"ui.ai_key": "API 密钥",
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
"ui.ai_mode_all": "全部允许",
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
"ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。",
"ui.ai_mode_ask": "每次询问",
"ui.ai_mode_ask_note": "对设计的每项更改和每个文件操作都要等待你的允许。",
"ui.ai_mode_edits": "自动允许模型编辑",
"ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。",
"ui.ai_mode_global": "按上方设置",
"ui.ai_model": "模型",
"ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
"ui.ai_name": "名称",
"ui.ai_need_command": "请输入启动智能体的命令",
"ui.ai_need_model": "请输入模型名称",
"ui.ai_no_answer": "智能体未及时响应",
"ui.ai_no_http": "此版本无法访问 API 端点",
"ui.ai_no_keychain": "系统钥匙串不可用,无法保存密钥。无需密钥的本地服务器仍可使用。",
"ui.ai_panel": "面板",
"ui.ai_permissions": "权限",
"ui.ai_preset": "预设",
"ui.ai_remove": "移除",
"ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续",
"ui.ai_reset": "重置",
"ui.ai_settings": "AI 设置…",
"ui.ai_show_features": "显示 AI 功能",
"ui.ai_show_features_note": "智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。",
"ui.ai_switch_tip": "更改此对话的智能体",
"ui.ai_test": "测试",
"ui.ai_test_acp_ok": "已响应:{agent}",
"ui.ai_test_acp_tip": "短暂启动智能体并请求一个会话,然后结束它",
"ui.ai_test_api_tip": "向端点查询其模型列表",
"ui.ai_testing": "正在测试…",
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
"ui.ai_test_ok": "已连接:{count} 个模型",
"ui.ai_toolbar_button": "在工具栏显示按钮",
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
"ui.ai_tool_done": "完成",
"ui.ai_tool_failed": "失败",
"ui.ai_tool_running": "运行中",
"ui.ai_tool_unfold": "点击显示返回内容",
"ui.ai_tool_waiting": "等待中",
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
"AI": "AI",
"ui.all": "全部",
"ui.angle": "角度",
"ui.angle_decimals": "角度小数位",
@@ -367,6 +538,28 @@
"ui.click_selects_whole_bodies": "单击选择整个实体",
"ui.click_then_press_key": "单击,然后按新按键",
"ui.close": "关闭",
"ui.material_review.title": "检查物理材料数据",
"ui.material_review.scope": "仅检查候选数据:不指定或保存。数值验证不等于工程认证。",
"ui.material_review.input": "粘贴架构版本1的材料卡(JSON)",
"ui.material_review.review": "检查材料卡",
"ui.material_review.limit": "材料卡超过256 KiB输入限制。",
"ui.material_review.unknown": "未知",
"ui.material_review.properties": "属性",
"ui.material_review.conditions": "声明的条件",
"ui.material_review.source": "来源",
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
"ui.material_review.derived": "推导的各向同性模量",
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
"ui.material_review.density": "密度",
"ui.material_review.elasticity": "弹性",
"ui.material_review.tensile_strength": "抗拉强度",
"ui.material_review.compressive_strength": "抗压强度",
"ui.material_review.yield_strength": "屈服强度",
"ui.material_review.shear_strength": "抗剪强度",
"ui.material_review.thermal_conductivity": "导热系数",
"ui.material_review.thermal_expansion": "热膨胀",
"ui.material_review.specific_heat": "比热容",
"ui.material_review.moisture_unit": "kg 水 / kg 干燥材料",
"ui.coil": "螺旋",
"ui.coincident": "重合",
"ui.collapse": "折叠",
@@ -1061,6 +1254,18 @@
"cmd.material_properties_directional": "方向弹性模量",
"update.button": "更新",
"update.button_tip": "SolidCraft {version} 已发布",
"install.menu": "安装应用…",
"install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用",
"install.title": "安装 SolidCraft",
"install.steps_intro": "Safari 通过自身的菜单安装网页应用:",
"install.home_screen_1": "轻点“共享”(若不在工具栏上,则在 … 菜单中)。",
"install.home_screen_2": "选择“添加到主屏幕”。",
"install.home_screen_3": "轻点“添加”。",
"install.home_screen_after": "之后 SolidCraft 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
"install.dock_1": "选择“文件”›“添加到程序坞”。",
"install.dock_2": "点按“添加”。",
"install.dock_after": "之后 SolidCraft 从程序坞在独立窗口中打开,离线也能使用。",
"install.done": "完成",
"update.title": "有可用更新",
"update.available": "SolidCraft {version} 已发布",
"update.you_have": "当前版本为 {version}。",
@@ -1111,7 +1316,7 @@
"render.reload_settings": "重新加载已保存设置",
"render.unavailable_preview": "此设置请求了不可用的预览配置。",
"render.use_preview": "使用集成预览",
"render.navigation_help": "拖动以旋转,按住中键拖动以平移,滚动以缩放。保存设置会记录当前相机。",
"render.navigation_help": "相机的操作与设计中相同(首选项 › 导航)。保存设置会记录当前相机。",
"render.return_design": "返回设计",
"render.image_title": "渲染图像",
"render.image_help": "CPU 预览图像 · PNG · 近似透明度",
@@ -1130,6 +1335,8 @@
"render.image_failed": "渲染失败",
"render.image_unknown": "渲染状态不可用",
"render.image_reduce": "请尝试更小的图像或更简单的场景。",
"render.image_too_large": "超出图像限制:每边 4096 像素,总计 4,194,304 像素。",
"render.image_use_largest": "使用此宽高比下的最大尺寸:",
"render.image_cancel": "取消渲染",
"render.image_stale": "此图像使用后续更改之前捕获的场景。",
"render.image_start": "开始渲染",
@@ -1163,5 +1370,12 @@
"Round": "圆角",
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。"
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
"Circumference": "周长",
"Arc length": "弧长",
"Slot width": "槽宽",
"Centre to centre": "中心距",
"Overall length": "总长",
"Min": "最小",
"Max": "最大"
}
+3
View File
@@ -235,6 +235,9 @@ impl crate::Feature {
mapped_name(n, parameters);
}
}
for e in sketch.text_exprs_mut() {
*e = clone_expression(e, parameters);
}
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) {
+105
View File
@@ -77,6 +77,57 @@ impl DisplayQuality {
}
}
/// Most refinement steps view-dependent display refinement takes past the chosen level (#1320):
/// each halves the chord tolerance, so the finest is 1/16 of the chosen level's.
pub const MAX_REFINE: u8 = 4;
/// Screen-space error (physical pixels) above which a body is refined: a facet's sag shows.
pub const REFINE_PX: f64 = 1.0;
/// Error a refinement aims for (below [`REFINE_PX`], so a body just refined is not refined again
/// at the next small zoom step).
pub const TARGET_PX: f64 = 0.75;
/// Error below which a refined body steps back toward the chosen level. A refinement lands
/// between half [`TARGET_PX`] and [`TARGET_PX`], and a step back between this and twice this, so
/// zooming out or in by up to 2× from either never flips the level back.
pub const COARSEN_PX: f64 = 0.175;
impl DisplayQuality {
/// Chord tolerance (mm) of a body of this size refined `k` steps past this level: halved
/// per step.
pub fn refined_chord(self, size: f64, k: u8) -> f64 {
self.chord(size) / f64::from(1u32 << k.min(MAX_REFINE))
}
/// Facet angle (radians) `k` steps past this level: an arc's sag goes with the square of its
/// angle, so the angle shrinks by √2 per step, keeping pace with the chord.
pub fn refined_angle(self, k: u8) -> f64 {
self.angle() / std::f64::consts::SQRT_2.powi(i32::from(k.min(MAX_REFINE)))
}
}
/// The refinement step a body should be drawn at (0: the chosen level), from `err_px`: the
/// screen-space error of the chosen level, its chord tolerance in physical pixels where the body
/// is nearest the eye (0 for a body off screen or too small to matter). `current` is the step
/// drawn now; steps halve the error. A body is refined once its error passes [`REFINE_PX`], to the
/// first step under [`TARGET_PX`], and steps back only once the error falls under [`COARSEN_PX`],
/// to the coarsest step that stays under twice that: the band between keeps an orbit or a small
/// zoom from flipping levels. Never past `max` (at most [`MAX_REFINE`]).
pub fn refinement_for(err_px: f64, current: u8, max: u8) -> u8 {
let max = max.min(MAX_REFINE);
let current = current.min(max);
if !(err_px.is_finite() && err_px > 0.0) {
return 0;
}
let err = |k: u8| err_px / f64::from(1u32 << k);
if err(current) > REFINE_PX {
(current..=max).find(|&k| err(k) <= TARGET_PX).unwrap_or(max)
} else if err(current) < COARSEN_PX {
(0..=current).find(|&k| err(k) <= 2.0 * COARSEN_PX).unwrap_or(current)
} else {
current
}
}
static CURRENT: AtomicU8 = AtomicU8::new(1);
/// The level the viewport draws at (process-wide; set from the preferences).
@@ -140,4 +191,58 @@ mod tests {
plan.vertex_count()
});
}
#[test]
fn refinement_follows_the_screen_space_error_with_hysteresis() {
// Off screen or tiny: the chosen level.
assert_eq!(refinement_for(0.0, 3, MAX_REFINE), 0);
assert_eq!(refinement_for(f64::NAN, 2, MAX_REFINE), 0);
// Fine already: stays.
assert_eq!(refinement_for(0.6, 0, MAX_REFINE), 0);
assert_eq!(refinement_for(1.0, 0, MAX_REFINE), 0);
// Past a pixel: the first step under the target (each halves the error).
assert_eq!(refinement_for(1.2, 0, MAX_REFINE), 1);
assert_eq!(refinement_for(5.0, 0, MAX_REFINE), 3);
assert_eq!(refinement_for(5.0, 1, MAX_REFINE), 3);
// Capped.
assert_eq!(refinement_for(1e6, 0, MAX_REFINE), MAX_REFINE);
assert_eq!(refinement_for(1e6, 0, 2), 2);
assert_eq!(refinement_for(1e6, 9, 99), MAX_REFINE);
// Zooming back out: kept until the error is well under a pixel, then the coarsest step
// that stays under half a pixel.
assert_eq!(refinement_for(5.0, 3, MAX_REFINE), 3);
assert_eq!(refinement_for(2.5, 3, MAX_REFINE), 3, "0.31 px at step 3: in the band");
assert_eq!(refinement_for(1.2, 3, MAX_REFINE), 2, "0.15 px at step 3: down to 0.3 px");
assert_eq!(refinement_for(0.05, 3, MAX_REFINE), 0);
// No flicker: any error, once settled, gives the same step again, and a zoom by up to
// 2x either way from where a level was chosen keeps it.
let mut e = 0.01;
while e < 1e4 {
for start in 0..=MAX_REFINE {
let k = refinement_for(e, start, MAX_REFINE);
assert_eq!(refinement_for(e, k, MAX_REFINE), k, "error {e}, from {start}");
// Settled by zooming in (from the chosen level) or out (from the finest).
if start == 0 || start == MAX_REFINE {
for f in [0.5, 0.7, 1.0, 1.4, 2.0] {
let back = refinement_for(e * f, k, MAX_REFINE);
let zoomed_in = f >= 1.0;
assert!(
back == k || (zoomed_in && back > k && start == 0) || (!zoomed_in && back < k && start == MAX_REFINE),
"error {e} x{f} from {start}: {k} -> {back}"
);
}
}
}
e *= 1.13;
}
}
#[test]
fn refined_steps_halve_the_chord() {
let q = DisplayQuality::Normal;
assert_eq!(q.refined_chord(200.0, 0), q.chord(200.0));
assert_eq!(q.refined_chord(200.0, 2), q.chord(200.0) / 4.0);
assert_eq!(q.refined_chord(200.0, 99), q.chord(200.0) / 16.0);
assert!((q.refined_angle(2) - q.angle() / 2.0).abs() < 1e-12);
}
}
+86
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel::Continuity;
use solidcraft_sketch::Sketch;
use crate::construct::{AxisDef, GeoRef, PointDef};
@@ -1254,6 +1255,10 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[walk(pos)]
edges: Vec<Vec3>,
/// How full a fitted patch's middle is (`crown`, -0.9…3, default 0; a planar loop is an
/// exact plane whatever this says).
#[serde(default, skip_serializing_if = "is_zero")]
crown: f64,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
@@ -1437,6 +1442,25 @@ pub enum FeatureKind {
#[serde(default = "yes", skip_serializing_if = "is_yes")]
neighbours: bool,
},
/// A blend surface bridging two picked edge chains of two bodies (`surface.blend`): each end
/// repeats the surface it leaves up to its requested continuity, with the fullness `bulge`.
SurfaceBlend {
from: BlendSide,
to: BlendSide,
/// How much of each support surface the blend repeats: [from, to].
#[serde(default = "blend_orders")]
continuity: [Continuity; 2],
/// How full each end leaves: [from, to]; 0 is the plain bridge.
#[serde(default)]
bulge: [f64; 2],
/// Points the blend leaves towards, when the faces at the picks do not say: [from, to].
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(pos)]
align: Option<[Vec3; 2]>,
/// Sew the blend onto its two bodies where the boundaries meet.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
stitch: bool,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
SurfaceTrim {
@@ -1475,6 +1499,11 @@ pub enum FeatureKind {
/// The file's product/assembly tree, kept for components.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solidcraft_kernel::StepNode>,
/// The file's bodies (by index) this feature does not build: later instances of a part
/// the file places more than once, shown by the other occurrences of that part's
/// component (#1386). Empty in designs from before format 22: every body is built.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
skip: Vec<u64>,
},
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
MeshConvert {
@@ -1609,6 +1638,22 @@ pub struct FaceAt {
pub point: Vec3,
}
/// One end of a blend: a body and the points that pick the edges of its boundary chain. With two
/// picks on the same edge the chain is that one edge; more pick a chain that runs on from pick to
/// pick (the order of the picks does not matter).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct BlendSide {
#[walk(body)]
pub body: String,
#[walk(pos)]
pub edges: Vec<Vec3>,
}
/// The continuity a blend repeats by default: the tangent plane on both sides.
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
@@ -1681,6 +1726,11 @@ fn is_zero_i8(v: &i8) -> bool {
*v == 0
}
/// A parameter that is left out when it is 0 (an optional shape knob).
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
/// Hole shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -1887,6 +1937,7 @@ impl FeatureKind {
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature",
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
@@ -1963,6 +2014,7 @@ impl FeatureKind {
FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve",
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
FeatureKind::SurfaceBlend { .. } => "Blend",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
@@ -2740,6 +2792,7 @@ impl Document {
}
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
used.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
used.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
}
for p in &self.params {
@@ -2938,6 +2991,39 @@ impl Document {
Ok(())
}
/// Give the texts of a sketch the numbers their expressions come to (in place): a text's
/// height, place and angle that were typed as expressions. A text's outlines are rebuilt from
/// its numbers when the sketch's links are refreshed, so this comes before that. A number
/// typed as a number is left as it is.
pub fn apply_text_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
use crate::eval::val;
for link in &mut sk.links {
let solidcraft_sketch::LinkSource::Text { at, height, angle, exprs, .. } = &mut link.source else { continue };
let number = |e: &Option<String>, kind: Kind, what: &str| -> Result<Option<f64>> {
match e {
None => Ok(None),
Some(e) => val(vals, e, kind).map(Some).map_err(|error| DocError::Expr(format!("{what}: {error}"))),
}
};
if let Some(v) = number(&exprs.x, Kind::Length, "text X")? {
at.x = v;
}
if let Some(v) = number(&exprs.y, Kind::Length, "text Y")? {
at.y = v;
}
if let Some(v) = number(&exprs.height, Kind::Length, "text height")? {
*height = v;
}
if let Some(v) = number(&exprs.angle, Kind::Angle, "text angle")? {
*angle = v;
}
if exprs.height.is_some() && !(*height > 1e-6 && *height < 1e5) {
return Err(DocError::Invalid("a text's height must be positive".into()));
}
}
Ok(())
}
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
/// jump it reaches by stepping the changed values from there, and an edit that would turn
+195 -46
View File
@@ -126,6 +126,25 @@ impl ModelBody {
.clone()
}
/// The display plan and its mesh `k` steps finer than `q` (view-dependent refinement, #1320;
/// see [`crate::display_quality::refinement_for`]). Step 0 is the cached plan at `q`; finer
/// steps are made on each call and kept by the viewport, which evicts them when it runs over
/// its budget. `None` when the body has no plan at that step.
pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc<TessPlan>, Arc<Mesh>)> {
if k == 0 {
return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?));
}
let plan = match self.body.display_plan_angular(q.refined_chord(self.body.size(), k), q.refined_angle(k)) {
Ok(plan) => plan,
Err(e) => {
log::debug!("{}: no display plan refined {k} steps ({e})", self.name);
return None;
}
};
let mesh = self.body.display_mesh_of(&plan).ok()?;
Some((Arc::new(plan), Arc::new(mesh)))
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
fn display_tol(&self) -> f64 {
(self.body.size() * 1e-3).max(1e-3)
@@ -718,11 +737,13 @@ pub struct Model {
pub no_cache: bool,
}
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
/// sketch's texts), sorted.
fn param_names(f: &Feature) -> Vec<String> {
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
names.sort();
names.dedup();
@@ -1288,7 +1309,7 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
}
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
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);
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
@@ -1694,11 +1715,62 @@ fn region_samples(r: &Region2) -> Vec<Vec2> {
out
}
/// The region with each hole filled whose inside the tool would sweep with no change to the
/// targets: all empty for a cut (the profile drawn round a hole in the part), all material for a
/// join. Then the tool's walls don't lie on the part's walls there (the boolean can't intersect
/// walls lying on each other, #1360), and what it cuts or joins is the same. A hole is filled only
/// when points across it at levels through the tool are all empty (or all material) and no
/// vertex of the targets lies inside the column over it (an island in it, a floor).
fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[String], plane: &Plane, (lo, hi): (f64, f64)) -> Region2 {
if r.holes.is_empty() || !matches!(op, Operation::Cut | Operation::Join) || hi - lo <= 0.0 {
return r.clone();
}
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
if bodies.is_empty() {
return r.clone();
}
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
let margin = (size * 1e-4).max(1e-4);
let n = plane.normal();
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let want = op == Operation::Join;
let mut holes = Vec::with_capacity(r.holes.len());
for h in &r.holes {
let column = Region2 { outer: h.ccw(), holes: vec![] };
let samples = region_samples(&column);
let swept = (1..=8).all(|k| {
let z = lo + (hi - lo) * (k as f64 - 0.5) / 8.0;
samples.iter().all(|s| inside_any(plane.to_world(*s) + n * z) == want)
});
// No boundary of the targets within the column (shrunk off its wall, short of its ends).
let clear = swept
&& meshes.iter().all(|m| {
m.positions.iter().all(|p| {
let z = (*p - plane.origin).dot(n);
let q = plane.to_local(*p);
!(z > lo + margin
&& z < hi - margin
&& column.contains(q)
&& h.segs.iter().all(|sg| sg.point_at(sg.closest_param(q)).dist(q) > margin))
})
});
if !clear {
holes.push(h.clone());
}
}
Region2 { outer: r.outer.clone(), holes }
}
/// Grow profile edges that lie on a face of a target body away from the profile, by a small
/// amount, when the space they grow into is already material (join) or empty (cut). `probe`
/// maps a sketch point to the world points where the tool would be (along the extrude, around
/// the revolve). Only loops of straight edges are adjusted.
/// the revolve). Loops of lines and arcs are adjusted: a line moves out, an arc keeps its centre
/// and changes its radius (a rounded corner of a face cut whole, #1275). Arcs move only where
/// both of their ends meet tangent neighbours that move with them; otherwise the loop is left
/// as it is.
fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec<Vec3>) -> Region2 {
use solidcraft_geom::Seg2;
if !matches!(op, Operation::Cut | Operation::Join) {
return r.clone();
}
@@ -1710,55 +1782,94 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
let delta = (size * 0.005).max(1e-3);
let lp = r.outer.ccw();
let lines: Option<Vec<(Vec2, Vec2)>> =
lp.segs.iter().map(|s| if let solidcraft_geom::Seg2::Line { a, b } = *s { Some((a, b)) } else { None }).collect();
let Some(lines) = lines else { return r.clone() };
let segs = &lp.segs;
if segs.is_empty() || !segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return r.clone();
}
// The outward normal (away from the profile) at parameter `t` of a segment of the
// counterclockwise loop: the tangent turned clockwise.
let out_at = |s: &Seg2, t: f64| -> Option<Vec2> {
match *s {
Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)),
Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()),
_ => None,
}
};
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let mut grow = vec![0.0; lines.len()];
let mut grow = vec![0.0; segs.len()];
let mut any = false;
for (i, (a, b)) in lines.iter().enumerate() {
let Some(dir) = (*b - *a).normalized() else { continue };
let out = Vec2::new(dir.y, -dir.x);
for (i, seg) in segs.iter().enumerate() {
if let Seg2::Arc { radius, sweep, .. } = *seg
&& sweep < 0.0
&& radius <= 2.0 * delta
{
// A concave arc too small to shrink by the step.
continue;
}
// Is the edge on a body face? Material on exactly one side of it.
let mids = [a.lerp(*b, 0.25), a.lerp(*b, 0.5), a.lerp(*b, 0.75)];
let side = |k: f64| mids.iter().flat_map(|m| probe(*m + out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), out_at(seg, *t)?))).collect();
if mids.len() < 3 {
continue;
}
let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let (outer, inner) = (side(0.5), side(-0.5));
let want_outer = op == Operation::Join;
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
let room = side(1.0).iter().all(|x| *x == want_outer);
if on_face && room {
if let Some(g) = grow.get_mut(i) {
*g = delta;
}
if on_face
&& room
&& let Some(g) = grow.get_mut(i)
{
*g = delta;
any = true;
}
}
if !any {
return r.clone();
}
// Offset the chosen lines and re-intersect neighbours.
let n = lines.len();
let shifted: Vec<(Vec2, Vec2)> = lines
.iter()
.zip(&grow)
.map(|((a, b), g)| {
let dir = (*b - *a).normalized().unwrap_or(Vec2::X);
let out = Vec2::new(dir.y, -dir.x);
(*a + out * *g, dir)
})
.collect();
let mut pts = Vec::with_capacity(n);
let n = segs.len();
let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0;
// Where segment `i` starts once grown: the corner of it and the one before.
let mut starts = Vec::with_capacity(n);
for i in 0..n {
let (Some(&(p0, d0)), Some(&(p1, d1))) = (shifted.get((i + n - 1) % n), shifted.get(i)) else { return r.clone() };
let (Some(prev), Some(cur), Some(&g0), Some(&g1)) = (segs.get((i + n - 1) % n), segs.get(i), grow.get((i + n - 1) % n), grow.get(i)) else {
return r.clone();
};
let corner = cur.start();
if g0 == 0.0 && g1 == 0.0 {
starts.push(corner);
continue;
}
if tangent(prev, cur) {
// Moved together, a tangent joint moves along the common normal and stays tangent.
if g0 != g1 {
return r.clone();
}
let Some(out) = out_at(cur, 0.0) else { return r.clone() };
starts.push(corner + out * g1);
continue;
}
let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return r.clone() };
let (Some(d0), Some(d1)) = ((b0 - a0).normalized(), (b1 - a1).normalized()) else { return r.clone() };
let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * g0, a1 + Vec2::new(d1.y, -d1.x) * g1);
let den = d0.cross(d1);
if den.abs() < 1e-12 {
pts.push(p1);
starts.push(p1);
continue;
}
let t = (p1 - p0).cross(d1) / den;
pts.push(p0 + d0 * t);
starts.push(p0 + d0 * t);
}
Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: r.holes.clone() }
let mut out = Vec::with_capacity(n);
for (i, seg) in segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(&g)) = (starts.get(i), starts.get((i + 1) % n), grow.get(i)) else { return r.clone() };
out.push(match *seg {
Seg2::Line { .. } => Seg2::Line { a, b },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius: radius + g * sweep.signum(), start, sweep },
_ => return r.clone(),
});
}
Region2 { outer: solidcraft_geom::Loop2 { segs: out }, holes: r.holes.clone() }
}
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
@@ -2763,6 +2874,8 @@ fn feature_tools_with_constructor(
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
// The footprint of each tool in the sketch: a tool lies in the prism over it.
let mut footprints = Vec::with_capacity(regions.len());
// Each tool's extent along the normal (they differ where one is run on into a face).
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
for r in &regions {
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
@@ -2776,6 +2889,7 @@ fn feature_tools_with_constructor(
// the sewable seam with a coincident curved intersection.
tools.extend(exact);
footprints.push(r.clone());
extents.push((lo + off, hi + off));
continue;
}
}
@@ -2783,18 +2897,31 @@ fn feature_tools_with_constructor(
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
let r = fill_void_holes(st, &r, *operation, targets, &ss.plane, (l2, h2));
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
footprints.push(r);
extents.push((l2, h2));
}
// A cut by profiles apart in the sketch (the letters of a text): each tool lies in the
// prism over its footprint, so the tools are apart too (whatever their heights, and
// trimmed or not). The body is then cut once by all of them rather than once per
// profile, each cut working on a body grown by the one before.
// (Sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.)
// A cut or a join by profiles apart in the sketch (the letters of a text): each tool
// lies in the prism over its footprint, so the tools are apart too (whatever their
// heights, and trimmed or not). The body is then cut once by all of them, or joined to
// all of them at once, rather than once per profile, each on a body grown or cut by
// the ones before. (A join meeting several bodies keeps joining them tool by tool;
// sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.)
let sheet =
if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) };
if *operation == Operation::Cut && !sheet && tools.len() > 1 && regions_apart(&footprints, APART_GAP) {
let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 };
// A join at once only of tools alike in height (one prism of them all, which the
// boolean joins exactly): tools that start at different levels (some run on into the
// face, one hanging over its edge) keep joining one by one.
let alike = extents.windows(2).all(|w| (w[0].0 - w[1].0).abs() < 1e-9 && (w[0].1 - w[1].1).abs() < 1e-9);
let at_once = match operation {
Operation::Cut => !sheet,
Operation::Join => one_target && !sheet && alike,
_ => false,
};
if at_once && tools.len() > 1 && regions_apart(&footprints, APART_GAP) {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
if let Ok(Some(one)) = kernel::join_apart(&bodies) {
tools = vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())];
@@ -3995,9 +4122,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceRevolve { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceOffset { .. }
| FeatureKind::SurfaceRuled { .. }
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
@@ -4019,6 +4147,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
@@ -4619,15 +4750,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Import { step, .. } => {
FeatureKind::Import { step, skip, .. } => {
let imp = kernel::step_import_shared(step)?;
let digest = crate::config::payload_digest(step);
// Files from board and assembly tools give thousands of bodies the same name: name
// them from a set, not by searching the bodies for every candidate (cubic).
let mut names = BodyNames::new(st);
for (k, b) in imp.bodies.iter().enumerate() {
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
let name = names.unique(base);
let names = import_body_names(st, &f.body_names, &imp, skip);
for (b, name) in imp.bodies.iter().zip(names) {
let Some(name) = name else { continue };
let b = crate::lineage::numbered_tool(b.body.clone().with_import_certificate(&digest, f.id)?);
st.bodies.push(ModelBody::traced_new(name, b, f.id));
}
@@ -4660,6 +4788,27 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
Ok(out)
}
/// The names an Import feature gives the file's bodies, added after the bodies of `st`, in the
/// file's order: `None` for a body it skips (a later instance of a repeated part). `body_names`
/// are the feature's stored names (by index), else the file's own.
pub fn import_body_names(st: &ModelState, body_names: &[String], imp: &kernel::StepImport, skip: &[u64]) -> Vec<Option<String>> {
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
// Files from board and assembly tools give thousands of bodies the same name: name them
// from a set, not by searching the bodies for every candidate (cubic).
let mut names = BodyNames::new(st);
imp.bodies
.iter()
.enumerate()
.map(|(k, b)| {
if skip.contains(&(k as u64)) {
return None;
}
let base = body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
Some(names.unique(base))
})
.collect()
}
/// [`unique_body_name`] for many bodies in a row: the same names, in time linear in the bodies.
struct BodyNames {
taken: std::collections::HashSet<String>,
+26 -3
View File
@@ -6,9 +6,9 @@ use super::*;
use crate::{RuledCorners, RuledMode};
use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
@@ -20,7 +20,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges(&b.body, edges)?);
made.push(kernel::patch_edges_crowned(&b.body, edges, *crown)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
@@ -107,6 +107,26 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
if from.edges.is_empty() || to.edges.is_empty() {
return Err(DocError::Invalid("a blend needs a chain of edges at each end".into()));
}
let (made, gap) = kernel::blend_between(&a, &from.edges, &b, &to.edges, *continuity, *bulge, *align, BLEND_STATIONS, *stitch)?;
if gap > 1e-9 {
*warning = Some(format!("the blend's boundary lies up to {gap:.4} mm from the edges it was built on"));
}
let name = new_name(st, f, 0);
// A stitched blend takes the place of both bodies it joins; otherwise it is a new
// surface body of its own.
if *stitch {
let gone = [from.body.clone(), to.body.clone()];
st.bodies.retain(|x| !gone.contains(&x.name));
}
st.bodies.push(ModelBody::new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let ss = sketch_in_feature_frame(
doc,
@@ -329,6 +349,9 @@ fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[Stri
/// Most sketch curves one feature chains.
const MAX_CHAIN_CURVES: usize = 1000;
/// Stations a blend samples each of its two boundaries at: enough that the boundary it fits is
/// within a small part of a thousandth of the chain's length of the edges it was built on.
const BLEND_STATIONS: usize = 41;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
+38 -1
View File
@@ -237,6 +237,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
let mut i = 0;
let mut out = Vec::new();
// Parentheses open at this point: a comma outside them can only be a decimal comma.
let mut depth = 0usize;
while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() {
i += 1;
@@ -245,6 +247,17 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
i += 1;
}
// A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347):
// a comma between digits, in a number with no point, outside any parentheses (inside
// them a comma separates a function's arguments: `max(1,2)`).
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
i += 1;
while digit(i) {
i += 1;
}
}
// Exponent: 1e-3 (but not `2em`).
if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') {
let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+');
@@ -257,7 +270,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
}
}
let t = s.get(at(st)..at(i)).unwrap_or_default();
let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
out.push((Tok::Num(v), at(st), at(i)));
} else if c.is_alphabetic() || c == '_' || c == 'µ' {
let st = i;
@@ -282,6 +295,11 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
out.push((Tok::Op(','), at(i), at(i + 1)));
i += 1;
} else if "+-*/^(),%".contains(c) {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ => {}
}
out.push((Tok::Op(c), at(i), at(i + 1)));
i += 1;
} else {
@@ -819,6 +837,25 @@ mod tests {
ParamDef { name: n.into(), expr: e.into(), unit: u.into() }
}
#[test]
fn a_decimal_comma_outside_parentheses() {
// #1347: a comma between digits is a decimal comma where it cannot separate arguments.
assert_eq!(ev("2,5", Kind::Unitless).unwrap(), 2.5);
assert_eq!(ev("2,5 mm", Kind::Length).unwrap(), 2.5);
assert_eq!(ev("12,75mm + 0,25", Kind::Length).unwrap(), 13.0);
assert_eq!(ev("width / 2,5", Kind::Length).unwrap(), 16.0);
assert!((ev("1,5e3 mm", Kind::Length).unwrap() - 1500.0).abs() < 1e-9);
assert!((ev("22,5 deg", Kind::Angle).unwrap() - 22.5f64.to_radians()).abs() < 1e-12);
// Inside parentheses a comma still separates a function's arguments.
assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
// Not between two digits, or a second comma: still an error.
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
}
}
#[test]
fn arithmetic_and_units() {
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
@@ -158,6 +158,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
@@ -328,6 +328,7 @@ surface_sweep_options
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft
surface_blend
surface_offset
inputs:
Distance = soff.distance (Length)
@@ -147,6 +147,8 @@ surface_revolve
surface_sweep
surface_sweep_options
surface_loft
surface_blend
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -169,6 +169,8 @@ surface_sweep_options
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
surface_blend
remap_bodies: from.body
surface_offset
remap_bodies: body
unstitch
+25 -2
View File
@@ -200,13 +200,35 @@
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
//! (0.11.0 refused it).
//! - 22: an Import (STEP) feature can list `skip`: indices of the file's bodies it does not
//! build, because they are later instances of a part the file places more than once and the
//! other occurrences of that part's component show them (#1386: an imported file becomes a
//! component, an assembly in it nested components). Version 21 designs need no
//! transformation: an Import without it builds every body, as it always did, and designs
//! whose imports sit loose in a component are left as they are. The version stamp makes
//! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again
//! on top of their occurrences.
//! Also under 22, a number in an expression can be written with a decimal comma outside
//! parentheses (`2,5 mm` is `2.5 mm`, #1347; inside a function's parentheses the comma still
//! separates its arguments). Nothing new is saved, but an expression typed that way is saved as
//! typed; 0.12.0 asks for an update instead of failing to evaluate it.
//! Also under 22, a design can hold a `SurfaceBlend` (Blend Surface: `from`/`to` edge chains of
//! two bodies, optional `continuity`, `bulge`, `align`, `stitch`), and a `Patch` can carry a
//! `crown` (0 by default, and then not saved: a bump that vanishes on the loop, so the face
//! still meets the picked edges). Version 21 designs need no transformation; 0.12.0 asks for an
//! update instead of meeting a feature it can't read or dropping a patch's crown.
//! Also under 22, a sketch text's link source can say that its place, height or angle are
//! expressions (`exprs`: `x`, `y`, `height`, `angle`, each an expression over the design's
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 21;
pub const FORMAT: u32 = 22;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -223,7 +245,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 20] = [
const JSON_STEPS: [JsonStep; 21] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -244,6 +266,7 @@ const JSON_STEPS: [JsonStep; 20] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+124 -42
View File
@@ -624,6 +624,16 @@ fn unknowns(tree: &Tree, around: &BTreeSet<u64>, asm: &Assembly, linked: &BTreeS
vars
}
/// Each value's limits, or none.
fn limit_bounds(tree: &Tree, vars: &[(u64, usize)]) -> Vec<(f64, f64)> {
vars.iter()
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
None => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect()
}
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
/// that far apart weigh turns like moves.
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
@@ -658,11 +668,72 @@ fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &m
/// keep the tree's count.
const MAX_DOF_OCCURRENCES: usize = 256;
/// How far `nearby_pose` moves each value: radians for an angle, a share of the loops' size for a
/// distance. Far enough for a part's motion that vanishes at a dead point to show in a rank, near
/// enough to stay on the mechanism's branch.
const NUDGE: f64 = 1e-3;
/// A pose of the loops near `vals`: each value they move nudged by an amount of its own within
/// its limits (so the nudge points in no special direction), then each group of loops closed
/// again. A group that can't close again, or only further away than a few nudges (on another
/// branch), keeps its values. It has a work budget of its own.
fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<(u64, usize)>, vals: &Values) -> Values {
let mut near = vals.clone();
let mut spent = 0usize;
for (closing, around, _) in groups {
let vars = unknowns(tree, around, asm, linked, &Held::new());
if vars.is_empty() || vars.len() > MAX_LOOP_VALUES {
continue;
}
let scale = loop_scale(tree, around, vals);
let step: Vec<f64> =
vars.iter().map(|(id, i)| NUDGE * if tree.joints.get(id).is_some_and(|j| j.kind.is_angle(*i)) { 1.0 } else { scale }).collect();
let bounds = limit_bounds(tree, &vars);
let x0: Vec<f64> = vars.iter().map(|(id, i)| near.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
let mut x: Vec<f64> = x0
.iter()
.zip(step.iter().zip(&bounds))
.enumerate()
.map(|(k, (v, (d, (lo, hi))))| {
// 0.5 to 1.5 nudges, alternately up and down.
let w = 0.5 + (k as f64 * 0.618_034).fract();
(v + d * if k % 2 == 0 { w } else { -w }).max(*lo).min(*hi)
})
.collect();
let mut work = near.clone();
let mut gaps = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
if spent > MAX_LOOP_WORK {
return None;
}
for ((id, i), v) in vars.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closing.len() * 9);
closure_gaps(tree, closing, &work, scale, &mut out)?;
out.iter().all(|v| v.is_finite()).then_some(out)
};
if least_squares(&mut gaps, &mut x, &bounds, (1e-10 * scale).min(1e-7))
&& x.iter().zip(&x0).zip(&step).all(|((a, b), d)| (a - b).abs() <= 4.0 * d)
&& gaps(&x).is_some()
{
near = work;
}
}
near
}
/// The degrees of freedom of the occurrences loops constrain: how many independent ways each can
/// still move with every loop kept closed, the rank of its placement over the joint values once
/// the loops' closure is taken out (rank [J_loops; J_placement] - rank J_loops). An occurrence's
/// values are those on its tree path and the unknowns of the loops on that path. The tree's
/// count stands where a loop group is over the solver's caps or the work runs out.
/// values are those on its tree path and the unknowns of the loops on that path. It is counted at
/// the pose and at a pose nearby, and the larger count stands: at a dead point of its loop (an
/// actuator in line with the path of its tip pin) a part's motion vanishes to first order, so the
/// pose alone counts 0. The tree's count stands where a loop group is over the solver's caps or
/// the work runs out.
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
if closing.len() > MAX_LOOPS {
return;
@@ -690,6 +761,8 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
}
}
let (mut spent, mut counted) = (0usize, 0usize);
// A pose nearby, when one differs.
let near = (!candidates.is_empty()).then(|| nearby_pose(tree, &groups, asm, &linked, vals)).filter(|n| n != vals);
for occ in tree.order.iter().filter(|o| candidates.contains(o)) {
let path: Vec<u64> = tree.path(*occ).1.iter().map(|(k, _)| *k).collect();
let (mut values, mut have): (Vec<(u64, usize)>, BTreeSet<(u64, usize)>) = (Vec::new(), BTreeSet::new());
@@ -751,41 +824,47 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
continue;
}
let scale = loop_scale(tree, &around, vals);
let mut work = vals.clone();
// The loops' gaps, then three points of the occurrence `scale` apart.
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
for ((id, i), v) in values.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
closure_gaps(tree, &closers, &work, scale, &mut out)?;
let t = tree.world(*occ, &work)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let q = apply_point(&t, p);
out.extend([q.x, q.y, q.z]);
}
out.iter().all(|v| v.is_finite()).then_some(out)
};
let x0: Vec<f64> = values.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
let (n, gaps) = (values.len(), closers.len() * 9);
let Some(jac) = jacobian(&mut f, &x0, gaps + 9) else { continue };
let rank = |rows: usize| {
let mut a = linalg::Mat::zeros(rows, n);
let mut big = 0.0f64;
for r in 0..rows {
for c in 0..n {
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
big = big.max(v.abs());
a.set(r, c, v);
// How many ways the occurrence moves at a pose, with the loops kept closed.
let mut free_at = |at: &Values| -> Option<usize> {
let mut work = at.clone();
// The loops' gaps, then three points of the occurrence `scale` apart.
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
for ((id, i), v) in values.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
}
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
closure_gaps(tree, &closers, &work, scale, &mut out)?;
let t = tree.world(*occ, &work)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let q = apply_point(&t, p);
out.extend([q.x, q.y, q.z]);
}
out.iter().all(|v| v.is_finite()).then_some(out)
};
let x0: Vec<f64> = values.iter().map(|(id, i)| at.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
let jac = jacobian(&mut f, &x0, gaps + 9)?;
let rank = |rows: usize| {
let mut a = linalg::Mat::zeros(rows, n);
let mut big = 0.0f64;
for r in 0..rows {
for c in 0..n {
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
big = big.max(v.abs());
a.set(r, c, v);
}
}
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
};
Some(rank(gaps + 9).saturating_sub(rank(gaps)))
};
let free = rank(gaps + 9).saturating_sub(rank(gaps));
let here = free_at(vals);
let there = near.as_ref().and_then(&mut free_at);
let Some(free) = here.max(there) else { continue };
dof.insert(*occ, free.min(6));
}
}
@@ -814,13 +893,7 @@ fn close_group(
if vars.len() > MAX_LOOP_VALUES {
return Some(BTreeMap::new());
}
let bounds: Vec<(f64, f64)> = vars
.iter()
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
None => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect();
let bounds = limit_bounds(tree, &vars);
let scale = loop_scale(tree, around, vals);
// Closer than the conflict check needs: 1e-7 mm, and turns of 1e-10 rad across the loop.
let tol = (1e-10 * scale).min(1e-7);
@@ -1054,9 +1127,18 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
let mut used: BTreeSet<u64> = BTreeSet::new();
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
// joint for every occurrence).
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
for j in joints.iter().copied() {
at.entry(j.a.occurrence).or_default().push(j);
if j.b.occurrence != j.a.occurrence {
at.entry(j.b.occurrence).or_default().push(j);
}
}
while let Some(occ) = queue.pop_front() {
let Some(t) = placed.get(&occ).copied() else { continue };
for j in &joints {
for j in at.get(&occ).map(Vec::as_slice).unwrap_or_default() {
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
continue;
}
+3 -3
View File
@@ -56,9 +56,9 @@ pub use assembly::{
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
world_state_reusing,
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance,
world_state, world_state_reusing,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solidcraft_kernel as kernel;
+14
View File
@@ -584,6 +584,11 @@ impl Document {
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
}
}
for (what, e) in sketch.text_exprs() {
if expr::references(e).iter().any(|r| r == name) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
}
}
}
}
out
@@ -662,6 +667,13 @@ impl Document {
n += 1;
}
}
for e in sketch.text_exprs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
}
}
if self.favorites.remove(old) {
@@ -717,6 +729,7 @@ impl Document {
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
}
@@ -744,6 +757,7 @@ impl Document {
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
}
if hit {
out.push(f.id);
+20 -6
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use crate::{DocError, Result};
@@ -116,6 +116,10 @@ pub struct Validation {
pub property_kinds: Vec<String>,
pub isotropic_moduli: Option<IsotropicModuli>,
pub unspecified_conditions: Vec<String>,
/// Property kind -> absent condition fields under the existing family policy.
/// Empty when reading an older report that did not include this detail.
#[serde(default)]
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
pub source_review_required: Vec<String>,
pub analysis_solver_available: bool,
pub rendering_properties_available: bool,
@@ -256,6 +260,7 @@ impl MaterialCard {
let mut kinds = BTreeSet::new();
let mut used_sources = BTreeSet::new();
let mut unspecified = Vec::new();
let mut missing_condition_fields = BTreeMap::new();
let mut isotropic_moduli = None;
for property in &self.properties {
let kind = property.data.kind();
@@ -279,12 +284,20 @@ impl MaterialCard {
if let Some(state) = &conditions.state {
text(state, 1024, "conditions.state")?;
}
if conditions.temperature_k.is_none()
|| conditions.state.is_none()
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
{
let mut missing = Vec::new();
for (absent, field) in [
(conditions.temperature_k.is_none(), "temperature_k"),
(conditions.state.is_none(), "state"),
(self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none(), "moisture_dry_basis_kg_kg"),
(self.family == Family::Polymer && conditions.strain_rate_per_s.is_none(), "strain_rate_per_s"),
] {
if absent {
missing.push(field.to_string());
}
}
if !missing.is_empty() {
unspecified.push(kind.to_string());
missing_condition_fields.insert(kind.to_string(), missing);
}
if let Some(moduli) = property.data.validate()? {
isotropic_moduli = Some(moduli);
@@ -295,6 +308,7 @@ impl MaterialCard {
property_kinds: kinds.into_iter().collect(),
isotropic_moduli,
unspecified_conditions: unspecified,
missing_condition_fields,
source_review_required: used_sources.into_iter().collect(),
analysis_solver_available: false,
rendering_properties_available: false,
+52
View File
@@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() {
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
}
#[test]
fn missing_condition_fields_follow_family_policy_without_inventing_values() {
for (family, extra) in
[("wood", Some("moisture_dry_basis_kg_kg")), ("polymer", Some("strain_rate_per_s")), ("glass", None), ("metal", None), ("other", None)]
{
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["family"] = json!(family);
let mut expected = vec!["temperature_k", "state"];
expected.extend(extra);
let result = validate_card(&value).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), expected.iter().map(|s| (*s).into()).collect())]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
// Explicit zero dry-basis moisture is known; no wet/dry conversion or default is inferred.
value["properties"][0]["conditions"] =
json!({"temperature_k":293.15,"state":"synthetic","moisture_dry_basis_kg_kg":0.,"strain_rate_per_s":1.});
assert!(validate_card(&value).unwrap().missing_condition_fields.is_empty());
for field in expected {
let mut partial = value.clone();
partial["properties"][0]["conditions"].as_object_mut().unwrap().remove(field);
let result = validate_card(&partial).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), vec![field.into()])]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
}
value["properties"] = json!([]);
let result = validate_card(&value).unwrap();
assert!(result.missing_condition_fields.is_empty() && result.unspecified_conditions.is_empty());
}
}
#[test]
fn missing_condition_fields_are_per_property_and_reports_remain_backward_readable() {
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"state":"synthetic"});
value["properties"].as_array_mut().unwrap().push(json!({"data":isotropic(12e9,0.25),"source_id":"test",
"conditions":{"state":"synthetic","moisture_dry_basis_kg_kg":1.5}}));
let before = value.clone();
let result = validate_card(&value).unwrap();
let expected = json!({"density":["moisture_dry_basis_kg_kg"],"elasticity":["temperature_k"]});
let serialized = serde_json::to_value(&result).unwrap();
assert_eq!(serialized["missing_condition_fields"], expected);
assert_eq!(result.unspecified_conditions, vec!["density", "elasticity"]);
assert_eq!(value, before);
assert_eq!(serde_json::from_value::<Validation>(serialized.clone()).unwrap(), result);
let mut old = serialized;
old.as_object_mut().unwrap().remove("missing_condition_fields");
let old: Validation = serde_json::from_value(old).unwrap();
assert!(old.missing_condition_fields.is_empty());
assert_eq!(old.unspecified_conditions, result.unspecified_conditions);
assert_eq!(old.source_review_required, result.source_review_required);
}
#[test]
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());
+1 -1
View File
@@ -255,7 +255,7 @@ fn resolve_uncached(
LinkSource::SketchAxis { name } => {
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
}
LinkSource::Text { text, at, height, angle, font, style } if style.path.is_none() => {
LinkSource::Text { text, at, height, angle, font, style, .. } if style.path.is_none() => {
solidcraft_sketch::text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, None)?
}
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
+23 -6
View File
@@ -67,13 +67,18 @@ impl Default for RenderSetup {
impl RenderSetup {
pub fn validate(&self) -> Result<()> {
let limits = RenderLimits::default();
if self.width == 0
|| self.height == 0
|| u64::from(self.width) > limits.max_dimension
|| u64::from(self.height) > limits.max_dimension
|| u64::from(self.width) * u64::from(self.height) > limits.max_pixels
let (width, height) = (u64::from(self.width), u64::from(self.height));
if width == 0 || height == 0 {
return Err(DocError::Invalid("render output must be at least 1 × 1 pixels".into()));
}
if let Some((w, h)) = limits.fit(width, height, None)
&& (w, h) != (width, height)
{
return Err(DocError::Invalid("render output exceeds the dimension/pixel limits".into()));
return Err(DocError::Invalid(format!(
"render output {width} × {height} exceeds the image limits ({} pixels per side, {} pixels in all); \
the largest image at this aspect is {w} × {h}",
limits.max_dimension, limits.max_pixels
)));
}
let camera = &self.camera;
if !camera.is_valid()
@@ -218,6 +223,18 @@ mod tests {
}
}
#[test]
fn oversized_output_names_the_limits_and_the_largest_size() {
let setup = RenderSetup { width: 4096, height: 4096, ..Default::default() };
let message = setup.validate().unwrap_err().to_string();
assert!(message.contains("4096 × 4096 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
assert!(message.contains("the largest image at this aspect is 2048 × 2048"), "{message}");
let setup = RenderSetup { width: 7680, height: 4320, ..Default::default() };
let message = setup.validate().unwrap_err().to_string();
assert!(message.contains("the largest image at this aspect is 27"), "{message}");
assert!(RenderSetup { width: 2048, height: 2048, ..Default::default() }.validate().is_ok());
}
#[test]
fn invalid_saved_intent_is_rejected_at_deserialization() {
for patch in [
+31
View File
@@ -44,6 +44,7 @@ impl Walk for ProfileSel {}
impl Walk for solidcraft_sketch::Sketch {}
impl Walk for solidcraft_kernel::StepNode {}
impl Walk for crate::document::Operation {}
impl Walk for solidcraft_kernel::Continuity {}
impl Walk for crate::document::Direction {}
impl Walk for crate::document::FilletFit {}
impl Walk for crate::document::MeshData {}
@@ -136,6 +137,36 @@ impl<T> Each<T> for Vec<T> {
}
}
impl<T, const N: usize> Each<T> for [T; N] {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self {
f(t)
}
}
}
impl<T, const N: usize> Each<T> for Option<[T; N]> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
if let Some(a) = self {
for t in a {
f(t)
}
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
if let Some(a) = self {
for t in a {
f(t)
}
}
}
}
impl<T> Each<T> for Option<Vec<T>> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self.iter().flatten() {
@@ -267,3 +267,24 @@ fn lip_swept_flush_round_a_corner_joins() {
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
}
/// #1275: an Extrude Cut of a whole face of a rounded-rectangle block with a bottom chamfer. The
/// tool's corner cylinders lay on the block's; they are grown off them now, as straight edges
/// were, and the cut takes 5 mm off the top.
#[test]
fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
let s = replay(include_str!("../../../examples/regressions/cut_whole_face_after_bottom_chamfer.json"));
let bodies = &s.model.state().bodies;
assert_eq!(bodies.len(), 1);
let b = &bodies[0];
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
let pi = std::f64::consts::PI;
let block = (40.0 * 40.0 - (4.0 - pi) * 8.0 * 8.0) * 15.0;
// The chamfer's 2 × 2 triangle swept along the loop (Pappus: its centroid is 2/3 mm in).
let chamfer = 2.0 * (4.0 * 24.0 + 2.0 * pi * (8.0 - 2.0 / 3.0));
let mesh = b.body.tessellate(1e-4).unwrap();
let volume = mesh.measure().volume;
assert!((volume - (block - chamfer)).abs() < 0.05, "{volume} expected {}", block - chamfer);
let top = mesh.bounds().max.z;
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
}
+10 -2
View File
@@ -374,9 +374,14 @@ mod tests {
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
// Each import is a component of its own (#1386), shown after the pasted copies: the first
// import's bodies stay where they were, the second's and their copies are lifted.
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
let at: Vec<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&plain).chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
let m = run(&mut s, "inspect.measure", json!({}));
let names: Vec<&str> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect();
assert_eq!(names, ["Aa (copy)", "Bb (copy)", "Cc (copy)", "Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
}
/// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The
@@ -522,6 +527,9 @@ mod tests {
assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}");
let st = s.world_state();
let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
// A spring on the wrong plate is hundreds of mm³ off. The two plates are measured apart
// (one moved 50 mm), which the boolean and the measure each round in their own way:
// a few 1e-6 mm³ on 5000.
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-8 * v("Plate"), "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
}
}
+309
View File
@@ -0,0 +1,309 @@
//! Compare Bodies: two versions of one part laid over each other. Both are measured, each
//! surface's distance to the other is sampled, and the places where only one of them has
//! material are found, measured and, on request, coloured. It works from the surfaces
//! ([`solidcraft_geom::compare`]), not from a boolean of the two bodies, so bodies that share
//! almost every face compare as well as any.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_geom::compare::{self, CompareOptions, Side};
use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
(colours the faces that hold a region, red where only a has material and green where only b has: the faces mostly beyond the tolerance, else the \
one with the largest share; an undo step) → a, b: volume_mm3, area_mm2, center_of_mass, bbox; difference: b − a; \
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3 (integrated along \
lines through the region: resolution), area_mm2, max_deviation_mm, at, center, bbox, faces_a, faces_b (face indices, as model.faces lists them); \
identical; verdict; warnings (a body whose surface does not enclose what it measures, lines left out). Works from the two surfaces, without a boolean of the bodies, so near-identical bodies compare too",
)];
/// Default colours: red for material only `a` has, green for material only `b` has.
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
fn side_name(s: Side) -> &'static str {
match s {
Side::OnlyInA => "only_in_a",
Side::OnlyInB => "only_in_b",
}
}
/// A number parameter in `range`, `default` when absent.
fn number(cmd: &str, p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && range.contains(x))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
}
}
/// The two bodies: `a` and `b`, else the two selected bodies, else the design's only two.
fn pair(s: &Session, st: &solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<(String, String)> {
let given = |k: &str| -> Result<Option<String>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) => Ok(Some(n.clone())),
Some(_) => Err(bad(cmd, format!("`{k}` must be a body name"))),
}
};
let (a, b) = match (given("a")?, given("b")?) {
(Some(a), Some(b)) => (a, b),
(None, None) => {
let selected: Vec<String> =
s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let all: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
match (selected.as_slice(), all.as_slice()) {
([a, b], _) | (_, [a, b]) => (a.clone(), b.clone()),
_ => return Err(bad(cmd, "give `a` and `b` (or select two bodies)")),
}
}
_ => return Err(bad(cmd, "give both `a` and `b`")),
};
for n in [&a, &b] {
if st.body(n).is_none() {
return Err(bad(cmd, format!("no body `{n}`")));
}
}
if a == b {
return Err(bad(cmd, "`a` and `b` must be two different bodies"));
}
Ok((a, b))
}
fn colours(cmd: &str, p: &Value) -> Result<Option<[[u8; 3]; 2]>> {
match p.get("color") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some([ONLY_IN_A, ONLY_IN_B])),
Some(Value::Object(o)) => {
let pick = |k: &str, d: [u8; 3]| match o.get(k) {
None | Some(Value::Null) => Ok(d),
Some(v) => {
super::inspect::color_param(v).ok_or_else(|| bad(cmd, format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255")))
}
};
Ok(Some([pick("only_in_a", ONLY_IN_A)?, pick("only_in_b", ONLY_IN_B)?]))
}
Some(_) => Err(bad(cmd, "`color` must be true or {only_in_a, only_in_b}")),
}
}
fn v3(v: &Value) -> Option<Vec3> {
crate::params::vec3(v)
}
/// b − a of the two bodies' measures.
fn difference(ma: &Value, mb: &Value) -> Value {
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
json!({
"volume_mm3": sub(va, vb),
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
"center_of_mass": com,
"center_of_mass_distance_mm": com.map(Vec3::len),
"bbox_min": corner("min"),
"bbox_max": corner("max"),
})
}
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
let o = m.bounds().center();
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| (a - o).dot((b - o).cross(c - o)) / 6.0).sum()
}
/// What makes the comparison less certain: a body whose surface does not enclose what it
/// measures (open, or faces that failed), lines left out of region volumes.
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c: &compare::Comparison) -> Vec<String> {
let mut out = Vec::new();
for (name, measure, mesh) in bodies {
let (Some(v), area) = (measure["volume_mm3"].as_f64(), measure["area_mm2"].as_f64().unwrap_or(0.0)) else { continue };
let enclosed = enclosed_volume(mesh);
// A mesh within `chord` of the surface encloses the volume to within chord × area.
if !((enclosed - v).abs() <= chord * area.abs() + 1e-6 * v.abs()) {
out.push(format!(
"`{name}`: its surface encloses {enclosed:.3} mm³ but the body measures {v:.3} mm³: it is open or has faces that failed, so its measures and the regions near the gap are unreliable"
));
}
}
let skipped: usize = c.regions.iter().map(|r| r.lines.skipped).sum();
if skipped > 0 {
out.push(format!("{skipped} line(s) through the regions crossed a gap in a surface and were left out of their volumes"));
}
out
}
fn deviation_json(d: &compare::Deviation) -> Value {
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
}
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.compare";
let tolerance = number(cmd, p, "tolerance", 0.01, 0.001..=10.0)?;
let spacing = number(cmd, p, "spacing", 0.5, 0.01..=1000.0)?;
let max_points = number(cmd, p, "max_points", 8e6, 1e4..=5e7)? as usize;
let max_regions = number(cmd, p, "max_regions", 50.0, 1.0..=1000.0)? as usize;
let colors = colours(cmd, p)?;
let st = s.world_state();
let (na, nb) = pair(s, &st, p, cmd)?;
let (Some(ba), Some(bb)) = (st.body(&na), st.body(&nb)) else { return Err(bad(cmd, "no such body")) };
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
// body's size), so two meshes of one surface agree well within it.
let chord = tolerance / 4.0;
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.tessellate_with(chord, true)))
.into_iter()
.zip([&na, &nb]);
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
let mesh = mesh.map_err(|e| bad(cmd, format!("`{name}`: {e}")))?;
if mesh.triangles.is_empty() {
return Err(bad(cmd, format!("`{name}` has no surface")));
}
Ok((m, mesh))
};
let ((measure_a, mesh_a), (measure_b, mesh_b)) = (next()?, next()?);
let options = CompareOptions { tolerance, spacing, max_points, max_regions, ..CompareOptions::default() };
let each = |n: usize, job: &(dyn Fn(usize) + Sync)| {
let jobs: Vec<usize> = (0..n).collect();
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
};
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
let regions: Vec<Value> = c
.regions
.iter()
.enumerate()
.map(|(i, r)| {
json!({
"index": i + 1,
"side": side_name(r.side),
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
"volume_mm3": r.volume,
"area_mm2": r.area_a + r.area_b,
"area_a_mm2": r.area_a,
"area_b_mm2": r.area_b,
"max_deviation_mm": r.max_deviation,
"at": r.at,
"center": r.center,
"bbox": {"min": r.bbox.min, "max": r.bbox.max},
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
})
})
.collect();
let total = |side: Side| {
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).collect();
// `+ 0.0`: an empty sum is -0.0.
(rs.len(), rs.iter().map(|r| r.volume).filter(|v| v.is_finite()).sum::<f64>() + 0.0)
};
let ((ca, va), (cb, vb)) = (total(Side::OnlyInA), total(Side::OnlyInB));
let worst = c.a_to_b.max.max(c.b_to_a.max);
let identical = c.regions.is_empty() && c.unlisted == 0;
let warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, &c);
let mut verdict = if identical {
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
} else {
let more = if c.unlisted > 0 { format!(" (and {} more, not measured)", c.unlisted) } else { String::new() };
format!(
"{} {} by more than {tolerance} mm: {ca} with material only in `{na}` ({va:.3} mm³), {cb} only in `{nb}` ({vb:.3} mm³){more}",
c.regions.len(),
if c.regions.len() == 1 { "region differs" } else { "regions differ" }
)
};
if !warnings.is_empty() {
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
}
let mut out = json!({
"a": measure_a,
"b": measure_b,
"difference": difference(&measure_a, &measure_b),
"tolerance_mm": tolerance,
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
"identical": identical,
"verdict": verdict,
"regions": regions,
"only_in_a": {"regions": ca, "volume_mm3": va},
"only_in_b": {"regions": cb, "volume_mm3": vb},
"unlisted_regions": c.unlisted,
"warnings": warnings,
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
});
if let Some(colors) = colors {
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
}
Ok(out)
}
/// A face is coloured for a region when at least this share of it deviates there.
const MOSTLY: f64 = 0.5;
/// The faces to colour for a region, as (body: 0 = a, 1 = b; face): those mostly beyond the
/// tolerance (a small difference on a large face would colour all of it), or, when it has none,
/// the one with the largest share.
fn faces_to_color(c: &compare::Comparison, r: &compare::Region) -> Vec<(usize, u32)> {
let share = |m: usize, &(f, beyond): &(u32, f64)| {
let all = c.face_area.get(m).and_then(|x| x.get(&f)).copied().unwrap_or(0.0);
(m, f, if all > 0.0 { beyond / all } else { 0.0 })
};
let all: Vec<(usize, u32, f64)> = r.faces_a.iter().map(|x| share(0, x)).chain(r.faces_b.iter().map(|x| share(1, x))).collect();
let mostly: Vec<(usize, u32)> = all.iter().filter(|x| x.2 >= MOSTLY).map(|x| (x.0, x.1)).collect();
if !mostly.is_empty() {
return mostly;
}
all.iter().max_by(|x, y| x.2.total_cmp(&y.2)).map(|x| vec![(x.0, x.1)]).unwrap_or_default()
}
/// Give the faces holding each region the colour of its side (a face holding several takes the
/// largest one's); returns how many face appearances were set.
fn color_regions(s: &mut Session, c: &compare::Comparison, names: [&String; 2], colors: [[u8; 3]; 2]) -> Result<usize> {
use solidcraft_doc::appearance::{FaceLook, Look};
let st = s.model.state();
// Where each face of the two bodies is, as the design file names and finds it. A body placed
// by an occurrence is coloured where it is defined, if at all.
let found: Vec<_> =
names.iter().map(|n| st.body(n).map(|b| (solidcraft_doc::naming::face_spots(b), solidcraft_doc::naming::face_names(b)))).collect();
let mut picks: Vec<(String, Vec3, Option<String>, Look)> = Vec::new();
// Smallest region first, so the largest one's colour is the one that stays.
for r in c.regions.iter().rev() {
let colour = if r.side == Side::OnlyInA { colors[0] } else { colors[1] };
let mut look = Look::custom(colour, 1.0);
look.name = if r.side == Side::OnlyInA { "Only in a".into() } else { "Only in b".into() };
for (m, f) in faces_to_color(c, r) {
let (Some(Some((spots, face_names))), Some(name)) = (found.get(m), names.get(m)) else { continue };
let Some(spot) = spots.get(f as usize).copied().flatten() else { continue };
picks.retain(|(b, pt, _, _)| !(b == *name && pt.dist(spot.point) < 1e-9));
picks.push(((*name).clone(), spot.point, face_names.get(f as usize).cloned(), look.clone()));
}
}
if picks.is_empty() {
return Ok(0);
}
let a = &mut s.doc_mut().appearances;
let mut n = 0;
for (body, point, name, look) in picks {
a.faces.retain(|f| !(f.body == body && f.point.dist(point) < 1e-9));
if a.faces.len() >= 100_000 {
return Err(bad("inspect.compare", "too many face appearances"));
}
a.faces.push(FaceLook { body, point, look, name, strict_name: false, certificate: None });
n += 1;
}
Ok(n)
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
+212
View File
@@ -0,0 +1,212 @@
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn regions(r: &Value) -> Vec<Value> {
r["regions"].as_array().cloned().unwrap_or_default()
}
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(f64::NAN)
}
fn close(got: f64, want: f64, rel: f64) -> bool {
(got - want).abs() <= rel * want.abs()
}
/// A 40 x 30 x 10 box `A` and its copy `A (2)`, in the same place.
fn two_boxes() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
s
}
#[test]
fn a_box_and_its_copy_are_identical() {
let mut s = two_boxes();
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
assert_eq!(r["identical"], true, "{r}");
assert_eq!(r["warnings"], json!([]), "{r}");
assert!(regions(&r).is_empty());
assert!(f(&r["deviation"]["max_mm"]) < 1e-9, "{}", r["deviation"]);
assert_eq!(f(&r["difference"]["volume_mm3"]), 0.0);
assert!(close(f(&r["a"]["volume_mm3"]), 12_000.0, 1e-9));
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("identical within 0.01 mm"), "{}", r["verdict"]);
// Without names: the design's only two bodies. Comparing changes nothing.
let before = s.doc.clone();
let r = run(&mut s, "inspect.compare", json!({}));
assert_eq!(r["identical"], true);
assert!(std::sync::Arc::ptr_eq(&before, &s.doc));
}
#[test]
fn a_pocket_is_material_only_the_plain_box_has() {
// b has a 10 x 8 x 0.5 pocket in its top: 40 mm³ that only a has.
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 10, "width": 8, "height": 0.5, "corner": [10, 10, 9.5], "operation": "cut", "targets": ["A (2)"]}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
let g = &rs[0];
assert_eq!(g["side"], "only_in_a");
assert_eq!(g["material_of"], "A");
assert!(close(f(&g["volume_mm3"]), 40.0, 0.01), "{g}");
assert!((f(&g["max_deviation_mm"]) - 0.5).abs() < 1e-6, "{g}");
let (lo, hi) = (&g["bbox"]["min"], &g["bbox"]["max"]);
for (k, (want_lo, want_hi)) in [(10.0, 20.0), (10.0, 18.0), (9.5, 10.0)].into_iter().enumerate() {
assert!((f(&lo[k]) - want_lo).abs() < 0.05 && (f(&hi[k]) - want_hi).abs() < 0.05, "{g}");
}
let c = &g["center"];
assert!((f(&c[0]) - 15.0).abs() < 0.02 && (f(&c[1]) - 14.0).abs() < 0.02 && (f(&c[2]) - 9.75).abs() < 1e-6, "{g}");
// The pocket's floor and walls, and a's top.
assert_eq!(g["faces_b"].as_array().map(Vec::len), Some(5), "{g}");
assert_eq!(g["faces_a"].as_array().map(Vec::len), Some(1), "{g}");
assert!(close(f(&r["difference"]["volume_mm3"]), -40.0, 1e-6), "{}", r["difference"]);
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 40.0, 0.01));
assert_eq!(r["only_in_b"]["regions"], 0);
// The other way round, the same material is only in b.
let r = run(&mut s, "inspect.compare", json!({"a": "A (2)", "b": "A"}));
assert_eq!(regions(&r).len(), 1);
assert_eq!(regions(&r)[0]["side"], "only_in_b");
assert_eq!(regions(&r)[0]["material_of"], "A");
}
#[test]
fn a_boss_is_material_only_the_box_with_it_has() {
// b has a 6 x 5 x 2 boss on its top: 60 mm³ that only b has.
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": true}));
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
assert_eq!(rs[0]["side"], "only_in_b");
assert!(close(f(&rs[0]["volume_mm3"]), 60.0, 0.01), "{}", rs[0]);
assert!((f(&rs[0]["max_deviation_mm"]) - 2.0).abs() < 1e-6);
assert!(close(f(&r["difference"]["volume_mm3"]), 60.0, 1e-6));
// Coloured green: the boss's top and four sides, all of them beyond the tolerance; not a's top,
// of which only the boss's footprint is. One undo step.
assert_eq!(r["colored_faces"], 5, "{r}");
let green = |s: &Session, body: &str| s.doc.appearances.faces.iter().filter(|x| x.body == body && x.look.color == [0x2f, 0x9e, 0x44]).count();
assert_eq!((green(&s, "A (2)"), green(&s, "A")), (5, 0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.appearances.faces.len(), 0);
// Own colours.
let r = run(&mut s, "inspect.compare", json!({"color": {"only_in_b": "#0000ff"}}));
assert_eq!(r["colored_faces"], 5);
assert_eq!(s.doc.appearances.faces.iter().filter(|x| x.look.color == [0, 0, 255]).count(), 5);
}
/// A 100 x 60 sheet-metal plate with a 20 mm flange along y = 0 and a 5 mm hole at (x, 40).
fn bracket(s: &mut Session, name: &str, x: f64) {
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} base")}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": format!("{name} base"), "body_name": name}));
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20, "body": name}));
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} hole")}));
run(s, "sketch.circle.center", json!({"center": [x, 40], "diameter": 5}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": format!("{name} hole"), "through_all": true, "operation": "cut", "targets": [name]}));
}
#[test]
fn a_hole_moved_2_mm_leaves_two_crescents() {
let mut s = Session::default();
bracket(&mut s, "Before", 50.0);
bracket(&mut s, "After", 52.0);
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After"}));
let rs = regions(&r);
assert_eq!(rs.len(), 2, "{r}");
// Each is a disk of 2.5 mm radius less its overlap with the other, centres 2 mm apart, through
// the 2.5 mm sheet: (π r² − 2 r² acos(d / 2r) + (d / 2) √(4 r² − d²)) · t.
let (rr, d, t) = (2.5f64, 2.0f64, 2.5);
let crescent = (std::f64::consts::PI * rr * rr - 2.0 * rr * rr * (d / (2.0 * rr)).acos() + d / 2.0 * (4.0 * rr * rr - d * d).sqrt()) * t;
assert!((crescent - 24.316_34).abs() < 1e-4);
for g in &rs {
assert!(close(f(&g["volume_mm3"]), crescent, 0.02), "{g}");
let c = &g["center"];
assert!((f(&c[1]) - 40.0).abs() < 0.05 && (f(&c[2]) - 1.25).abs() < 0.01, "{g}");
// Material only Before has is where After's hole is (beyond x = 51), and the reverse.
match g["side"].as_str() {
Some("only_in_a") => assert!(f(&c[0]) > 51.5, "{g}"),
Some("only_in_b") => assert!(f(&c[0]) < 50.5, "{g}"),
_ => panic!("{g}"),
}
}
assert_ne!(rs[0]["side"], rs[1]["side"]);
assert!(f(&r["difference"]["volume_mm3"]).abs() < 1e-3, "{}", r["difference"]);
// Coloured: the hole walls, not the plate's faces, of which only the crescents deviate.
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After", "color": true}));
let n = r["colored_faces"].as_u64().unwrap_or(0) as usize;
assert!((2..=8).contains(&n), "{r}");
let looks = &s.doc.appearances.faces;
assert_eq!(looks.len(), n);
for l in looks {
assert!(((l.point.x - 51.0).powi(2) + (l.point.y - 40.0).powi(2)).sqrt() < 4.0, "{:?}", l.point);
}
}
#[test]
fn a_part_and_its_step_round_trips_are_identical() {
// Curved faces too: a hole and a rounded edge.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"diameter": 8, "height": 10, "base": [20, 15, 0], "operation": "cut", "targets": ["Part"]}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 3}));
let dir = std::env::temp_dir().join(format!("solidcraft-compare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.step");
run(&mut s, "file.export", json!({"path": path, "bodies": ["Part"]}));
let mut inserted = Vec::new();
for _ in 0..2 {
let r = run(&mut s, "file.insert_step", json!({"path": path}));
inserted.push(r["created_bodies"][0].as_str().unwrap().to_string());
}
let _ = std::fs::remove_dir_all(&dir);
for (a, b) in [("Part", inserted[0].as_str()), (inserted[0].as_str(), inserted[1].as_str())] {
let r = run(&mut s, "inspect.compare", json!({"a": a, "b": b}));
assert_eq!(r["identical"], true, "{a} vs {b}: {r}");
assert!(f(&r["deviation"]["max_mm"]) < 0.005, "{a} vs {b}: {}", r["deviation"]);
assert!(f(&r["difference"]["volume_mm3"]).abs() < 0.05, "{a} vs {b}: {}", r["difference"]);
}
}
#[test]
fn bad_parameters_are_refused() {
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "C"}));
for p in [
json!({}),
json!({"a": "A"}),
json!({"a": "A", "b": "A"}),
json!({"a": "A", "b": "nope"}),
json!({"a": "A", "b": 3}),
json!({"a": "A", "b": "C", "tolerance": 0}),
json!({"a": "A", "b": "C", "tolerance": -1}),
json!({"a": "A", "b": "C", "tolerance": "a lot"}),
json!({"a": "A", "b": "C", "tolerance": 1e300}),
json!({"a": "A", "b": "C", "spacing": 0}),
json!({"a": "A", "b": "C", "max_regions": 0}),
json!({"a": "A", "b": "C", "max_points": 10}),
json!({"a": "A", "b": "C", "color": "blue"}),
json!({"a": "A", "b": "C", "color": {"only_in_a": "#12345"}}),
] {
assert!(s.execute("inspect.compare", &p).is_err(), "{p}");
}
// Two selected bodies are the pair.
s.selection = vec![crate::Sel::Body { name: "A".into() }, crate::Sel::Body { name: "C".into() }];
let r = run(&mut s, "inspect.compare", json!({}));
assert_eq!(r["a"]["name"], "A");
assert_eq!(r["b"]["name"], "C");
assert_eq!(r["only_in_a"]["regions"], 1, "{r}");
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 12_000.0 - 125.0, 0.01), "{r}");
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("1 region differs by more than 0.01 mm"), "{}", r["verdict"]);
}
+32
View File
@@ -42,6 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
@@ -251,6 +255,23 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"deleted": gone, "features": feats}))
}
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.unlock";
let id = component(s, p, "component", cmd)?;
if id == 0 {
return Err(bad(cmd, "pick a component, not the design"));
}
let gone = s.contents_joints(id);
if gone.is_empty() {
return Err(bad(cmd, "nothing holds this component's contents together"));
}
let doc = s.doc_mut();
doc.assembly.joints.retain(|j| !gone.contains(&j.id));
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
transfer::move_bodies(s, p)
}
@@ -376,6 +397,17 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
if o.grounded {
return Err(bad(cmd, format!("{} is grounded", o.name)));
}
if let Some(mut group) = s.held_by_rigid_group(id) {
// The outermost component the group holds it in moves it.
for _ in 0..64 {
match s.doc.occurrence_of(group).and_then(|o| s.held_by_rigid_group(o.id)) {
Some(up) => group = up,
None => break,
}
}
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
}
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
let angle = match p.get("angle") {
+1 -1
View File
@@ -346,7 +346,7 @@ pub(super) fn paste(
bodies.insert(body.name.clone(), new_name.clone());
let feature_name = unique(&format!("{} Base", body.name), &mut used);
let id = scratch.doc_mut().add_feature(
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new() },
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new(), skip: Vec::new() },
Some(&feature_name),
)?;
if let Some(f) = scratch.doc_mut().feature_mut(id) {
+1 -1
View File
@@ -16,7 +16,7 @@ use crate::params::{bad, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params(
CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params(
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
),
@@ -73,7 +73,7 @@ fn imported(session: &mut Session, step: String, name: &str) -> u64 {
let owner = session.active_component;
let id = session
.doc_mut()
.add_feature(FeatureKind::Import { file: "fixture.step".into(), step: step.into(), components: vec![] }, Some(name))
.add_feature(FeatureKind::Import { file: "fixture.step".into(), step: step.into(), components: vec![], skip: Vec::new() }, Some(name))
.unwrap();
let feature = session.doc_mut().feature_mut(id).unwrap();
feature.body_names = vec![name.into()];
+14
View File
@@ -447,6 +447,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other(format!("`{key}` must list existing bodies")));
}
// A cut or intersect made where the active component has no body of its own (a sketch in
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
// takes part in every body instead, which it changes only where it meets them.
let participants = if participants.is_empty()
&& !primitive
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
&& {
let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
} {
vec!["*".to_string()]
} else {
participants
};
if matches!(kind, FeatureKind::ConstructionPlane { .. })
&& let Some(n) = name
{
@@ -959,6 +959,44 @@ fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() {
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
/// A line of text cut into a block (#391): the boolean came out empty on it after two minutes
/// of retries, at 3.5 mm deep. Two walls of a letter met the block's face 2e-7 mm apart, which
/// truck read as two vertices, and the result's shell did not close. The cut works at any
/// depth now, and takes out the same section at each (the text runs off the block's end, so a
/// little less than the letters).
#[test]
fn a_line_of_text_cuts_into_a_block_at_any_depth() {
let mut removed = Vec::new();
let mut area = 0.0;
for depth in [1.0, 3.5] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-40}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":7.81}));
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
// The letters' area, counters left out (a letter's hole is its own profile too).
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves)));
area = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
removed.push((105.0 * 26.0 * 40.0 - volume) / depth);
assert_eq!(s.world_state().bodies.len(), 1);
}
let (shallow, deep) = (removed[0], removed[1]);
assert!((deep - shallow).abs() < 1e-3 * shallow, "{shallow} mm² taken out at 1 mm, {deep} mm² at 3.5 mm");
assert!(shallow > 0.85 * area && shallow < area, "{shallow} mm² taken out, the letters are {area} mm²");
}
/// A design saved before tapered sides were built through `taper_loop` (#1014), by the build
/// before it: two tapered bosses apart from each other (now joined in one boolean), a fillet on
/// a sloping side edge of one and on the top edge of the other, both saved by edge name. They
@@ -977,3 +1015,106 @@ fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() {
assert!((volume - 69454.8852092132).abs() < 1e-3, "{volume}");
assert_eq!(s.world_state().bodies.len(), 1);
}
/// #1360: a ring drawn round a hole, its inner circle on the hole's wall, cut into a chamfered
/// disc. The ring's hole lies over the part's hole all the way down the cut, so the tool takes
/// it in (a disc instead of a ring cuts the same): its wall no longer lies on the hole's wall,
/// which the boolean couldn't intersect. Over a hole whose floor the cut reaches, the ring's hole
/// stays: the material under the floor is not cut (the cut may still fail there, as before).
#[test]
fn a_ring_cut_round_a_hole_takes_in_the_hole_it_is_drawn_round() {
let ring = |hole: Value| {
let mut s = Session::default();
for (c, p) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.circle.center", json!({"center":[0,0],"radius":40})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance":10})),
("solid.hole", hole),
("solid.chamfer", json!({"edges":[[40,0,10]],"distance":1})),
("sketch.create", json!({"plane":{"face":[0,0,10]}})),
("sketch.circle.center", json!({"center":[20,0],"radius":3})),
("sketch.circle.center", json!({"center":[20,0],"radius":6})),
("sketch.finish", json!({})),
] {
run(&mut s, c, p);
}
let before = volume(&mut s);
// Profile 2: the ring between the hole's wall and the larger circle.
let r = s.execute("solid.extrude", &json!({"direction":"negative","distance":"3","operation":"cut","profiles":[2]}));
let clean = r
.as_ref()
.ok()
.and_then(|v| v["feature"].as_u64())
.and_then(|id| s.model.result(id))
.is_some_and(|f| f.boolean_diagnostics.events.is_empty());
(r.is_ok(), before - volume(&mut s), clean)
};
let taken = std::f64::consts::PI * (36.0 - 9.0) * 3.0;
let (ok, through, clean) = ring(json!({"position":[20,0,10],"diameter":6}));
assert!(ok && clean && rel(through, taken) < 1e-3, "{through} vs {taken}");
// A blind hole 1 deep: under its floor the ring's hole is material, and nothing of it goes.
let (ok, blind, _) = ring(json!({"position":[20,0,10],"diameter":6,"depth":1}));
assert!(if ok { rel(blind, taken) < 1e-3 } else { blind.abs() < 1e-6 }, "{ok} {blind} vs {taken}");
}
/// A line of text cut into a face: the letters' curves (the font's quadratics) are read
/// exactly, and the cut is decided in 2D in one boolean, at any depth and where the text runs
/// off the face. The B-rep boolean failed on many letters at once ("no result" at 3.5 mm, and
/// off the edge), else took one letter at a time on an ever larger body (tens of seconds).
#[test]
fn a_line_of_text_is_cut_in_one_exact_boolean() {
let cut = |at: [f64; 2], depth: f64| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
let before = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":at,"height":7.81}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{at:?} {depth}: {r:?}");
before - volume(&mut s)
};
// Straight walls: the material taken grows with the depth, the letters' area each mm.
let (one, deep) = (cut([3.0, 9.0], 1.0), cut([3.0, 9.0], 3.5));
assert!(rel(deep, 3.5 * one) < 1e-3, "{one} {deep}");
// Off the face's edge, the part of the text still over it.
let off = cut([-6.0, 20.0], 1.0);
assert!(off > 0.0 && off < one, "{off} {one}");
}
/// A line of text cut right through a part, and one joined on in relief, are decided in 2D in
/// one boolean too. Through, each letter's counter (inside an "e" or an "a") comes out as a
/// piece of its own. In relief the letters are joined to the part all at once: one by one, each
/// joined to a part grown by the ones before, it took minutes.
#[test]
fn a_line_of_text_cut_through_or_joined_in_relief_is_one_boolean() {
let text = |op: &str, distance: f64| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
let before = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "solid.extrude", json!({"distance":distance,"operation":op}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{op}: {r:?}");
(before, volume(&mut s), s.model.state().bodies.len())
};
let (before, through, pieces) = text("cut", -40.0);
let (_, one, _) = text("cut", -1.0);
// Through: the letters' area all the way down, with the counters left as pieces.
assert!(pieces > 1, "{pieces}");
assert!(rel(before - through, 40.0 * (before - one)) < 2e-3, "{} vs {}", before - through, 40.0 * (before - one));
// In relief 2 high: twice what a 1 mm cut takes.
let (_, relief, pieces) = text("join", 2.0);
assert_eq!(pieces, 1);
assert!(rel(relief - before, 2.0 * (before - one)) < 2e-3, "{} vs {}", relief - before, 2.0 * (before - one));
}
+51 -45
View File
@@ -3,6 +3,9 @@
#[path = "file_thumbnail.rs"]
mod thumbnail;
#[path = "file_import.rs"]
pub(crate) mod import;
use serde_json::{Value, json};
use solidcraft_doc::Document;
use solidcraft_io::Format;
@@ -17,15 +20,15 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solidcraft design, or a .step/.stp file (opens as a new design); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
.params("path: .solidcraft design, or a .step/.stp/.igs/.3mf/.stl/.obj file (opens as a new design holding the file as a component named after it, as file.insert_step makes); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?; up?: z|y (default z: the file's axes are kept; y: the file has Y up, so its bodies are turned a quarter turn about X, Y becoming Z, by a Move feature after the import); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
.params("path: .step/.stp file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
.params("path: .3mf, .stl or .obj file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence); name?"),
CommandSpec::new("file.save", "Save", save)
.icon("save")
.key("Ctrl+S")
@@ -99,65 +102,57 @@ fn read_step(path: &str, names: solidcraft_io::StepNames) -> Result<solidcraft_i
solidcraft_io::step_import_feature_named(&bytes, path, names).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// Turn bodies a quarter turn about X (a file's Y up becomes Z up) with one Move feature, the
/// same one the app's "Stand it up" offer adds, and report its id.
fn stand_up(s: &mut Session, bodies: &[String]) -> Result<u64> {
let kind = solidcraft_doc::FeatureKind::Move {
bodies: bodies.to_vec(),
translate: ["0".into(), "0".into(), "0".into()],
rotate_axis: Some(solidcraft_geom::Vec3::X),
angle: Some("90 deg".into()),
copy: false,
};
let id = s.doc_mut().add_feature(kind, None)?;
s.refresh();
match s.model.result(id).and_then(|r| r.error.clone()) {
Some(e) => Err(EngineError::Other(e)),
None => Ok(id),
}
/// A quarter turn about X through the origin: a file's Y up becomes Z up.
fn quarter_turn_x() -> solidcraft_doc::Mat {
solidcraft_doc::rigid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::X, std::f64::consts::FRAC_PI_2)
}
/// Add an Import feature for a STEP file to the session's design.
/// Add an Import feature for a STEP file to the session's design, in a new component named
/// after the file (#1386).
fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptions) -> Result<Value> {
let sf = read_step(path, options.names)?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&sf.name).to_string();
let id = s.doc_mut().add_feature(sf.kind, Some(&fname))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.body_names = sf.body_names;
}
s.active_sketch = None;
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let created: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
let turned = if options.y_up && !created.is_empty() { Some(stand_up(s, &created)?) } else { None };
let cname = import::component_name(path, name);
let (id, placed) = import::add_in_component(s, sf.kind, &fname, sf.body_names, &cname, Some(&sf.import))?;
// Y up: the import's component is turned a quarter turn about X (Y becomes Z), so an
// assembly's parts and instances turn together.
let turned = if options.y_up {
let occ = placed.occurrence.ok_or_else(|| EngineError::Other("the import has no placement to turn".into()))?;
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = solidcraft_doc::mat_mul(&quarter_turn_x(), &o.transform);
}
s.refresh();
Some(occ)
} else {
None
};
let created = created_bodies(s, id);
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings});
placed.report(&mut out);
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
Ok(out)
}
/// Add a MeshImport feature for a 3MF or STL file.
/// The bodies an import feature built, by name.
fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
s.model.state().bodies.iter().filter(|b| b.feature == feature).map(|b| b.name.clone()).collect()
}
/// Add a MeshImport feature for a 3MF, STL or OBJ file, in a new component named after the file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solidcraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.body_names = mf.body_names;
}
s.active_sketch = None;
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
let cname = import::component_name(path, name);
let (id, placed) = import::add_in_component(s, mf.kind, &fname, mf.body_names, &cname, None)?;
let bodies = created_bodies(s, id);
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings}))
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
Ok(out)
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
@@ -193,10 +188,21 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
} else {
add_mesh(&mut fresh, path, None)?
};
// The design is the file: its component is the one being worked in.
if let Some(c) = r["component"].as_u64() {
fresh.active_component = c;
fresh.active_occurrence_path = Some(fresh.doc.first_path(c));
}
fresh.undo.clear();
s.replace_design(fresh);
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
let mut out = json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]});
for k in ["component", "component_name", "occurrence", "subcomponents", "rigid_joints", "stand_up"] {
if let Some(v) = r.get(k) {
out[k] = v.clone();
}
}
return Ok(out);
}
let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if let Some(stem) = default_name_for(&doc.name, path) {
+380
View File
@@ -0,0 +1,380 @@
//! Where an imported file's bodies go (#1386). Every import (STEP, IGES, 3MF, STL, OBJ) becomes a
//! new component named after the file, placed in the active component at the file's origin, and
//! its feature belongs to that component. A STEP assembly keeps its structure as components
//! nested under it, named after the file's products; a part (or sub-assembly) the file places
//! more than once becomes one component with an occurrence per placement, its later instances
//! not built again by the Import feature (`skip`). The contents of an imported assembly are held
//! together by rigid joints, so the import moves as one; `component.unlock` releases them.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use serde_json::{Value, json};
use solidcraft_doc::joints::{Joint, JointKind, JointOrigin, Snap};
use solidcraft_doc::kernel::{StepImport, StepNode};
use solidcraft_doc::{FeatureKind, IDENTITY, Mat};
use solidcraft_geom::Vec3;
use crate::{EngineError, Result, Session};
/// Most components one import makes below its own (the rest of the file stays in the parent).
const MAX_COMPONENTS: usize = 5_000;
/// Deepest nesting followed (below the document's placement depth limit).
const MAX_DEPTH: usize = 32;
/// A component the import makes below its own.
struct Slot {
name: String,
}
/// A placement of a slot in another.
struct Place {
slot: usize,
parent: Option<usize>,
transform: Mat,
}
/// How a STEP file's tree becomes components.
#[derive(Default)]
struct Plan {
slots: Vec<Slot>,
places: Vec<Place>,
/// Body index → the slot it goes to, and the transform from that slot's frame to the import
/// component's (where the Import feature builds it).
bodies: Vec<(usize, usize, Mat)>,
/// Bodies not built: later instances.
skip: Vec<u64>,
}
/// A placement STEP assemblies can carry over as an occurrence: finite, rigid, not mirrored.
fn rigid(m: &Mat) -> bool {
crate::cmd::component::valid_position(m)
}
impl Plan {
fn of(imp: &StepImport) -> Plan {
let mut plan = Plan::default();
// One root product is the import's own component; several are components under it.
let nodes: Vec<&StepNode> = match imp.tree.as_slice() {
[root] => root.children.iter().collect(),
roots => roots.iter().collect(),
};
let mut seen: HashMap<u64, usize> = HashMap::new();
for n in nodes {
plan.walk(imp, n, None, &IDENTITY, &mut seen, 0);
}
plan.skip.sort_unstable();
plan.skip.dedup();
plan
}
fn walk(&mut self, imp: &StepImport, node: &StepNode, parent: Option<usize>, parent_world: &Mat, seen: &mut HashMap<u64, usize>, depth: usize) {
if !has_bodies(node, 0) {
return;
}
let world = solidcraft_doc::mat_mul(parent_world, &node.transform);
let inverse = solidcraft_doc::mat_inverse(&world);
// Too deep, too many, or placed in a way an occurrence can't be: the bodies stay where
// the parent is (they are built in place, so nothing moves).
let (Some(inverse), true, true, true) = (inverse, depth < MAX_DEPTH, self.slots.len() < MAX_COMPONENTS, subtree_rigid(node, depth)) else {
self.flatten(node, parent, parent_world);
return;
};
let key = node_key(imp, node, 0);
if let Some(&slot) = seen.get(&key) {
// Another placement of a part already made: an occurrence of its component, and the
// file's copy of its bodies is not built.
self.places.push(Place { slot, parent, transform: node.transform });
collect_bodies(node, &mut self.skip, 0);
return;
}
let slot = self.slots.len();
self.slots.push(Slot { name: node.name.trim().to_string() });
seen.insert(key, slot);
self.places.push(Place { slot, parent, transform: node.transform });
for b in &node.bodies {
self.bodies.push((*b, slot, inverse));
}
for c in &node.children {
self.walk(imp, c, Some(slot), &world, seen, depth + 1);
}
}
/// Every body below `node` goes to `parent`.
fn flatten(&mut self, node: &StepNode, parent: Option<usize>, parent_world: &Mat) {
let Some(parent) = parent else { return };
let Some(inverse) = solidcraft_doc::mat_inverse(parent_world) else { return };
let mut all = Vec::new();
collect_bodies(node, &mut all, 0);
self.bodies.extend(all.into_iter().map(|b| (b as usize, parent, inverse)));
}
}
fn has_bodies(node: &StepNode, depth: usize) -> bool {
!node.bodies.is_empty() || (depth < 64 && node.children.iter().any(|c| has_bodies(c, depth + 1)))
}
fn subtree_rigid(node: &StepNode, depth: usize) -> bool {
rigid(&node.transform) && (depth >= 64 || node.children.iter().all(|c| subtree_rigid(c, depth + 1)))
}
fn collect_bodies(node: &StepNode, out: &mut Vec<u64>, depth: usize) {
out.extend(node.bodies.iter().map(|b| *b as u64));
if depth < 64 {
for c in &node.children {
collect_bodies(c, out, depth + 1);
}
}
}
/// What makes two placements the same part: the product's name, the file items its bodies come
/// from and, below it, the same parts placed the same way.
fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
node.name.hash(&mut h);
for b in &node.bodies {
imp.bodies.get(*b).map(|b| b.source_item).hash(&mut h);
}
if depth < 64 {
for c in &node.children {
node_key(imp, c, depth + 1).hash(&mut h);
for v in c.transform.iter().flatten() {
// Placements equal to within rounding are the same placement.
((v * 1e6).round() as i64).hash(&mut h);
}
}
}
h.finish()
}
/// The import's component, as the command reports it.
pub(crate) struct Placed {
pub component: u64,
pub occurrence: Option<u64>,
pub name: String,
/// Components made below it (an assembly's parts and sub-assemblies).
pub components: usize,
/// Rigid joints holding its contents together.
pub joints: Vec<u64>,
}
impl Placed {
pub fn report(&self, out: &mut Value) {
out["component"] = json!(self.component);
out["component_name"] = json!(self.name);
out["occurrence"] = json!(self.occurrence);
if self.components > 0 {
out["subcomponents"] = json!(self.components);
out["rigid_joints"] = json!(self.joints.len());
}
}
}
/// The component an import makes: named `name`, in the active component at its origin.
fn new_component(s: &mut Session, name: &str) -> Result<(u64, Option<u64>)> {
let parent = s.active_component;
let id = s.doc_mut().add_component(Some(name), parent)?;
Ok((id, s.doc.occurrence_of(id).map(|o| o.id)))
}
/// The name of an imported file's component: `name` if given, else the file's name without its
/// extension (`bracket.step` → "bracket").
pub(crate) fn component_name(path: &str, name: Option<&str>) -> String {
if let Some(n) = name.map(str::trim).filter(|n| !n.is_empty()) {
return n.chars().take(128).collect();
}
std::path::Path::new(path)
.file_stem()
.map(|s| s.to_string_lossy().trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(128).collect())
.unwrap_or_else(|| "Import".into())
}
/// Add an import feature `kind` (named `feature_name`, with the file's `body_names`) inside a
/// new component named `name`, and for a STEP assembly (`imp`) the components of its tree.
/// Evaluates the design. Returns the feature and the component.
pub(crate) fn add_in_component(
s: &mut Session,
mut kind: FeatureKind,
feature_name: &str,
body_names: Vec<String>,
name: &str,
imp: Option<&StepImport>,
) -> Result<(u64, Placed)> {
let (top, top_occ) = new_component(s, name)?;
let plan = imp.map(Plan::of).unwrap_or_default();
// The components, each made at its first placement; the others are further occurrences.
let mut comps: Vec<u64> = Vec::with_capacity(plan.slots.len());
let mut first_occ: Vec<Option<u64>> = Vec::with_capacity(plan.slots.len());
let mut joints: Vec<(u64, u64, Mat)> = Vec::new();
for place in &plan.places {
let parent_comp = place.parent.and_then(|p| comps.get(p).copied()).unwrap_or(top);
let parent_occ = match place.parent {
Some(p) => first_occ.get(p).copied().flatten(),
None => top_occ,
};
let occ = if let Some(&comp) = comps.get(place.slot) {
s.doc_mut().add_occurrence(comp, parent_comp, place.transform)?
} else {
let slot = plan.slots.get(place.slot).ok_or_else(|| EngineError::Other("import: component plan".into()))?;
let label = if slot.name.is_empty() { None } else { Some(slot.name.as_str()) };
let comp = s.doc_mut().add_component(label, parent_comp)?;
comps.push(comp);
let occ = s.doc.occurrence_of(comp).map(|o| o.id).ok_or_else(|| EngineError::Other("import: occurrence".into()))?;
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = place.transform;
}
first_occ.push(Some(occ));
occ
};
if let Some(a) = parent_occ {
joints.push((a, occ, place.transform));
}
}
if let FeatureKind::Import { skip, .. } = &mut kind {
skip.clone_from(&plan.skip);
}
// The bodies' names, as the feature will give them (checked once it is built).
let names = match (&kind, imp) {
(FeatureKind::Import { skip, .. }, Some(imp)) => solidcraft_doc::import_body_names(&s.model.state(), &body_names, imp, skip),
_ => Vec::new(),
};
let id = s.doc_mut().add_feature(kind, Some(feature_name))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.component = top;
f.body_names = body_names;
}
let moves: Vec<(usize, u64, Mat)> = plan.bodies.iter().filter_map(|(b, slot, offset)| comps.get(*slot).map(|c| (*b, *c, *offset))).collect();
put_bodies(s, &names, &moves);
let joint_ids = add_rigid_joints(s, &joints)?;
s.active_sketch = None;
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
// The names as built (they differ from the forecast only when the timeline was not up to
// date): the bodies go by those.
if !moves.is_empty() {
let built: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
let forecast: Vec<String> = names.iter().flatten().cloned().collect();
if built != forecast {
let mut it = built.into_iter();
let actual: Vec<Option<String>> = names.iter().map(|n| n.as_ref().and_then(|_| it.next())).collect();
for n in forecast {
s.doc_mut().body_components.remove(&n);
s.doc_mut().body_offsets.remove(&n);
}
put_bodies(s, &actual, &moves);
s.refresh();
}
}
let name = s.doc.components.iter().find(|c| c.id == top).map(|c| c.name.clone()).unwrap_or_default();
Ok((id, Placed { component: top, occurrence: top_occ, name, components: comps.len(), joints: joint_ids }))
}
/// Record that body `names[b]` lives in component `c`, placed by `offset`.
fn put_bodies(s: &mut Session, names: &[Option<String>], moves: &[(usize, u64, Mat)]) {
if moves.is_empty() {
return;
}
let d = s.doc_mut();
for (b, comp, offset) in moves {
let Some(Some(name)) = names.get(*b) else { continue };
d.body_components.insert(name.clone(), *comp);
if !solidcraft_doc::is_identity(offset) {
d.body_offsets.insert(name.clone(), *offset);
}
}
}
/// Rigid joints holding each placement where it is in its parent (`(a, b, transform of b in
/// a)`), shown in the browser's joints but not drawn in the view. Returns their ids.
fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u64>> {
if pairs.is_empty() {
return Ok(Vec::new());
}
if s.doc.assembly.joints.len() + pairs.len() > solidcraft_doc::joints::MAX_JOINTS {
// Too many to hold together: the parts are placed, not locked.
return Ok(Vec::new());
}
let mut id = s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0);
let mut ids = Vec::with_capacity(pairs.len());
let point = |m: &Mat, p: Vec3| solidcraft_doc::apply_point(m, p);
let vector = |m: &Mat, v: Vec3| solidcraft_doc::apply_vector(m, v);
let made: Vec<Joint> = pairs
.iter()
.map(|(a, b, m)| {
id += 1;
ids.push(id);
// As built: B's origin, seen from A (B's placement in A) and from B itself.
Joint {
id,
name: format!("Rigid Group {a}:{b}"),
kind: JointKind::Rigid,
a: JointOrigin {
occurrence: *a,
snap: Snap::Frame { origin: point(m, Vec3::ZERO), z: vector(m, Vec3::Z), x: Some(vector(m, Vec3::X)) },
},
b: JointOrigin { occurrence: *b, snap: Snap::Frame { origin: Vec3::ZERO, z: Vec3::Z, x: Some(Vec3::X) } },
values: Vec::new(),
limits: Vec::new(),
flip: true,
offset: 0.0,
angle: 0.0,
suppressed: false,
}
})
.collect();
s.doc_mut().assembly.joints.extend(made);
// Dozens of coinciding glyphs say nothing: the group shows in the browser instead.
s.visibility.hidden_joints.retain(|h| !ids.contains(h));
s.visibility.hidden_joints.extend(ids.iter().copied());
Ok(ids)
}
impl Session {
/// The component whose contents hold occurrence `occ` where it is: a rigid joint ties it to
/// a placement of the component it sits in (an imported assembly's part). `None` when it is
/// free to move on its own.
pub fn held_by_rigid_group(&self, occ: u64) -> Option<u64> {
let parent = self.doc.occurrences.iter().find(|o| o.id == occ)?.parent;
if parent == 0 {
return None;
}
let of_parent = |o: u64| self.doc.occurrences.iter().any(|x| x.id == o && x.component == parent);
self.doc
.assembly
.joints
.iter()
.any(|j| {
j.kind == JointKind::Rigid
&& !j.suppressed
&& ((j.b.occurrence == occ && of_parent(j.a.occurrence)) || (j.a.occurrence == occ && of_parent(j.b.occurrence)))
})
.then_some(parent)
}
/// The rigid joints that hold `component`'s contents together (an imported assembly's rigid
/// group): both ends at its own occurrences or at occurrences inside it. `component.unlock`
/// removes them.
pub fn contents_joints(&self, component: u64) -> Vec<u64> {
if component == 0 {
return Vec::new();
}
let inside = |occ: u64| {
self.doc
.occurrences
.iter()
.find(|o| o.id == occ)
.is_some_and(|o| o.component == component || self.doc.component_within(o.parent, component))
};
self.doc
.assembly
.joints
.iter()
.filter(|j| j.kind == JointKind::Rigid && inside(j.a.occurrence) && inside(j.b.occurrence))
.map(|j| j.id)
.collect()
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("measure")
.key("I")
.noundo()
.params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume, minimum distance (from, to) and angle"),
.params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
+58 -5
View File
@@ -39,7 +39,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.params("a, b: joints; ratio (b = ratio · a + offset); offset?; ia?, ib?: value indices (default 0)"),
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
@@ -314,7 +314,7 @@ fn next_joint_id(s: &Session) -> u64 {
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
}
fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
if s.doc.assembly.joints.len() >= 10_000 {
return Err(EngineError::Other("too many joints".into()));
}
@@ -327,6 +327,30 @@ fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
(component.from_bodies)",
));
}
// It starts at its values inside its limits. A value moved to a limit moves the part as it
// is joined, which the answer says.
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
joints::clamp(&j, &mut q);
let round = |v: &Value| v.as_f64().map(|x| (x * 1e6).round() / 1e6).unwrap_or(f64::NAN);
let warnings: Vec<String> = named_values(&j, &q)
.iter()
.zip(named_values(&j, &j.values))
.filter(|(now, was)| now["value"] != was["value"])
.map(|(now, was)| {
let unit = now["unit"].as_str().unwrap_or("");
format!(
"{} {} starts at {} {unit}, inside its limits [{}, {}], not at {} {unit}: the part moved there as it was joined",
j.name,
now["name"].as_str().unwrap_or(""),
round(&now["value"]),
round(&now["limits"][0]),
round(&now["limits"][1]),
round(&was["value"])
)
})
.collect();
j.values = q;
let id = j.id;
let name = j.name.clone();
// A new joint can take a deleted one's id; it starts shown.
@@ -336,6 +360,9 @@ fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["name"] = json!(name);
if !warnings.is_empty() {
out["warnings"] = json!(warnings);
}
Ok(out)
}
@@ -493,12 +520,28 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
if ia >= dofs(a) || ib >= dofs(b) {
return Err(bad(cmd, "the linked joints need moving values at those indices"));
}
let offset = num(p, "offset").unwrap_or(0.0);
// In b's units, like its values and limits (stored in radians for an angle).
let offset = match p.get("offset") {
Some(v) => value(s, v, is_angle(s, b, ib), cmd)?,
None => 0.0,
};
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset });
let sol = resolve(s);
Ok(sol_json(&sol, p))
}
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
s.doc.assembly.joints.iter().find(|j| j.id == joint).is_some_and(|j| j.kind.is_angle(i))
}
/// A link's `ratio` is in b's units per a's (mm per degree, degrees per mm) and stored per
/// radian: the given one times this is the stored one.
fn ratio_scale(s: &Session, a: u64, ia: usize, b: u64, ib: usize) -> f64 {
let unit = |angle: bool| if angle { 1f64.to_radians() } else { 1.0 };
unit(is_angle(s, b, ib)) / unit(is_angle(s, a, ia))
}
fn limits(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.limits";
let id = joint_id(s, p.get("joint"), cmd)?;
@@ -614,8 +657,18 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)})
})
.collect();
let links: Vec<Value> =
s.doc.assembly.links.iter().map(|l| json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": l.ratio, "offset": l.offset})).collect();
let links: Vec<Value> = s
.doc
.assembly
.links
.iter()
.map(|l| {
let angle = is_angle(s, l.b, l.ib);
let offset = if angle { l.offset.to_degrees() } else { l.offset };
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }})
})
.collect();
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
let mut out = sol_json(&sol, p);
out["joints"] = json!(js);
+124
View File
@@ -808,6 +808,32 @@ fn loops_report_their_mobility() {
}
}
/// An actuator in line with the path of its tip pin, as modelled: a panel hinged at the origin,
/// its tip pin at (30, 0) moving along Y, and a barrel and rod along Y from a root pin at
/// (30, -40). There the rod only extends and the barrel's turn is zero to first order, but the
/// barrel turns as soon as the panel does: it has one degree of freedom, at that pose too.
#[test]
fn a_part_at_a_dead_point_of_its_loop_keeps_its_mobility() {
let mut s = Session::default();
let (tip, root) = (Vec3::new(30.0, 0.0, 0.0), Vec3::new(30.0, -40.0, 0.0));
for (name, c) in [("Ground", Vec3::new(0.0, -40.0, 0.0)), ("Panel", tip), ("Barrel", root), ("Rod", (tip + root) * 0.5)] {
part(&mut s, name, [c.x, c.y, -5.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Hinge", "Ground", "Panel", Vec3::ZERO, Vec3::Z);
pin(&mut s, "revolute", "Root pin", "Ground", "Barrel", root, Vec3::Z);
pin(&mut s, "slider", "Stroke", "Barrel", "Rod", root, Vec3::Y);
pin(&mut s, "revolute", "Tip pin", "Rod", "Panel", tip, Vec3::Z);
for part in ["Panel", "Barrel", "Rod"] {
assert_eq!(dof_of(&mut s, part), 1, "dead point: {part}");
}
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 5}));
for part in ["Panel", "Barrel", "Rod"] {
assert_eq!(dof_of(&mut s, part), 1, "driven: {part}");
}
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-joint-visibility-{tag}-{}", std::process::id()));
@@ -1054,3 +1080,101 @@ fn a_single_value_keeps_the_joints_other_values() {
let r = run(&mut s, "joint.drive", json!({"joint": "Spindle", "values": [60]}));
assert!((at(&r)[0] - 60.0).abs() < 1e-9 && (at(&r)[1] - 5.0).abs() < 1e-9, "{r}");
}
/// A motion link's offset is in the linked value's units, like joint values and limits: degrees
/// for an angle, mm for a distance, or an expression. `joint.list` answers it the same way, it is
/// stored in radians as before, and a saved link opens with the offset it had.
#[test]
fn a_link_offset_is_in_the_linked_values_units() {
let mut s = Session::default();
for (name, x) in [("Ground", 0.0), ("Dial", 80.0), ("Gear", 100.0), ("Car", 120.0), ("Cart", 140.0)] {
part(&mut s, name, [x, 0.0, -5.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
for (kind, name, x) in [("revolute", "Dial", 80.0), ("revolute", "Gear", 100.0), ("slider", "Car", 120.0), ("slider", "Cart", 140.0)] {
pin(&mut s, kind, name, "Ground", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
run(&mut s, "joint.motion_link", json!({"a": "Dial", "b": "Gear", "ratio": 1, "offset": 10}));
run(&mut s, "joint.motion_link", json!({"a": "Car", "b": "Cart", "ratio": 1, "offset": "5 mm"}));
run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 20}));
run(&mut s, "joint.drive", json!({"joint": "Car", "value": 3}));
let value = |l: &Value, name: &str| {
l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == name).and_then(|j| j["values"][0]["value"].as_f64()).unwrap_or(f64::NAN)
};
let l = run(&mut s, "joint.list", json!({}));
assert!((value(&l, "Gear") - 30.0).abs() < 1e-9 && (value(&l, "Cart") - 8.0).abs() < 1e-9, "{l}");
let links: Vec<(f64, &str)> =
l["links"].as_array().into_iter().flatten().map(|k| (k["offset"].as_f64().unwrap_or(f64::NAN), k["unit"].as_str().unwrap_or(""))).collect();
assert!(
links.len() == 2 && (links[0].0 - 10.0).abs() < 1e-9 && links[0].1 == "deg" && (links[1].0 - 5.0).abs() < 1e-9 && links[1].1 == "mm",
"{l}"
);
assert!((s.doc.assembly.links[0].offset - 10f64.to_radians()).abs() < 1e-12);
// A design saved before offsets took degrees stored 0.1 rad, and opens turning Gear 5.73° past Dial.
s.doc_mut().assembly.links[0].offset = 0.1;
let (dir, path) = temp_design("link-offset", "solidcraft");
run(&mut s, "file.save", json!({"path": path}));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
let l = run(&mut s, "joint.list", json!({}));
assert!((value(&l, "Gear") - value(&l, "Dial") - 0.1f64.to_degrees()).abs() < 1e-9, "{l}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A link between an angle and a distance takes its ratio in b's units per a's, like its offset:
/// a pinion moves its rack 0.5 mm per degree, and a car turns its dial 2 degrees per mm.
/// `joint.list` answers the ratios the same way; they are stored per radian, as before.
#[test]
fn a_link_ratio_between_an_angle_and_a_distance_is_per_degree() {
let mut s = Session::default();
for (name, x) in [("Ground", 0.0), ("Pinion", 80.0), ("Rack", 100.0), ("Car", 120.0), ("Dial", 140.0)] {
part(&mut s, name, [x, 0.0, -5.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
for (kind, name, x) in [("revolute", "Pinion", 80.0), ("slider", "Rack", 100.0), ("slider", "Car", 120.0), ("revolute", "Dial", 140.0)] {
pin(&mut s, kind, name, "Ground", name, Vec3::new(x, 0.0, 0.0), Vec3::Z);
}
run(&mut s, "joint.motion_link", json!({"a": "Pinion", "b": "Rack", "ratio": 0.5}));
run(&mut s, "joint.motion_link", json!({"a": "Car", "b": "Dial", "ratio": 2}));
run(&mut s, "joint.drive", json!({"joint": "Pinion", "value": 20}));
run(&mut s, "joint.drive", json!({"joint": "Car", "value": 3}));
let l = run(&mut s, "joint.list", json!({}));
let value = |name: &str| {
l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == name).and_then(|j| j["values"][0]["value"].as_f64()).unwrap_or(f64::NAN)
};
assert!((value("Rack") - 10.0).abs() < 1e-9 && (value("Dial") - 6.0).abs() < 1e-9, "{l}");
let ratios: Vec<f64> = l["links"].as_array().into_iter().flatten().map(|k| k["ratio"].as_f64().unwrap_or(f64::NAN)).collect();
assert!(ratios.len() == 2 && (ratios[0] - 0.5).abs() < 1e-9 && (ratios[1] - 2.0).abs() < 1e-9, "{l}");
let stored: Vec<f64> = s.doc.assembly.links.iter().map(|k| k.ratio).collect();
assert!((stored[0] - 0.5f64 / 1f64.to_radians()).abs() < 1e-9 && (stored[1] - 2f64.to_radians()).abs() < 1e-12, "{stored:?}");
}
/// A new joint starts at its values inside its limits. Limits that leave out 0 (or the values
/// given) start it at the nearer limit, which it keeps, and the answer says the part moved there.
#[test]
fn a_new_joint_starts_inside_its_limits_and_says_so() {
let mut s = two_cubes();
let r = run(&mut s, "joint.as_built", json!({"type": "slider", "a": "A", "b": "B", "name": "Rail", "limits": [[2, 5]]}));
let w = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(
w.len() == 1 && w[0].as_str().is_some_and(|w| w.starts_with("Rail distance starts at 2 mm, inside its limits [2, 5], not at 0 mm")),
"{r}"
);
assert!(near(place(&s, "B", Vec3::new(20.0, 0.0, 0.0)), Vec3::new(20.0, 0.0, 2.0)), "{:?}", place(&s, "B", Vec3::new(20.0, 0.0, 0.0)));
assert_eq!(s.doc.assembly.joints[0].values, [2.0]);
// Values given outside the limits start at the nearer one too; angles are answered in degrees.
let mut s = two_cubes();
let r = run(
&mut s,
"joint.create",
json!({"type": "revolute", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "values": [90], "limits": [[0, 60]], "name": "Hinge"}),
);
let w = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("starts at 60 deg") && w.contains("not at 90 deg")), "{r}");
// Inside the limits, nothing to say.
let mut s = two_cubes();
let r = run(&mut s, "joint.as_built", json!({"type": "cylindrical", "a": "A", "b": "B", "name": "Spindle", "limits": [[-30, 30], [0, 5]]}));
assert!(r.get("warnings").is_none(), "{r}");
}
@@ -57,6 +57,7 @@ mod tests {
let response = s.execute("material.properties.directional", &json!({"card":card,"direction":[1,2,3]})).unwrap();
assert_eq!(response["young_modulus_pa"], 12e9);
assert_eq!(response["validation"]["source_review_required"], json!(["test"]));
assert_eq!(response["validation"]["missing_condition_fields"], json!({"elasticity":["temperature_k","state"]}));
for direction in [json!([0, 0, 0]), json!([1, 2]), json!([1, 2, 3, 4]), json!([true, 0, 0]), json!(["1", 0, 0]), Value::Null] {
assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":direction})).is_err());
}
@@ -76,6 +77,7 @@ mod tests {
assert_eq!(result["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9}));
assert_eq!(result["validation"]["source_review_required"], json!(["fixture"]));
assert_eq!(result["validation"]["rendering_properties_available"], false);
assert_eq!(result["validation"]["missing_condition_fields"], json!({"elasticity":["temperature_k","state"]}));
card["properties"][0]["data"]["model"]["poisson_ratio"] = json!(0.6);
assert!(s.execute("material.properties.validate", &json!({"card":card})).is_err());
}
@@ -91,6 +93,7 @@ mod tests {
let card = json!({"schema_version":1,"designation":"Synthetic empty card","family":"other","sources":[],"properties":[]});
let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap();
assert_eq!(result["validation"]["analysis_solver_available"], false);
assert_eq!(result["validation"]["missing_condition_fields"], json!({}));
for p in [json!({}), json!({"card":card,"assign":true}), json!({"card":{"schema_version":9}})] {
assert!(s.execute("material.properties.validate", &p).is_err());
}
+349 -26
View File
@@ -1,10 +1,13 @@
//! Measuring picked geometry (`inspect.measure` with `items`): one item gives its length, area,
//! volume or position; two give the minimum distance between them (with the two closest
//! points) and, where both have a direction (planar faces, straight edges), the angle.
//! volume or position, and a round one (circle, arc, cylinder) its diameter and radius, a slot
//! its sizes; two give the minimum and maximum distance between them (with their points), the
//! distance from a round item's centre or axis (see [`super::measure_shape`]) and, where both
//! have a direction (planar faces, straight edges), the angle.
use serde_json::{Value, json};
use solidcraft_geom::{Mesh, Vec3};
use super::measure_shape::{Shape, Slot, add_round, centre};
use crate::params::bad;
use crate::{Result, Sel, Session};
@@ -68,13 +71,214 @@ fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == f).filter_map(|(t, _)| m.tri(t)).collect()
}
/// Geometry, direction (planar face normal, straight edge direction) and a description.
fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
/// A described item: its geometry, direction (planar face normal, straight edge direction),
/// description and exact shape.
struct Item {
geo: Geo,
dir: Option<Vec3>,
json: Value,
shape: Shape,
/// A slot this item is one end of (or the outline of).
slot: Option<Slot>,
}
/// The circle through a polyline's points (all on one within `tol`): a fallback for edges the
/// kernel can't name (mesh bodies).
fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, Vec3, f64)> {
let n = pts.len().checked_sub(1)?;
let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?);
let (u, v) = (b - a, c - a);
let w = u.cross(v);
if !(w.len2() > u.len2() * v.len2() * 1e-16) {
return None;
}
let center = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2()));
let axis = w.normalized()?;
let radius = center.dist(a);
let on = pts.iter().all(|p| (p.dist(center) - radius).abs() <= tol && (*p - center).dot(axis).abs() <= tol);
(on && radius.is_finite()).then_some((center, axis, radius))
}
/// The round shape of mesh edge `index` of body `b`: from the kernel's exact curve when it
/// is the same edge (its ends on the polyline), else fitted to the polyline's points.
fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool) -> Option<Shape> {
let pts = m.edges.get(index)?;
let tol = (m.bounds().diagonal().max(1.0) * 1e-6).max(1e-9);
let exact = solidcraft_kernel::edge_circle(&b.body, index).filter(|c| {
let ends = [pts.first(), pts.last()].into_iter().flatten().all(|p| p.dist(c.start).min(p.dist(c.end)) <= tol * 10.0);
let on = pts.iter().all(|p| (p.dist(c.center) - c.radius).abs() <= tol * 10.0);
ends && on
});
let (center, axis, radius, angle) = match exact {
Some(c) => (c.center, c.axis, c.radius, if whole { std::f64::consts::TAU } else { c.angle }),
None => {
let all: Vec<Vec3> =
if whole { m.circular_rim(index).iter().filter_map(|&i| m.edges.get(i)).flatten().copied().collect() } else { pts.clone() };
let (center, axis, radius) = fit_circle(&all, tol * 10.0)?;
let angle = if whole { std::f64::consts::TAU } else { polyline_len(pts) / radius };
(center, axis, radius, angle)
}
};
Some(Shape::Round { center, axis, radius, angle, planar: true, lo: 0.0, hi: 0.0 })
}
/// Points along a circle or arc (`angle` from `start` about `axis`).
fn arc_points(center: Vec3, axis: Vec3, start: Vec3, angle: f64) -> Vec<Vec3> {
let x = start - center;
let y = axis.cross(x);
let n = 128;
(0..=n)
.map(|i| {
let t = angle * f64::from(i) / f64::from(n);
center + x * t.cos() + y * t.sin()
})
.collect()
}
/// The solved sketch holding a picked curve or point: `sketch` when given, else the active
/// sketch, else the last sketch with that id.
fn sketch_of<'a>(
s: &Session,
st: &'a solidcraft_doc::ModelState,
sketch: Option<u64>,
has: impl Fn(&solidcraft_sketch::Sketch) -> bool,
) -> Option<&'a solidcraft_doc::SolvedSketch> {
if let Some(id) = sketch {
return st.sketch(id);
}
if let Some(ss) = s.active_sketch.and_then(|id| st.sketch(id)).filter(|ss| has(&ss.sketch)) {
return Some(ss);
}
st.sketches.iter().rev().map(|ss| &**ss).find(|ss| has(&ss.sketch))
}
fn sketch_curve(ss: &solidcraft_doc::SolvedSketch, id: &str) -> Option<Item> {
use solidcraft_sketch::CurveKind;
let sk = &ss.sketch;
let c = sk.curves.get(sk.curve_index(id)?)?;
let at = |i: usize| sk.points.get(i).map(|p| ss.plane.to_world(p.pos));
let normal = ss.plane.normal();
let (shape, pts) = match c.kind {
CurveKind::Circle { c, r } => {
let center = at(c)?;
let shape = Shape::Round { center, axis: normal, radius: r, angle: std::f64::consts::TAU, planar: true, lo: 0.0, hi: 0.0 };
(shape, arc_points(center, normal, center + ss.plane.x * r, std::f64::consts::TAU))
}
CurveKind::Arc { c, a, b } => {
let (center, a, b) = (at(c)?, at(a)?, at(b)?);
let radius = center.dist(a);
let (x, y) = ((a - center).normalized()?, normal.cross((a - center).normalized()?));
let mut angle = (b - center).dot(y).atan2((b - center).dot(x));
if angle <= 0.0 {
angle += std::f64::consts::TAU;
}
let shape = Shape::Round { center, axis: normal, radius, angle, planar: true, lo: 0.0, hi: 0.0 };
(shape, arc_points(center, normal, a, angle))
}
CurveKind::Line { a, b } => {
let (a, b) = (at(a)?, at(b)?);
(Shape::Line { a, b }, densify(&[a, b]))
}
_ => return None,
};
let mut json = json!({"type": "sketch_curve", "id": id});
let dir = match shape {
Shape::Line { a, b } => {
json["length_mm"] = json!(a.dist(b));
(b - a).normalized()
}
_ => {
json["length_mm"] = json!(polyline_len(&pts));
add_round(&mut json, &shape);
None
}
};
Some(Item { geo: Geo::Points(pts), dir, json, shape, slot: None })
}
/// A profile (closed region of a sketch): its area, and when it is a circle or a slot, its
/// sizes.
fn profile(ss: &solidcraft_doc::SolvedSketch, index: usize) -> Option<Item> {
use solidcraft_geom::{Seg2, Vec2};
let pr = ss.profiles.get(index)?;
let outline: Vec<Vec3> = pr.region.outer.polyline(1e-3).iter().map(|p| ss.plane.to_world(*p)).collect();
let mut json = json!({"type": "profile", "area_mm2": pr.region.area()});
let normal = ss.plane.normal();
// Arcs about one centre merge (a circle is two halves); straight pieces stay as they are.
let mut arcs: Vec<(Vec2, f64, f64)> = Vec::new();
let mut lines: Vec<(Vec2, Vec2)> = Vec::new();
let mut other = !pr.region.holes.is_empty();
for seg in &pr.region.outer.segs {
match *seg {
Seg2::Arc { center, radius, sweep, .. } => {
match arcs.iter_mut().find(|(c, r, _)| c.dist(center) <= 1e-6 * (1.0 + radius) && (r - radius).abs() <= 1e-6 * (1.0 + radius)) {
Some(a) => a.2 += sweep,
None => arcs.push((center, radius, sweep)),
}
}
Seg2::Line { a, b } => lines.push((a, b)),
_ => other = true,
}
}
let mut shape = Shape::Other;
let mut slot = None;
if !other {
if let ([(c, r, sweep)], []) = (arcs.as_slice(), lines.as_slice())
&& (sweep.abs() - std::f64::consts::TAU).abs() < 1e-6
{
shape = Shape::Round {
center: ss.plane.to_world(*c),
axis: normal,
radius: *r,
angle: std::f64::consts::TAU,
planar: true,
lo: 0.0,
hi: 0.0,
};
add_round(&mut json, &shape);
} else if let Some((a, b, r)) = super::measure_shape::slot_outline(&arcs, &lines) {
let s = Slot { width: 2.0 * r, centres: (ss.plane.to_world(a), ss.plane.to_world(b)) };
json["slot"] = s.json();
slot = Some(s);
}
}
let planar = Some(normal);
let geo = Geo::Points(densify(&outline));
let shape = if matches!(shape, Shape::Other) { Shape::Plane { point: ss.plane.origin, normal } } else { shape };
Some(Item { geo, dir: planar, json, shape, slot })
}
/// A slot this cylindrical face is one end of, from the body's other faces.
fn face_slot(b: &solidcraft_doc::ModelBody, m: &Mesh, shape: &Shape, pts: &[Vec3]) -> Option<Slot> {
let faces = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0);
if faces > 4000 {
return None;
}
let mut verts: Vec<Vec<Vec3>> = vec![Vec::new(); faces];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if let (Some(v), Some(tri)) = (verts.get_mut(*f as usize), m.tri(t)) {
v.extend(tri);
}
}
let surfaces: Vec<(solidcraft_kernel::FaceSurface, Vec<Vec3>)> =
verts.into_iter().enumerate().filter_map(|(i, v)| Some((solidcraft_kernel::face_surface(&b.body, i)?, v))).collect();
super::measure_shape::slot_from_faces(shape, pts, &surfaces)
}
fn describe(s: &Session, sel: &Sel) -> Option<Item> {
let st = s.world_state();
Some(match sel {
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
let plain = |geo, dir, json| Some(Item { geo, dir, json, shape: Shape::Other, slot: None });
match sel {
Sel::Vertex { point, .. } => Some(Item {
geo: Geo::Points(vec![*point]),
dir: None,
json: json!({"type": "vertex", "position": point}),
shape: Shape::Point(*point),
slot: None,
}),
Sel::Edge { body, index, point } => {
let m = st.body(body)?.mesh();
let b = st.body(body)?;
let m = b.mesh();
let index = edge_index(&m, *index, *point)?;
let pts = m.edges.get(index)?;
let rim = m.circular_rim(index);
@@ -82,29 +286,81 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
let points = pieces.iter().flat_map(|p| densify(p)).collect();
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
let mut json = json!({"type": "edge", "length_mm": len});
let shape = edge_round(b, &m, index, true).unwrap_or(Shape::Other);
add_round(&mut json, &shape);
return Some(Item { geo: Geo::Points(points), dir: None, json, shape, slot: None });
}
let (a, b) = (*pts.first()?, *pts.last()?);
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
let (a, z) = (*pts.first()?, *pts.last()?);
let straight = (z - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(z))));
let len = polyline_len(pts);
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
let mut json = json!({"type": "edge", "length_mm": len});
let shape = match straight {
Some(_) => Shape::Line { a, b: z },
None => edge_round(b, &m, index, false).unwrap_or(Shape::Other),
};
add_round(&mut json, &shape);
Some(Item { geo: Geo::Points(densify(pts)), dir: straight, json, shape, slot: None })
}
Sel::Face { body, index, .. } => {
let m = st.body(body)?.mesh();
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
let b = st.body(body)?;
let m = b.mesh();
let patch = m.surface_patch(*index);
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
(Geo::Tris(tris), planar, json!({"type": "face", "area_mm2": area, "planar": planar.is_some()}))
let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()});
let verts: Vec<Vec3> = tris.iter().flat_map(|t| t.iter().copied()).collect();
let shape = match solidcraft_kernel::face_surface(&b.body, *index) {
Some(solidcraft_kernel::FaceSurface::Cylinder { origin, axis, radius }) => {
super::measure_shape::cylinder(origin, axis, radius, &verts).unwrap_or(Shape::Other)
}
Some(solidcraft_kernel::FaceSurface::Plane { normal, point }) if planar.is_some() => Shape::Plane { point, normal },
None => match (planar, verts.first()) {
(Some(normal), Some(point)) => Shape::Plane { point: *point, normal },
_ => Shape::Other,
},
_ => Shape::Other,
};
add_round(&mut json, &shape);
let slot = if matches!(shape, Shape::Round { .. }) && !shape.full() { face_slot(b, &m, &shape, &verts) } else { None };
if let Some(sl) = &slot {
json["slot"] = sl.json();
}
Some(Item { geo: Geo::Tris(tris), dir: planar, json, shape, slot })
}
Sel::Body { name } => {
let m = st.body(name)?.mesh();
let tris: Vec<[Vec3; 3]> = m.triangles.iter().filter_map(|t| m.tri(t)).collect();
let v = m.measure().volume;
(Geo::Tris(tris), None, json!({"type": "body", "name": name, "volume_mm3": v}))
plain(Geo::Tris(tris), None, json!({"type": "body", "name": name, "volume_mm3": v}))
}
_ => return None,
})
Sel::SketchCurve { id } => sketch_curve(sketch_of(s, &st, None, |sk| sk.curve_index(id).is_some())?, id),
Sel::ExternalSketchCurve { sketch, id } => sketch_curve(sketch_of(s, &st, Some(*sketch), |_| true)?, id),
Sel::SketchPoint { id } | Sel::ExternalSketchPoint { id, .. } => {
let given = match sel {
Sel::ExternalSketchPoint { sketch, .. } => Some(*sketch),
_ => None,
};
// "<sketch id>:<point id>" names a point of any sketch.
let (given, id) = match id.split_once(':').and_then(|(k, p)| Some((k.parse::<u64>().ok()?, p))) {
Some((k, p)) if given.is_none() => (Some(k), p),
_ => (given, id.as_str()),
};
let ss = sketch_of(s, &st, given, |sk| sk.point_index(id).is_some())?;
let p = ss.plane.to_world(ss.sketch.points.get(ss.sketch.point_index(id)?)?.pos);
Some(Item {
geo: Geo::Points(vec![p]),
dir: None,
json: json!({"type": "sketch_point", "id": id, "position": p}),
shape: Shape::Point(p),
slot: None,
})
}
Sel::Profile { sketch, index } => profile(st.sketch(*sketch)?, *index),
_ => None,
}
}
fn sample<T: Copy>(v: &[T], max: usize) -> Vec<T> {
@@ -195,22 +451,89 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
}
let mut described = Vec::new();
for x in &sels {
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
described.push(
describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces, bodies, sketch curves or profiles)"))?,
);
}
let mut out = json!({"items": described.iter().map(|d| d.2.clone()).collect::<Vec<_>>()});
if let [(ga, da, _), (gb, db, _)] = described.as_slice() {
if let Some((p, q)) = closest(ga, gb) {
out["distance_mm"] = json!(p.dist(q));
let mut out = json!({"items": described.iter().map(|d| d.json.clone()).collect::<Vec<_>>()});
if let [one] = described.as_slice() {
for k in ["diameter", "radius", "circumference", "angle_deg", "arc_length", "center", "slot"] {
if let Some(v) = one.json.get(k) {
out[k] = v.clone();
}
}
if let Some(sl) = &one.slot {
out["center_from"] = json!(sl.centres.0);
out["center_to"] = json!(sl.centres.1);
}
}
if let [a, b] = described.as_slice() {
let near = closest(&a.geo, &b.geo);
let far = farthest(&a.geo, &b.geo);
let c = centre(&a.shape, &b.shape);
// An exact closest or farthest distance stands only where the mesh agrees with it to
// within its chord error (a bounded face can end before the exact value's point).
let radii = [a.shape, b.shape].iter().map(|x| if let Shape::Round { radius, .. } = x { *radius } else { 0.0 }).sum::<f64>();
let slack = radii * 0.05 + 1e-6;
let mut min = near.map(|(p, q)| p.dist(q));
if let (Some(exact), Some(m)) = (c.and_then(|c| c.min), min)
&& (m - exact).abs() <= slack
{
min = Some(exact);
}
let mut max = far.map(|(p, q)| p.dist(q));
if let (Some(exact), Some(m)) = (c.and_then(|c| c.max), max)
&& (m - exact).abs() <= slack
{
max = Some(exact);
}
if let (Some((p, q)), Some(d)) = (near, min) {
out["distance_mm"] = json!(d);
out["from"] = json!(p);
out["to"] = json!(q);
out["delta"] = json!(q - p);
out["min"] = json!(d);
}
if let (Some(a), Some(b)) = (da, db) {
if let (Some((p, q)), Some(d)) = (far, max) {
out["max"] = json!(d);
out["max_from"] = json!(p);
out["max_to"] = json!(q);
out["max_delta"] = json!(q - p);
}
if let Some(c) = c {
out["center_distance"] = json!(c.distance);
out["center_from"] = json!(c.from);
out["center_to"] = json!(c.to);
out["center_delta"] = json!(c.to - c.from);
}
out["measure"] = json!(if c.is_some() { "center" } else { "min" });
if let (Some(da), Some(db)) = (a.dir, b.dir) {
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
let c = a.dot(*b).clamp(-1.0, 1.0);
let c = if both_faces { c } else { c.abs() };
out["angle_deg"] = json!(c.acos().to_degrees());
let cos = da.dot(db).clamp(-1.0, 1.0);
let cos = if both_faces { cos } else { cos.abs() };
out["angle_deg"] = json!(cos.acos().to_degrees());
}
}
Ok(out)
}
/// Farthest pair of points between two pieces of geometry (on their samples and vertices).
fn farthest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
let pts = |g: &Geo| -> Vec<Vec3> {
match g {
Geo::Points(p) => sample(p, 2000),
Geo::Tris(t) => sample(&t.iter().flat_map(|t| t.iter().copied()).collect::<Vec<_>>(), 2000),
}
};
let (p, q) = (pts(a), pts(b));
let mut best: Option<(f64, Vec3, Vec3)> = None;
for x in &p {
for y in &q {
let d = x.dist(*y);
if best.is_none_or(|(bd, _, _)| d > bd) {
best = Some((d, *x, *y));
}
}
}
best.map(|(_, x, y)| (x, y))
}
+385
View File
@@ -0,0 +1,385 @@
//! What a picked item is, exactly (#1392): circles, arcs and cylinders (round items with an
//! axis), planes, straight lines and points, from the B-rep's exact curves and surfaces or the
//! sketch's own geometry. Round items report their diameter and radius, and a pair with a
//! round item measures from its centre or axis, with the closest and farthest distances too.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use std::f64::consts::{PI, TAU};
/// The exact shape of a picked item.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Shape {
/// A circle, an arc or a cylinder. `center` lies on the axis: a circle's or arc's centre,
/// or for a cylinder the point of the axis level with the middle of the face. `lo..hi` is
/// the extent along `axis` from `center` (0..0 for a planar circle). `angle` is the span
/// around the axis (2π all the way round).
Round {
center: Vec3,
axis: Vec3,
radius: f64,
angle: f64,
planar: bool,
lo: f64,
hi: f64,
},
Plane {
point: Vec3,
normal: Vec3,
},
Line {
a: Vec3,
b: Vec3,
},
Point(Vec3),
Other,
}
/// Within this of a whole turn counts as all the way round (a tessellated face's coverage).
const FULL_TURN: f64 = TAU - 1e-3;
impl Shape {
pub(crate) fn full(&self) -> bool {
matches!(self, Shape::Round { angle, .. } if *angle >= FULL_TURN)
}
}
/// Diameter, radius and length (circumference or arc length, with the arc's angle) of a round
/// item, added to its description. Ø first, then R, then the length.
pub(crate) fn add_round(v: &mut Value, shape: &Shape) {
let Shape::Round { center, axis, radius, angle, planar, .. } = *shape else { return };
v["diameter"] = json!(2.0 * radius);
v["radius"] = json!(radius);
if angle >= FULL_TURN {
v["circumference"] = json!(TAU * radius);
} else {
v["angle_deg"] = json!(angle.to_degrees());
v["arc_length"] = json!(radius * angle);
}
v["center"] = json!(center);
v["axis"] = json!(axis);
v["round"] = json!(if !planar {
"cylinder"
} else if angle >= FULL_TURN {
"circle"
} else {
"arc"
});
}
/// Cylinder `axis` through `origin` of radius `radius` over the points of its face: the
/// round shape, centred level with the middle of the face, with its extent and coverage.
pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
let axis = axis.normalized()?;
if pts.is_empty() || !(radius.is_finite() && radius > 0.0) {
return None;
}
let ts: Vec<f64> = pts.iter().map(|p| (*p - origin).dot(axis)).collect();
let (tmin, tmax) = ts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), t| (a.min(*t), b.max(*t)));
let mid = (tmin + tmax) * 0.5;
let center = origin + axis * mid;
Some(Shape::Round { center, axis, radius, angle: coverage(center, axis, pts), planar: false, lo: tmin - mid, hi: tmax - mid })
}
/// The angle around the axis the points cover: a whole turn less the widest gap between them.
pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 {
let x = axis.any_perp();
let y = axis.cross(x);
let mut angles: Vec<f64> = pts
.iter()
.filter_map(|p| {
let d = *p - center;
let r = d - axis * d.dot(axis);
(r.len() > 1e-12).then(|| r.dot(y).atan2(r.dot(x)))
})
.collect();
if angles.len() < 2 {
return 0.0;
}
angles.sort_by(f64::total_cmp);
let wrap = angles.first().copied().unwrap_or(0.0) + TAU - angles.last().copied().unwrap_or(0.0);
let widest = angles.windows(2).map(|w| w[1] - w[0]).fold(wrap, f64::max);
// Tessellation leaves gaps of a few degrees on a whole cylinder: those still count.
if widest < 0.35 { TAU } else { TAU - widest }
}
/// The unit direction a point leaves the axis at, towards the middle of the face's points.
fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option<Vec3> {
let sum = pts.iter().fold(Vec3::ZERO, |acc, p| {
let d = *p - center;
acc + (d - axis * d.dot(axis)).normalized().unwrap_or(Vec3::ZERO)
});
sum.normalized()
}
/// Closest points of two infinite lines (a point and a unit direction each).
fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) {
let w = p1 - p2;
let b = d1.dot(d2);
let denom = 1.0 - b * b;
if denom < 1e-12 {
return (p1, p2 + d2 * w.dot(d2));
}
let (d, e) = (d1.dot(w), d2.dot(w));
let s = (b * e - d) / denom;
let t = (e - b * d) / denom;
(p1 + d1 * s, p2 + d2 * t)
}
fn parallel(a: Vec3, b: Vec3) -> bool {
a.cross(b).len() < 1e-6
}
/// A measurement from a round item's centre or axis.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Centre {
pub distance: f64,
pub from: Vec3,
pub to: Vec3,
/// Closest and farthest distances worked out exactly (`None`: measure them on the mesh).
pub min: Option<f64>,
pub max: Option<f64>,
}
/// Distance between two non-negative in-plane circles' rims, closest and farthest, with
/// centres `d` apart.
fn rims(d: f64, ra: f64, rb: f64) -> (f64, f64) {
let near = if d >= ra + rb {
d - ra - rb
} else if d <= (ra - rb).abs() {
(ra - rb).abs() - d
} else {
0.0
};
(near, d + ra + rb)
}
/// Centre-to-centre (axis to axis, axis to plane, line or point) between two items, when one
/// of them is round. `None` for pairs without a round item and where an axis meets the other
/// item at an angle that gives no single distance (a cylinder crossing a plane).
pub(crate) fn centre(a: &Shape, b: &Shape) -> Option<Centre> {
match (a, b) {
(Shape::Round { .. }, _) => from_round(a, b),
(_, Shape::Round { .. }) => from_round(b, a).map(|c| Centre { from: c.to, to: c.from, ..c }),
_ => None,
}
}
fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
let Shape::Round { center: ca, axis: na, radius: ra, planar: pa, lo: loa, hi: hia, .. } = *a else { return None };
let full_a = a.full();
let c = match *b {
Shape::Round { center: cb, axis: nb, radius: rb, lo: lob, hi: hib, .. } => {
if parallel(na, nb) {
let w = cb - ca;
let along = w.dot(na);
// Drawn level with A's centre: on B's axis, the foot of A's centre.
let foot = cb - na * along;
let d = (w - na * along).len();
let (mut min, mut max) = (None, None);
if full_a && b.full() {
// B's extent along A's axis.
let s = if na.dot(nb) >= 0.0 { 1.0 } else { -1.0 };
let (b0, b1) = (along + lob * s, along + hib * s);
let (b0, b1) = (b0.min(b1), b0.max(b1));
let gap = (loa.max(b0) - hia.min(b1)).max(0.0);
let reach = (hia - b0).abs().max((b1 - loa).abs());
let (near, far) = rims(d, ra, rb);
min = Some(near.hypot(gap));
max = Some(far.hypot(reach));
}
Centre { distance: d, from: ca, to: foot, min, max }
} else {
let (p, q) = line_line(ca, na, cb, nb);
Centre { distance: p.dist(q), from: p, to: q, min: None, max: None }
}
}
Shape::Plane { point, normal } => {
let n = normal.normalized()?;
let h = (ca - point).dot(n);
if na.dot(n).abs() < 1e-6 {
// Axis along the plane: axis to plane, and the rim's nearest and farthest.
let d = h.abs();
let (min, max) = if full_a { (Some((d - ra).max(0.0)), Some(d + ra)) } else { (None, None) };
Centre { distance: d, from: ca, to: ca - n * h, min: if d >= ra { min } else { None }, max }
} else if pa {
// A circle's centre to the plane.
Centre { distance: h.abs(), from: ca, to: ca - n * h, min: None, max: None }
} else {
return None;
}
}
Shape::Line { a: la, b: lb } => {
let dir = (lb - la).normalized()?;
let (p, q) = line_line(ca, na, la, dir);
let d = p.dist(q);
let across = dir.dot(na).abs() < 1e-6;
let min = (full_a && across && d >= ra).then_some(d - ra);
Centre { distance: d, from: p, to: q, min, max: None }
}
Shape::Point(p) => {
if pa {
let d = p.dist(ca);
let in_plane = (p - ca).dot(na).abs() < 1e-6 * (1.0 + d);
let (min, max) = if full_a && in_plane { (Some((d - ra).abs()), Some(d + ra)) } else { (None, None) };
Centre { distance: d, from: ca, to: p, min, max }
} else {
let w = p - ca;
let foot = ca + na * w.dot(na);
Centre { distance: p.dist(foot), from: foot, to: p, min: None, max: None }
}
}
Shape::Other => return None,
};
(c.distance.is_finite() && c.from.is_finite() && c.to.is_finite()).then_some(c)
}
/// A slot's sizes: width (the ends' diameter), the distance between the ends' centres and the
/// overall length, with the two centres.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Slot {
pub width: f64,
pub centres: (Vec3, Vec3),
}
impl Slot {
pub(crate) fn json(&self) -> Value {
let c2c = self.centres.0.dist(self.centres.1);
json!({"width": self.width, "center_distance": c2c, "overall_length": c2c + self.width, "centers": [self.centres.0, self.centres.1]})
}
}
/// A slot's outline in its plane: two half-circle ends of one radius joined by two straight
/// sides. `arcs` are (centre, radius, swept angle) with pieces of one end already merged,
/// `lines` the straight pieces (2D, in the sketch plane).
pub(crate) fn slot_outline(
arcs: &[(solidcraft_geom::Vec2, f64, f64)],
lines: &[(solidcraft_geom::Vec2, solidcraft_geom::Vec2)],
) -> Option<(solidcraft_geom::Vec2, solidcraft_geom::Vec2, f64)> {
let [(c1, r1, s1), (c2, r2, s2)] = arcs else { return None };
let tol = 1e-6 * (1.0 + r1.abs() + c1.dist(*c2));
if (r1 - r2).abs() > tol || (s1.abs() - PI).abs() > 1e-4 || (s2.abs() - PI).abs() > 1e-4 || c1.dist(*c2) <= tol || lines.is_empty() {
return None;
}
let along = (*c2 - *c1).normalized()?;
// Every straight piece runs along the slot, `r` to one side of the centre line.
let side = |p: solidcraft_geom::Vec2| (p - *c1).cross(along);
let ok = lines.iter().all(|(a, b)| {
let d = *b - *a;
d.cross(along).abs() <= tol * (1.0 + d.len()) && (side(*a).abs() - r1).abs() <= tol && (side(*b).abs() - r1).abs() <= tol
});
let both = lines.iter().any(|(a, _)| side(*a) > 0.0) && lines.iter().any(|(a, _)| side(*a) < 0.0);
(ok && both).then_some((*c1, *c2, *r1))
}
/// A slot cut or boss on a solid, found from one of its half-cylinder ends: another half
/// cylinder of the same radius on a parallel axis, bulging the other way, and two planes along
/// the slot `r` either side of its centre line. `faces` gives each face's surface and points.
pub(crate) fn slot_from_faces(picked: &Shape, picked_pts: &[Vec3], faces: &[(solidcraft_kernel::FaceSurface, Vec<Vec3>)]) -> Option<Slot> {
use solidcraft_kernel::FaceSurface;
let Shape::Round { center, axis, radius, angle, planar: false, .. } = *picked else { return None };
if (angle - PI).abs() > 0.3 {
return None;
}
let tol = 1e-6 * (1.0 + radius) * 100.0;
let out_a = bulge(center, axis, picked_pts)?;
for (s, pts) in faces {
let FaceSurface::Cylinder { origin, axis: ax, radius: r } = *s else { continue };
if (r - radius).abs() > tol || !parallel(ax, axis) {
continue;
}
let Some(Shape::Round { center: cb, angle: ab, .. }) = cylinder(origin, ax, r, pts) else { continue };
let w = cb - center;
let off = w - axis * w.dot(axis);
let Some(along) = off.normalized() else { continue };
if (ab - PI).abs() > 0.3 || off.len() <= tol {
continue;
}
let Some(out_b) = bulge(cb, axis, pts) else { continue };
// Each end bulges away from the other.
if out_a.dot(along) > -0.5 || out_b.dot(along) < 0.5 {
continue;
}
let across = axis.cross(along);
let sides = faces.iter().filter(|(fs, _)| match *fs {
FaceSurface::Plane { normal, point } => {
parallel(normal, across) && ((point - center).dot(across).abs() - radius).abs() <= tol.max(radius * 1e-4)
}
_ => false,
});
let (mut pos, mut neg) = (false, false);
for (fs, _) in sides {
if let FaceSurface::Plane { point, .. } = fs {
if (*point - center).dot(across) > 0.0 { pos = true } else { neg = true }
}
}
if pos && neg {
// Both centres level with the picked end's middle.
return Some(Slot { width: 2.0 * radius, centres: (center, center + off) });
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec2;
fn circle(c: Vec3, r: f64) -> Shape {
Shape::Round { center: c, axis: Vec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
}
#[test]
fn two_circles_measure_centre_to_centre_with_rims() {
let c = centre(&circle(Vec3::ZERO, 3.0), &circle(Vec3::new(30.0, 40.0, 0.0), 2.0)).unwrap();
assert!((c.distance - 50.0).abs() < 1e-12);
assert!((c.min.unwrap() - 45.0).abs() < 1e-12);
assert!((c.max.unwrap() - 55.0).abs() < 1e-12);
}
#[test]
fn a_cylinder_to_a_side_plane_measures_from_its_axis() {
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let plane = Shape::Plane { point: Vec3::ZERO, normal: Vec3::new(-1.0, 0.0, 0.0) };
let c = centre(&cyl, &plane).unwrap();
assert!((c.distance - 10.0).abs() < 1e-12);
assert!((c.min.unwrap() - 7.5).abs() < 1e-12 && (c.max.unwrap() - 12.5).abs() < 1e-12);
// Swapped, the line runs the other way.
let d = centre(&plane, &cyl).unwrap();
assert_eq!((d.from, d.to), (c.to, c.from));
// A cylinder crossing a plane has no single axis distance.
assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: Vec3::Z }).is_none());
}
#[test]
fn an_axis_to_a_crossing_edge_is_the_distance_between_the_lines() {
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(40.0, 0.0, 10.0) };
let c = centre(&cyl, &edge).unwrap();
assert!((c.distance - 15.0).abs() < 1e-12);
assert!((c.min.unwrap() - 12.5).abs() < 1e-12);
assert!(c.from.dist(Vec3::new(10.0, 15.0, 10.0)) < 1e-12 && c.to.dist(Vec3::new(10.0, 0.0, 10.0)) < 1e-12);
}
#[test]
fn coverage_of_a_half_and_a_whole_ring() {
let ring = |n: usize, span: f64| -> Vec<Vec3> {
(0..=n).map(|i| Vec3::new((span * i as f64 / n as f64).cos(), (span * i as f64 / n as f64).sin(), 0.0)).collect()
};
assert!((coverage(Vec3::ZERO, Vec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
assert_eq!(coverage(Vec3::ZERO, Vec3::Z, &ring(32, TAU)), TAU);
}
#[test]
fn a_slot_outline_and_not_a_slot() {
let arcs = [(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 3.0, PI)];
let lines = [(Vec2::new(0.0, -3.0), Vec2::new(20.0, -3.0)), (Vec2::new(20.0, 3.0), Vec2::new(0.0, 3.0))];
let (a, b, r) = slot_outline(&arcs, &lines).unwrap();
assert_eq!((a.dist(b), r), (20.0, 3.0));
let skew = [(Vec2::new(0.0, -3.0), Vec2::new(20.0, -2.0)), (Vec2::new(20.0, 3.0), Vec2::new(0.0, 3.0))];
assert!(slot_outline(&arcs, &skew).is_none());
assert!(slot_outline(&[(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 4.0, PI)], &lines).is_none());
}
}
+5
View File
@@ -22,6 +22,7 @@ macro_rules! planned {
mod browser;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
@@ -42,6 +43,7 @@ mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
@@ -62,6 +64,7 @@ pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::created as sketch_created;
mod sketch_3d;
mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_freeform;
@@ -250,11 +253,13 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
+28
View File
@@ -402,3 +402,31 @@ fn pattern_of_touching_cuts_needs_no_fallback() {
assert_eq!(m["bodies"][0][k], single["bodies"][0][k], "{k}");
}
}
/// A pattern of cuts beside a round, on a bar whose long top edges are rounded, takes the exact
/// planar route for every copy rather than the general boolean's retries: the teeth touch side
/// by side and never reach the rounds (#1197). Retries and volume, not timing (see
/// `scales_linearly`).
#[test]
fn pattern_of_cuts_beside_a_round_needs_no_retries() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-10, 0, 0], "length": 20, "width": 200, "height": 4, "name": "Bar"}));
run(&mut s, "solid.fillet", json!({"edges": [[-10, 100, 4], [10, 100, 4]], "radius": 1}));
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Tooth"}));
run(&mut s, "sketch.line", json!({"points": [[20, 4], [24, 2], [24, 5], [20, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
let cut =
run(&mut s, "solid.extrude", json!({"sketch": "Tooth", "distance": 16, "direction": "symmetric", "operation": "cut", "name": "Tooth cut"}));
let teeth =
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Tooth cut"], "dir1": [0, 1, 0], "count1": 40, "spacing1": 4, "name": "Teeth"}));
for created in [&cut, &teeth] {
let id = created["feature"].as_u64().unwrap();
let result = s.model.result(id).unwrap();
assert!(result.error.is_none() && result.warning.is_none(), "{result:?}");
assert!(result.boolean_diagnostics.events.is_empty(), "{:?}", result.boolean_diagnostics);
}
// The bar, less the rounds' corners and 40 teeth (a 4 x 2 triangle below the top, 16 wide).
let expected = 16000.0 - 400.0 * (1.0 - PI / 4.0) - 40.0 * 4.0 * 16.0;
let v = volume(&mut s);
assert!(rel(v, expected) < 1e-5, "{v} vs {expected}");
}
+30 -2
View File
@@ -111,7 +111,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. Returns measured in unit (mm or deg)",
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -623,6 +623,18 @@ pub(super) fn face_contains(m: &solidcraft_geom::Mesh, fi: usize, p: Vec3, tol:
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.create";
// These name the plane, so they belong inside `plane`. Next to it they were ignored and the
// sketch went to XY (or the plane given) without a word.
for (key, example) in [
("face", r#"{"plane": {"face": [x, y, z]}}"#),
("origin", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
("x_dir", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
("y_dir", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
] {
if p.get(key).is_some() {
return Err(bad(cmd, format!("`{key}` belongs inside `plane`: {example}")));
}
}
create_sketch_with_plane(s, p, plane_ref(s, p, cmd)?, None)
}
@@ -1880,6 +1892,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let value = expr(p, "value");
let text_at = p.get("text_at").and_then(vec2);
let driven = bool_(p, "driven").unwrap_or(false);
let scale_sketch = bool_(p, "scale_sketch").unwrap_or(false);
let mut scaled = None;
let mut before = None;
let (mut residual, mut new_id) = (None, String::new());
let start = s.doc.clone();
@@ -2043,6 +2057,16 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
// only restates what the sketch already has.
let want = vals.get(&pname).and_then(|v| v.to_kind(if is_angle { Kind::Angle } else { Kind::Length }).ok());
residual = want.map(|w| (w - cur).abs()).filter(|r| r.is_finite());
// The first length dimension of a sketch with nothing sized yet scales it (#1365).
if scale_sketch
&& !is_angle
&& let Some(factor) = want.map(|w| w / cur).filter(|f| f.is_finite() && (f - 1.0).abs() > 1e-9)
&& let Some(big) = sk.scaled_for_first_dimension(factor)
{
*sk = big;
scaled = Some(factor);
residual = Some(0.0);
}
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
new_id = id.clone();
@@ -2078,7 +2102,11 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
let mut out = json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info});
if let Some(factor) = scaled {
out["scaled"] = json!(round6(factor));
}
Ok(out)
}
/// A dimension's measured value as reported, in the unit its expression is written in.
+325
View File
@@ -0,0 +1,325 @@
//! Copy and paste of sketch geometry (#1480). `sketch.copy` keeps the chosen curves and points
//! with the constraints and dimensions that lie wholly among them (a dimension keeps its
//! expression); `sketch.paste` adds an independent copy to the sketch being edited, at a point or
//! offset from the original, as one undo step, and selects it. Constraints that reach outside
//! the copied geometry (to the origin, to projected edges, to other curves) are left behind, as
//! are fixes, so the pasted geometry can be moved straight away.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
use super::sketch::{edit, target_sketch};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, string_list, vec2};
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy)
.noundo()
.enabled(in_sketch)
.params("entities?: [curve or point ids] (default: the selected sketch geometry); the constraints and dimensions among them come along"),
CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut)
.enabled(in_sketch)
.params("entities?: [curve or point ids] (default: the selected sketch geometry): copies them as sketch.copy does, then deletes them (one undo step)"),
CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).enabled(in_sketch).params(
"at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)",
),
];
/// A curve of the clipboard; its points are indices into [`SketchClip::points`].
#[derive(Clone, Debug)]
pub struct ClipCurve {
pub kind: CurveKind,
pub construction: bool,
pub centerline: bool,
pub reversed: bool,
}
/// A constraint or dimension of the clipboard, over clipboard points and curves. A dimension
/// keeps the expression and unit of its parameter.
#[derive(Clone, Debug)]
pub struct ClipConstraint {
pub kind: ConstraintKind,
pub param: Option<(String, String)>,
pub driven: bool,
pub text: Option<Vec2>,
}
/// Sketch geometry copied with `sketch.copy`, in the sketch coordinates it was copied from.
#[derive(Clone, Debug, Default)]
pub struct SketchClip {
/// The sketch it was copied from.
pub sketch: u64,
pub points: Vec<Vec2>,
pub curves: Vec<ClipCurve>,
pub constraints: Vec<ClipConstraint>,
/// Text sources (their outlines are made again on paste). A path, when kept, is the index of
/// a clipboard curve.
pub texts: Vec<(LinkSource, Option<usize>)>,
/// Lone points (not used by a copied curve), as indices into `points`.
pub lone: Vec<usize>,
/// Centre of the copied geometry's bounding box: what `at` places.
pub center: Vec2,
/// Size of the bounding box (the larger side), for the paste offset.
pub extent: f64,
/// Pastes made without a position since the copy (each lands one offset further on).
pub pastes: u32,
}
/// Curve and point ids named by `entities`, or the selected sketch geometry.
fn chosen(s: &Session, p: &Value) -> Vec<String> {
if p.get("entities").is_some() {
return string_list(p, "entities");
}
s.selection
.iter()
.filter_map(|sel| match sel {
Sel::SketchCurve { id } | Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.collect()
}
fn text_of(sk: &Sketch, ci: usize) -> Option<&str> {
let link = sk.curves.get(ci)?.link.as_deref()?;
sk.link(link).filter(|l| l.kind == LinkKind::Text).map(|l| l.id.as_str())
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.copy";
let id = target_sketch(s, p, cmd)?;
let ids = chosen(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select the sketch curves or points to copy"));
}
let sk = s.doc.sketch(id)?;
let (mut cs, mut ps, mut texts): (Vec<usize>, Vec<usize>, Vec<String>) = (Vec::new(), Vec::new(), Vec::new());
for e in &ids {
if let Some(c) = sk.curve_index(e) {
match text_of(sk, c) {
Some(t) if !texts.iter().any(|x| x == t) => texts.push(t.to_string()),
Some(_) => {}
None if !cs.contains(&c) => cs.push(c),
None => {}
}
} else if let Some(q) = sk.resolve_point(e) {
match sk.points.get(q).and_then(|x| x.link.as_deref()).and_then(|l| sk.link(l)).filter(|l| l.kind == LinkKind::Text) {
Some(l) if !texts.contains(&l.id) => texts.push(l.id.clone()),
Some(_) => {}
None if !ps.contains(&q) => ps.push(q),
None => {}
}
} else {
return Err(bad(cmd, format!("unknown sketch entity `{e}`")));
}
}
let mut clip = SketchClip { sketch: id, ..Default::default() };
// Points: those of the copied curves, then the lone ones, each once.
let mut pmap: BTreeMap<usize, usize> = BTreeMap::new();
let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap<usize, usize>| -> usize {
*pmap.entry(q).or_insert_with(|| {
clip.points.push(sk.point(q).unwrap_or_default());
clip.points.len() - 1
})
};
let mut cmap: BTreeMap<usize, usize> = BTreeMap::new();
for &c in &cs {
let Some(cu) = sk.curves.get(c) else { continue };
let mut kind = cu.kind.clone();
let used: Vec<(usize, usize)> = kind.point_ids().into_iter().map(|q| (q, take(q, &mut clip, &mut pmap))).collect();
kind.map_points(&|q| used.iter().find(|(o, _)| *o == q).map_or(q, |(_, n)| *n));
cmap.insert(c, clip.curves.len());
// Projected geometry comes in as ordinary geometry.
clip.curves.push(ClipCurve { kind, construction: cu.construction, centerline: cu.centerline, reversed: cu.reversed });
}
for &q in &ps {
let known = pmap.contains_key(&q);
let n = take(q, &mut clip, &mut pmap);
if !known {
clip.lone.push(n);
}
}
for c in &sk.constraints {
// Fixes and pattern or offset relations stay with the original.
if matches!(
c.kind,
ConstraintKind::Fix { .. }
| ConstraintKind::PatternPoint { .. }
| ConstraintKind::PatternRadius { .. }
| ConstraintKind::OffsetDistance { .. }
) {
continue;
}
let mut kind = c.kind.clone();
if !kind.remap(&|q| pmap.get(&q).copied(), &|k| cmap.get(&k).copied()) {
continue;
}
let param = c.param.as_ref().map(|name| match s.doc.param(name) {
Some(x) => (x.expr.clone(), x.unit.clone()),
None => (format!("{}", kind.value().unwrap_or_default()), if kind.is_angle() { "rad".into() } else { "mm".into() }),
});
clip.constraints.push(ClipConstraint { kind, param, driven: c.driven, text: c.text });
}
for t in &texts {
let Some(mut src) = sk.link(t).map(|l| l.source.clone()) else { continue };
let LinkSource::Text { style, .. } = &mut src else { continue };
// A text on a path copied with its path follows the copy; otherwise it is set straight.
let path = style.path.as_deref().and_then(|pid| sk.curve_index(pid)).and_then(|pc| cmap.get(&pc).copied());
if path.is_none() {
style.path = None;
}
clip.texts.push((src, path));
}
// Bounds of everything copied, as drawn.
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut add = |q: Vec2| {
lo = Vec2::new(lo.x.min(q.x), lo.y.min(q.y));
hi = Vec2::new(hi.x.max(q.x), hi.y.max(q.y));
};
let mut drawn: Vec<usize> = cs.clone();
for t in &texts {
drawn.extend(sk.link_curves(t));
}
for c in drawn {
sk.polyline(c).into_iter().for_each(&mut add);
}
for &n in &clip.lone {
if let Some(q) = clip.points.get(n) {
add(*q);
}
}
if !(lo.x.is_finite() && hi.x.is_finite()) {
return Err(bad(cmd, "nothing to copy"));
}
clip.center = (lo + hi) * 0.5;
clip.extent = (hi.x - lo.x).max(hi.y - lo.y);
let out = json!({
"curves": clip.curves.len() + texts.iter().map(|t| sk.link_curves(t).len()).sum::<usize>(),
"points": clip.lone.len(),
"texts": texts.len(),
"constraints": clip.constraints.len(),
"center": [clip.center.x, clip.center.y],
});
s.sketch_clipboard = Some(clip);
Ok(out)
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let entities = chosen(s, p);
let mut q = p.clone();
q["entities"] = json!(entities);
let out = copy(s, &q)?;
let delete = super::find_command("sketch.delete").ok_or_else(|| bad("sketch.cut", "sketch.delete is missing"))?;
let mut d = json!({ "entities": entities });
if let Some(sk) = p.get("sketch") {
d["sketch"] = sk.clone();
}
delete.run_nested(s, &d)?;
s.selection.clear();
// The cut geometry is gone: a paste back into this sketch goes where it was, not offset.
if let Some(c) = s.sketch_clipboard.as_mut() {
c.sketch = u64::MAX;
}
Ok(out)
}
/// The step a paste without a position moves by: a tenth of the copy's size, rounded up to 1, 2
/// or 5 times a power of ten (at least 1 mm).
fn paste_step(extent: f64) -> f64 {
let want = (extent * 0.1).max(1.0);
let mag = 10f64.powf(want.log10().floor());
[1.0, 2.0, 5.0, 10.0].iter().map(|k| k * mag).find(|x| *x >= want - 1e-9).unwrap_or(want)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.paste";
let Some(clip) = s.sketch_clipboard.clone() else { return Err(bad(cmd, "nothing copied (sketch.copy first)")) };
let target = target_sketch(s, p, cmd)?;
let positioned = p.get("at").is_some() || p.get("translate").is_some();
let t = if let Some(v) = p.get("at") {
vec2(v).ok_or_else(|| bad(cmd, "`at` must be [x, y]"))? - clip.center
} else if let Some(v) = p.get("translate") {
vec2(v).ok_or_else(|| bad(cmd, "`translate` must be [x, y]"))?
} else if target == clip.sketch {
let d = paste_step(clip.extent) * f64::from(clip.pastes.saturating_add(1));
Vec2::new(d, -d)
} else {
Vec2::ZERO
};
if !(t.x.is_finite() && t.y.is_finite()) {
return Err(bad(cmd, "the paste position must be finite"));
}
let mut p = p.clone();
p["sketch"] = json!(target);
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| {
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let mut pts = Vec::with_capacity(clip.points.len());
for q in &clip.points {
pts.push(sk.add_point(*q + t, None)?);
}
let mut made = Vec::with_capacity(clip.curves.len());
for c in &clip.curves {
let mut kind = c.kind.clone();
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
let i = sk.add_curve(kind, None)?;
if let Some(cu) = sk.curves.get_mut(i) {
cu.construction = c.construction;
cu.centerline = c.centerline;
cu.reversed = c.reversed;
}
made.push(i);
}
let mut cons = Vec::new();
for c in &clip.constraints {
let mut kind = c.kind.clone();
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
continue;
}
// Each pasted dimension gets its own parameter, with the same expression.
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
let id = sk.add_constraint(kind, param)?;
if let Some(nc) = sk.constraints.last_mut() {
nc.driven = c.driven;
nc.text = c.text;
}
cons.push(id);
}
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
for (src, path) in &clip.texts {
let mut src = src.clone();
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
*at += t;
// The copy sits where it was pasted: its place no longer follows the original's expressions.
exprs.x = None;
exprs.y = None;
let geom = sk.text_source_geometry(&src)?;
let link = sk.add_link(LinkKind::Text, src, &geom)?;
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
}
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
Ok((curve_ids, lone, cons))
})?;
if !positioned
&& target == clip.sketch
&& let Some(c) = s.sketch_clipboard.as_mut()
{
c.pastes = c.pastes.saturating_add(1);
}
// The pasted geometry is selected, ready to move.
if s.active_sketch == Some(target) {
s.selection =
curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).chain(lone.iter().map(|id| Sel::SketchPoint { id: id.clone() })).collect();
}
Ok(json!({"curves": curves, "points": lone, "constraints": constraints, "translate": [t.x, t.y], "sketch": info}))
}
#[cfg(test)]
#[path = "sketch_clipboard_tests.rs"]
mod tests;
@@ -0,0 +1,233 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, Sketch};
use crate::{Sel, Session};
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn sketch(s: &Session, id: u64) -> &Sketch {
s.doc.sketch(id).unwrap()
}
fn pos(sk: &Sketch, curve: &str) -> Vec<Vec2> {
let c = &sk.curves[sk.curve_index(curve).unwrap()];
c.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect()
}
/// A sketch on XY with a dimensioned, constrained rectangle tied to the origin, a circle with a
/// diameter, an arc, a spline and a point.
fn drawing() -> (Session, Vec<String>, String) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}))["curves"]);
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 10}));
let circle = strs(&run(&mut s, "sketch.circle.center", json!({"center": [40, 5], "diameter": 6}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [circle], "type": "diameter", "value": 6}));
(s, rect, circle)
}
#[test]
fn copy_paste_keeps_internal_constraints_and_dimensions_and_drops_outside_ones() {
let (mut s, rect, circle) = drawing();
let sid = s.active_sketch.unwrap();
let mut entities = rect.clone();
entities.push(circle.clone());
let copied = run(&mut s, "sketch.copy", json!({"entities": entities}));
assert_eq!(copied["curves"], 5);
let before = sketch(&s, sid).clone();
let params_before = s.doc.params.len();
let undo = s.undo.len();
let out = run(&mut s, "sketch.paste", json!({"at": [100, 50]}));
assert_eq!(s.undo.len(), undo + 1, "one paste, one undo step");
let pasted = strs(&out["curves"]);
assert_eq!(pasted.len(), 5);
let sk = sketch(&s, sid);
// The originals are where they were.
for c in entities.iter() {
assert_eq!(pos(sk, c), pos(&before, c));
}
// Centred on `at`: the copy's bounding box (0..43 x 0..10) centre goes to (100, 50).
let p0 = pos(sk, &pasted[0]);
assert!(p0[0].dist(Vec2::new(100.0 - 21.5, 50.0 - 5.0)) < 1e-6, "{p0:?}");
// The horizontal/vertical, coincident-corner and parallel constraints and the three dimensions
// came along; the coincidence with the origin did not.
let new_cons: Vec<_> = sk.constraints.iter().filter(|c| !before.constraints.iter().any(|b| b.id == c.id)).collect();
let internal = before.constraints.iter().filter(|c| !c.kind.refers_to_point(0)).count();
assert_eq!(new_cons.len(), internal, "{new_cons:#?}");
assert!(!new_cons.iter().any(|c| c.kind.refers_to_point(0)));
let dims: Vec<_> = new_cons.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 3);
// Each pasted dimension has its own parameter.
assert_eq!(s.doc.params.len(), params_before + 3);
assert!(dims.iter().all(|d| d.param.is_some() && !before.constraints.iter().any(|b| b.param == d.param)));
let length = dims
.iter()
.find(|d| matches!(d.kind, ConstraintKind::Length { .. } | ConstraintKind::Distance { .. }))
.and_then(|d| d.param.clone())
.unwrap();
// The paste is selected.
let sel: Vec<_> = s.selection.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
assert_eq!(sel, pasted);
// The pasted rectangle is free to move (the original is held by the origin): dragging it moves it.
run(&mut s, "sketch.drag", json!({"entity": pasted[0], "from": [p0[0].x, p0[0].y], "to": [p0[0].x + 5.0, p0[0].y + 5.0]}));
let moved = pos(sketch(&s, sid), &pasted[0]);
assert!(moved[0].dist(p0[0]) > 1.0, "{moved:?}");
assert_eq!(pos(sketch(&s, sid), &rect[0]), pos(&before, &rect[0]));
// Editing a pasted dimension changes the copy only.
run(&mut s, "parameters.change", json!({"name": length, "expression": "30"}));
let sk = sketch(&s, sid);
let span = |c: &str| {
let p = pos(sk, c);
p[0].dist(p[1])
};
assert!((span(&rect[0]) - 20.0).abs() < 1e-6 && (span(&rect[1]) - 10.0).abs() < 1e-6);
assert!(pasted[..4].iter().any(|c| (span(c) - 30.0).abs() < 1e-6), "{:?}", pasted.iter().map(|c| span(c)).collect::<Vec<_>>());
}
#[test]
fn paste_is_one_undo_step() {
let (mut s, rect, circle) = drawing();
let sid = s.active_sketch.unwrap();
let before = s.doc.to_json();
run(&mut s, "sketch.copy", json!({"entities": [rect[0], rect[1], circle]}));
run(&mut s, "sketch.paste", json!({}));
assert_ne!(s.doc.to_json(), before);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.active_sketch, Some(sid));
}
#[test]
fn paste_without_a_position_steps_away_from_the_original_each_time() {
let (mut s, rect, _) = drawing();
let sid = s.active_sketch.unwrap();
run(&mut s, "sketch.copy", json!({"entities": [rect[0]]}));
let a = strs(&run(&mut s, "sketch.paste", json!({}))["curves"]);
let b = strs(&run(&mut s, "sketch.paste", json!({}))["curves"]);
let sk = sketch(&s, sid);
let (o, pa, pb) = (pos(sk, &rect[0])[0], pos(sk, &a[0])[0], pos(sk, &b[0])[0]);
// A 20 mm line: steps of 2 mm.
assert!(pa.dist(o + Vec2::new(2.0, -2.0)) < 1e-6, "{o:?} {pa:?}");
assert!(pb.dist(o + Vec2::new(4.0, -4.0)) < 1e-6, "{o:?} {pb:?}");
}
#[test]
fn every_kind_of_sketch_geometry_pastes() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let line = strs(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"]);
let arc = strs(&run(&mut s, "sketch.arc.three_point", json!({"start": [20, 0], "end": [30, 0], "through": [25, 4]}))["curves"]);
let circle = strs(&run(&mut s, "sketch.circle.center", json!({"center": [40, 0], "radius": 3}))["curves"]);
let spline = strs(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 10], [5, 14], [10, 10], [15, 13]]}))["curves"]);
let point = run(&mut s, "sketch.point", json!({"point": [50, 5]}));
let point_id = strs(&point["points"]).first().cloned().unwrap_or_else(|| point["point"].as_str().unwrap().to_string());
let text = run(&mut s, "sketch.text", json!({"text": "Hi", "at": [0, 20], "height": 5}));
let text_curve = strs(&text["curves"])[0].clone();
let sid = s.active_sketch.unwrap();
let before = sketch(&s, sid).clone();
let mut all: Vec<String> = [line, arc, circle, spline].concat();
all.push(point_id.clone());
all.push(text_curve);
// Copy from the selection, as Ctrl+C does.
s.selection =
all.iter().map(|id| if *id == point_id { Sel::SketchPoint { id: id.clone() } } else { Sel::SketchCurve { id: id.clone() } }).collect();
run(&mut s, "sketch.copy", json!({}));
let out = run(&mut s, "sketch.paste", json!({"translate": [0, 100]}));
let sk = sketch(&s, sid);
let new_curves: Vec<_> = sk.curves.iter().filter(|c| !before.curves.iter().any(|b| b.id == c.id)).collect();
let kinds = |pred: fn(&CurveKind) -> bool| new_curves.iter().filter(|c| pred(&c.kind)).count();
assert_eq!(kinds(|k| matches!(k, CurveKind::Arc { .. })), 1);
assert_eq!(kinds(|k| matches!(k, CurveKind::Circle { .. })), 1);
assert_eq!(
kinds(|k| matches!(k, CurveKind::Spline { .. }))
- before.curves.iter().filter(|c| c.link.is_some() && matches!(c.kind, CurveKind::Spline { .. })).count(),
1
);
// The text came as a new, editable text 100 mm up.
let texts: Vec<_> = sk.links.iter().filter(|l| l.kind == LinkKind::Text).collect();
assert_eq!(texts.len(), 2);
let Some(solidcraft_sketch::LinkSource::Text { at, text, .. }) =
texts.iter().map(|l| &l.source).find(|src| !before.links.iter().any(|b| &b.source == *src))
else {
panic!()
};
assert_eq!(text, "Hi");
assert!(at.dist(Vec2::new(0.0, 120.0)) < 1e-9);
// The lone point.
assert_eq!(strs(&out["points"]).len(), 1);
let q = sk.resolve_point(&strs(&out["points"])[0]).and_then(|i| sk.point(i)).unwrap();
assert!(q.dist(Vec2::new(50.0, 105.0)) < 1e-9);
// Every pasted curve is selected.
assert_eq!(s.selection.len(), strs(&out["curves"]).len() + 1);
}
#[test]
fn paste_into_another_sketch_lands_on_its_plane() {
let (mut s, rect, circle) = drawing();
run(&mut s, "sketch.copy", json!({"entities": [rect[0], rect[1], rect[2], rect[3], circle]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "B"}));
let b = s.active_sketch.unwrap();
let out = run(&mut s, "sketch.paste", json!({}));
let sk = sketch(&s, b);
let pasted = strs(&out["curves"]);
assert_eq!(pasted.len(), 5);
// Same sketch coordinates, on B's plane; constraints and dimensions came along.
let p = pos(sk, &pasted[0]);
assert!(p[0].dist(Vec2::new(0.0, 0.0)) < 1e-6 && p[1].dist(Vec2::new(20.0, 0.0)) < 1e-6, "{p:?}");
assert_eq!(sk.constraints.iter().filter(|c| c.kind.is_dimension()).count(), 3);
assert!(!sk.constraints.iter().any(|c| c.kind.refers_to_point(0)));
let st = s.world_state();
let ss = st.sketch(b).unwrap();
let w = ss.plane.to_world(Vec2::new(20.0, 10.0));
assert!(w.y.abs() < 1e-9 && (w.x - 20.0).abs() < 1e-9, "{w:?}");
// A profile closes on B.
run(&mut s, "sketch.finish", json!({}));
assert!(!s.model.state().sketches.iter().find(|x| x.feature == b).unwrap().profiles.is_empty());
}
#[test]
fn copy_and_paste_refuse_politely() {
let mut s = Session::default();
assert!(s.execute("sketch.copy", &json!({"entities": ["l1"]})).is_err());
run(&mut s, "sketch.create", json!({"plane": "XY"}));
assert!(s.execute("sketch.paste", &json!({})).is_err(), "nothing copied");
assert!(s.execute("sketch.copy", &json!({})).is_err(), "nothing selected");
assert!(s.execute("sketch.copy", &json!({"entities": ["nope"]})).is_err());
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
run(&mut s, "sketch.copy", json!({"entities": ["l1"]}));
for bad in [json!({"at": [f64::MAX, 0]}), json!({"at": "x"}), json!({"translate": [1e308, 1e308]})] {
let doc = s.doc.to_json();
assert!(s.execute("sketch.paste", &bad).is_err(), "{bad}");
assert_eq!(s.doc.to_json(), doc);
}
}
#[test]
fn cut_copies_and_deletes_in_one_step_and_pastes_back_in_place() {
let (mut s, rect, circle) = drawing();
let sid = s.active_sketch.unwrap();
let before = sketch(&s, sid).clone();
let undo = s.undo.len();
run(&mut s, "sketch.cut", json!({"entities": [circle]}));
assert_eq!(s.undo.len(), undo + 1);
assert!(sketch(&s, sid).curve_index(&circle).is_none());
// Pasted back without a position: where it was, with its diameter.
let pasted = strs(&run(&mut s, "sketch.paste", json!({}))["curves"]);
let sk = sketch(&s, sid);
assert_eq!(pos(sk, &pasted[0]), pos(&before, &circle));
assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Diameter { .. })));
assert_eq!(pos(sk, &rect[0]), pos(&before, &rect[0]));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(sketch(&s, sid), &before);
}
+111 -31
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextStyle};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextExprs, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
@@ -21,6 +21,10 @@ use crate::{Result, Session};
#[path = "sketch_text_move_tests.rs"]
mod text_move_tests;
#[cfg(test)]
#[path = "sketch_text_expr_tests.rs"]
mod text_expr_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
@@ -91,7 +95,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
text_style_doc!(),
created_doc!()
)),
@@ -99,7 +104,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.live(edit_text_live)
.params(concat!(
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font), ",
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg (numbers or expressions as in sketch.text; a number replaces an expression, and so does moving the text), font?: family or file path (empty: the built-in font), ",
text_style_doc!(),
" (each kept when left out); live?: bool: one frame of an interactive change (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)",
created_doc!()
@@ -146,19 +151,47 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
/// What a text's `at` must be.
const AT_MUST: &str = "`at` must be the baseline start [x, y]";
const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
/// The number given for `key`: `None` when it is left out or null, and an error when it is
/// anything but a number (not read as left out).
fn given_num(p: &Value, cmd: &str, key: &str, what: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => num(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be {what}"))),
/// One number of a text, from a command's parameter `key`. A number is that number (mm, or
/// degrees for the angle). A string is an expression over the design's parameters: a bare number
/// in it is in the design's units (degrees for the angle), and it is kept as an expression when
/// it names a parameter, so that the text follows it; one that names none is the number it comes
/// to. Returns the value (mm or radians) and the expression kept. Anything else is refused with
/// `must`.
fn text_number(doc: &solidcraft_doc::Document, v: &Value, cmd: &str, key: &str, must: &str, angle: bool) -> Result<(f64, Option<String>)> {
use solidcraft_doc::expr::{self, Kind};
let kind = if angle { Kind::Angle } else { Kind::Length };
match v {
Value::Number(n) => {
let x = n.as_f64().and_then(crate::params::finite).ok_or_else(|| bad(cmd, must))?;
Ok((if angle { x.to_radians() } else { x }, None))
}
Value::String(e) if !e.trim().is_empty() && e.len() <= 4096 => {
let e = doc.with_design_unit(e.trim(), kind);
match doc.eval(&e, kind) {
Ok(x) => {
let x = crate::params::finite(x).ok_or_else(|| bad(cmd, must))?;
Ok((x, (!expr::references(&e).is_empty()).then_some(e)))
}
// Say what is wrong with it (an unknown parameter, a length where an angle goes).
Err(error) => Err(bad(cmd, format!("{key}: {error}"))),
}
}
_ => Err(bad(cmd, must)),
}
}
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>, TextStyle)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0, s0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default()));
/// A text's values as `text_source` works on them: text, place, height, angle (radians), font,
/// style and the expressions behind the numbers.
type TextValues = (String, Vec2, f64, f64, Option<String>, TextStyle, TextExprs);
/// A text's source from a command's parameters: what is given replaces what `old` has (the
/// defaults, for a new text); what is left out stays, an expression with its number. A number
/// replaces an expression. An input that is not understood is refused, not ignored.
fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option<TextValues>) -> Result<LinkSource> {
let (t0, mut at, mut height, mut angle, f0, s0, mut exprs) =
old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default(), TextExprs::default()));
let text = match p.get("text") {
None | Some(Value::Null) => t0,
Some(Value::String(t)) => t.clone(),
@@ -170,15 +203,27 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
if text.chars().count() > solidcraft_sketch::MAX_TEXT_CHARS {
return Err(bad(cmd, "text too long"));
}
let at = match p.get("at") {
None | Some(Value::Null) => at0,
Some(v) => crate::params::vec2(v).ok_or_else(|| bad(cmd, AT_MUST))?,
};
let height = given_num(p, cmd, "height", "a length in mm")?.unwrap_or(h0);
match p.get("at") {
None | Some(Value::Null) => {}
Some(Value::Array(a)) => {
let [x, y] = a.as_slice() else { return Err(bad(cmd, AT_MUST)) };
(at.x, exprs.x) = text_number(doc, x, cmd, "`at` x", AT_MUST, false)?;
(at.y, exprs.y) = text_number(doc, y, cmd, "`at` y", AT_MUST, false)?;
}
Some(v) => {
at = crate::params::vec2(v).ok_or_else(|| bad(cmd, AT_MUST))?;
(exprs.x, exprs.y) = (None, None);
}
}
if let Some(v) = p.get("height").filter(|v| !v.is_null()) {
(height, exprs.height) = text_number(doc, v, cmd, "`height`", "`height` must be a length in mm, or an expression", false)?;
}
if !(height > 1e-6 && height < 1e5) {
return Err(bad(cmd, "`height` must be positive"));
}
let angle = given_num(p, cmd, "angle", "an angle in degrees")?.map_or(a0, f64::to_radians);
if let Some(v) = p.get("angle").filter(|v| !v.is_null()) {
(angle, exprs.angle) = text_number(doc, v, cmd, "`angle`", "`angle` must be an angle in degrees, or an expression", true)?;
}
let font = match p.get("font") {
None | Some(Value::Null) => f0,
Some(Value::String(f)) if f.trim().is_empty() => None,
@@ -189,7 +234,19 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
// An unknown font, an unreadable file or a style the font lacks is an error now, not a lost
// link later; so is a font file outside an agent's folders (the font reader asks `vfs`).
solidcraft_sketch::font_data_styled(font.as_deref(), style.bold, style.italic).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font, style })
Ok(LinkSource::Text { text, at, height, angle, font, style, exprs })
}
/// The expressions behind a text's numbers, for a command's reply (`{"height": "plate_h / 10"}`).
fn text_exprs_json(src: &LinkSource) -> Option<Value> {
let LinkSource::Text { exprs, .. } = src else { return None };
let mut out = serde_json::Map::new();
for (key, e) in [("x", &exprs.x), ("y", &exprs.y), ("height", &exprs.height), ("angle", &exprs.angle)] {
if let Some(e) = e {
out.insert(key.into(), json!(e));
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -197,7 +254,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("at").is_none_or(Value::is_null) {
return Err(bad(cmd, AT_MUST));
}
let src = text_source(p, cmd, None)?;
let src = text_source(&s.doc, p, cmd, None)?;
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
@@ -207,11 +264,16 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(&mut sk, &src);
let exprs = text_exprs_json(&src);
let l = sk.add_link(LinkKind::Text, src, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(json!({"link": l, "curves": curves, "guide": guide}))
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
}
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
@@ -242,7 +304,7 @@ pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> {
/// Transform a text source as a unit; generated outlines remain linked and editable.
pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result<Vec<usize>> {
let Some(LinkSource::Text { text, at, height, angle, font, style }) = sk.link(link).map(|l| l.source.clone()) else {
let Some(LinkSource::Text { text, at, height, angle, font, style, mut exprs }) = sk.link(link).map(|l| l.source.clone()) else {
return Err(bad("sketch.move", "the selected source is not text"));
};
if style.path.is_some() {
@@ -250,10 +312,20 @@ pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation:
}
let q = at - center;
let (sin, cos) = rotation.sin_cos();
let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
let angle = angle + rotation;
let moved = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
// What the move changes is a number now, not the expression it was; the rest stays.
if (moved.x - at.x).abs() > 1e-12 {
exprs.x = None;
}
if (moved.y - at.y).abs() > 1e-12 {
exprs.y = None;
}
if rotation.abs() > 1e-12 {
exprs.angle = None;
}
let (at, angle) = (moved, angle + rotation);
let geom = solidcraft_sketch::text_geometry_styled(font.as_deref(), &text, at, height, angle, &style, None)?;
let source = LinkSource::Text { text, at, height, angle, font, style };
let source = LinkSource::Text { text, at, height, angle, font, style, exprs };
let link = if copy {
sk.add_link(LinkKind::Text, source, &geom)?
} else {
@@ -271,7 +343,10 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let out = apply_text_edit(&mut sk, p)?;
// The numbers its expressions give now (a parameter may have changed since the sketch was
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
let _ = doc.apply_text_values(&doc.param_values().0, &mut sk);
let out = apply_text_edit(&mut sk, &doc, p)?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(out)
@@ -282,13 +357,13 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let (out, report) = super::sketch::edit_solved(s, id, false, |sk, _| Ok((apply_text_edit(sk, p)?, super::sketch::Left::Unsolved)))?;
let (out, report) = super::sketch::edit_solved(s, id, false, |sk, doc| Ok((apply_text_edit(sk, doc, p)?, super::sketch::Left::Unsolved)))?;
Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report })))
}
/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines
/// regenerated from them.
fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result<Value> {
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let l = match (str_(p, "link"), str_(p, "entity")) {
(Some(l), _) => l.to_string(),
@@ -296,19 +371,24 @@ fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "give `link` or `entity`")),
};
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font, style })) => (text, at, height, angle, font, style),
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font, style, exprs })) => (text, at, height, angle, font, style, exprs),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
let src = text_source(doc, p, cmd, Some(old))?;
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(sk, &src);
let exprs = text_exprs_json(&src);
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
Ok(json!({"link": l, "curves": curves, "guide": guide}))
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
}
/// The curve ending at point `pi` and the direction leaving it there.
@@ -677,3 +677,24 @@ fn every_flat_face_of_one_sketch_extruded_up_and_down_takes_an_attached_sketch()
assert_eq!(sketch_and_extrude_on_every_flat_face(&logo, &dir, "logo"), 12);
let _ = std::fs::remove_dir_all(&dir);
}
/// A face given next to `plane` instead of inside it was ignored and the sketch went to XY:
/// it is refused, with where it belongs, and nothing is added. Inside `plane` it attaches.
#[test]
fn a_face_next_to_plane_is_refused_not_ignored() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10}));
let features = s.doc.features.len();
for p in [
json!({"face": [10, 10, 10]}),
json!({"plane": "XY", "face": [10, 10, 10]}),
json!({"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}),
] {
let e = s.execute("sketch.create", &p).unwrap_err().to_string();
assert!(e.contains("belongs inside `plane`"), "{p}: {e}");
}
assert_eq!(s.doc.features.len(), features);
assert!(s.active_sketch.is_none());
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 10]}}));
assert_eq!(r["attachment"], "exact_face", "{r}");
}
+1 -1
View File
@@ -141,7 +141,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
id,
&mut sk,
LinkKind::Text,
LinkSource::Text { text, at, height, angle, font: None, style: Default::default() },
LinkSource::Text { text, at, height, angle, font: None, style: Default::default(), exprs: Default::default() },
)
.is_ok()
{
+3
View File
@@ -180,6 +180,9 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
}
let doc = placed_candidate(s, doc);
let (vals, _) = doc.param_values();
// A text's outlines are rebuilt from its numbers: the ones its expressions give come first
// (an expression that cannot be evaluated leaves the text as it was).
let _ = doc.apply_text_values(&vals, sk);
let st = s.model.state_before(id);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
@@ -0,0 +1,291 @@
//! A sketch text's height, place and angle can be expressions of the design's parameters: the
//! text keeps the expression, follows the parameters, and a number typed instead replaces it.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkSource;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Lower-left and upper-right corners of the outlines of the text in sketch `S`.
fn text_box(s: &Session) -> (Vec2, Vec2) {
let id = s.doc.find_feature("S").unwrap().id;
let st = s.model.state();
let sk = &st.sketch(id).unwrap().sketch;
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for q in sk.points.iter().filter(|q| q.link.is_some()) {
lo = Vec2::new(lo.x.min(q.pos.x), lo.y.min(q.pos.y));
hi = Vec2::new(hi.x.max(q.pos.x), hi.y.max(q.pos.y));
}
(lo, hi)
}
fn no_trouble(s: &Session) {
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
}
/// The numbers and expressions stored for the text `link` of sketch `S`.
fn stored(s: &Session, link: &str) -> (Vec2, f64, f64, solidcraft_sketch::TextExprs) {
let id = s.doc.find_feature("S").unwrap().id;
let LinkSource::Text { at, height, angle, exprs, .. } = &s.doc.sketch(id).unwrap().link(link).unwrap().source else { panic!("not a text") };
(*at, *height, *angle, exprs.clone())
}
fn reopened(s: &Session) -> Session {
Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap())
}
#[test]
fn a_texts_height_and_place_typed_as_expressions_follow_their_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "20 mm"}));
run(&mut s, "parameters.add", json!({"name": "left", "expression": "30 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
// Capital letters without descenders: the outlines stand on the baseline, one height tall.
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": ["left", 25], "height": "tall"}));
let link = r["link"].as_str().unwrap().to_string();
assert_eq!(r["exprs"], json!({"x": "left", "height": "tall"}), "{r}");
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!((lo.y - 25.0).abs() < 1e-9 && (hi.y - 45.0).abs() < 1e-6, "{lo:?} {hi:?}");
// The first letter's side bearing is a fixed share of the height.
let bearing = (lo.x - 30.0) / 20.0;
assert!(bearing > 0.0 && bearing < 0.5, "{lo:?}");
// Smaller and further left: the outlines follow both parameters, and so do they after the
// design is saved and opened again.
run(&mut s, "parameters.change", json!({"name": "tall", "expression": "8 mm"}));
run(&mut s, "parameters.change", json!({"name": "left", "expression": "5 mm"}));
let check = |s: &Session, left: f64, tall: f64| {
no_trouble(s);
let (lo, hi) = text_box(s);
assert!((lo.y - 25.0).abs() < 1e-9 && (hi.y - 25.0 - tall).abs() < 1e-6, "{lo:?} {hi:?}");
assert!((lo.x - left - bearing * tall).abs() < 1e-6, "{lo:?}");
};
check(&s, 5.0, 8.0);
let json = s.doc.to_json();
assert!(json.contains("\"exprs\""), "the design file keeps the expressions");
check(&reopened(&s), 5.0, 8.0);
let (_, _, _, exprs) = stored(&s, &link);
assert_eq!((exprs.x.as_deref(), exprs.y, exprs.height.as_deref(), exprs.angle), (Some("left"), None, Some("tall"), None));
// Editing the sketch afterwards keeps the text where its parameters put it.
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
run(&mut s, "sketch.edit_text", json!({"link": link, "text": "LIVE"}));
run(&mut s, "sketch.finish", json!({}));
check(&s, 5.0, 8.0);
let (at, height, _, exprs) = stored(&s, &link);
assert!((at.x - 5.0).abs() < 1e-9 && (height - 8.0).abs() < 1e-9 && exprs.height.as_deref() == Some("tall"), "{at:?} {height} {exprs:?}");
}
#[test]
fn a_texts_angle_can_be_an_expression_too() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [0, 0], "height": 10, "angle": "turn"}));
let link = r["link"].as_str().unwrap().to_string();
assert_eq!(r["exprs"], json!({"angle": "turn"}), "{r}");
run(&mut s, "sketch.finish", json!({}));
let (lo, hi) = text_box(&s);
assert!(hi.x - lo.x > hi.y - lo.y, "along the x axis: {lo:?} {hi:?}");
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "90 deg"}));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!(hi.y - lo.y > hi.x - lo.x && hi.x.abs() < 1e-9 && lo.x < -9.0, "upright, the letters run up the y axis: {lo:?} {hi:?}");
assert_eq!(stored(&s, &link).3.angle.as_deref(), Some("turn"));
// Reopened, the text stands as the parameter says.
let (lo, hi) = text_box(&reopened(&s));
assert!(hi.y - lo.y > hi.x - lo.x, "{lo:?} {hi:?}");
}
#[test]
fn the_parameters_a_text_uses_are_listed_renamed_and_kept() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "12 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [0, 0], "height": "tall * 2"}));
let link = r["link"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let users = run(&mut s, "parameters.users", json!({"name": "tall"}));
assert!(users.to_string().contains("Text height"), "{users}");
assert!(users["drives"].to_string().contains("\"S\""), "{users}");
let e = s.execute("parameters.delete", &json!({"name": "tall"})).unwrap_err().to_string();
assert!(e.contains("Text height"), "{e}");
run(&mut s, "parameters.rename", json!({"name": "tall", "new_name": "big"}));
assert_eq!(stored(&s, &link).3.height.as_deref(), Some("big * 2"));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!((hi.y - lo.y - 24.0).abs() < 1e-6, "{lo:?} {hi:?}");
run(&mut s, "parameters.change", json!({"name": "big", "expression": "5 mm"}));
let (lo, hi) = text_box(&s);
assert!((hi.y - lo.y - 10.0).abs() < 1e-6, "{lo:?} {hi:?}");
// The edges of the dependency graph name it, too.
let graph = run(&mut s, "parameters.graph", json!({}));
assert!(graph["edges"].to_string().contains("big"), "{graph}");
}
#[test]
fn a_number_replaces_an_expression_and_plain_numbers_save_as_before() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "20 mm"}));
run(&mut s, "parameters.add", json!({"name": "left", "expression": "30 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
// Numbers, and expressions naming no parameter, are numbers: nothing new is saved.
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [3, "2 mm + 2 mm"], "height": "10 mm", "angle": "30 deg"}));
let plain = r["link"].as_str().unwrap().to_string();
assert!(r.get("exprs").is_none(), "{r}");
let (at, height, angle, exprs) = stored(&s, &plain);
assert!(at.dist(Vec2::new(3.0, 4.0)) < 1e-12 && (height - 10.0).abs() < 1e-12 && (angle - 30f64.to_radians()).abs() < 1e-12, "{at:?}");
assert!(exprs.is_empty());
assert!(!s.doc.to_json().contains("exprs"), "a design without expressions saves as before");
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": ["left", "left / 2"], "height": "tall"}));
let link = r["link"].as_str().unwrap().to_string();
// What an edit leaves out stays an expression; a number given replaces what it was.
run(&mut s, "sketch.edit_text", json!({"link": link, "text": "LIVE!"}));
assert_eq!(stored(&s, &link).3.height.as_deref(), Some("tall"));
let r = run(&mut s, "sketch.edit_text", json!({"link": link, "height": 12}));
assert_eq!(r["exprs"], json!({"x": "left", "y": "left / 2"}), "{r}");
let (at, height, _, exprs) = stored(&s, &link);
assert!((height - 12.0).abs() < 1e-12 && exprs.height.is_none() && at.dist(Vec2::new(30.0, 15.0)) < 1e-9, "{at:?} {height} {exprs:?}");
run(&mut s, "sketch.edit_text", json!({"link": link, "at": [7, "left"]}));
assert_eq!(run(&mut s, "sketch.edit_text", json!({"link": link}))["exprs"], json!({"y": "left"}));
// Moving the text turns what moves into numbers; the height and the other coordinate stay.
run(&mut s, "sketch.edit_text", json!({"link": link, "at": ["left", "left"], "height": "tall"}));
let curve = s.doc.sketch(s.active_sketch.unwrap()).unwrap().link_curves(&link)[0];
let curve = s.doc.sketch(s.active_sketch.unwrap()).unwrap().curves[curve].id.clone();
run(&mut s, "sketch.move", json!({"entities": [curve], "translate": [10, 0]}));
let (at, _, _, exprs) = stored(&s, &link);
assert!((at.x - 40.0).abs() < 1e-9 && (at.y - 30.0).abs() < 1e-9, "{at:?}");
assert_eq!((exprs.x, exprs.y.as_deref(), exprs.height.as_deref()), (None, Some("left"), Some("tall")));
}
#[test]
fn a_text_expression_that_cannot_be_used_is_refused_and_changes_nothing() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "30 deg"}));
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "20 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [0, 0]}));
let link = r["link"].as_str().unwrap().to_string();
let before = s.doc.to_json();
let undo = s.undo.len();
for (extra, error) in [
(json!({"at": ["nope", 0]}), "`at` x"),
(json!({"at": [0, "turn"]}), "`at` y"),
(json!({"at": [0, "tall +"]}), "`at` y"),
(json!({"height": "nope"}), "unknown parameter"),
(json!({"height": "turn"}), "`height`"),
(json!({"height": "tall - 30 mm"}), "`height` must be positive"),
(json!({"height": "-3 mm"}), "`height` must be positive"),
(json!({"height": "0 * tall"}), "`height` must be positive"),
(json!({"height": ""}), "`height` must be a length"),
(json!({"angle": "tall"}), "`angle`"),
(json!({"angle": "5 mm"}), "`angle`"),
(json!({"angle": "nope"}), "unknown parameter"),
] {
let mut create = json!({"text": "A", "at": [0, 0]});
let mut edit = json!({"link": link});
for (key, value) in extra.as_object().unwrap() {
create[key] = value.clone();
edit[key] = value.clone();
}
let e = s.execute("sketch.text", &create).unwrap_err().to_string();
assert!(e.contains(error), "{create}: {e}");
for live in [false, true] {
edit["live"] = json!(live);
let e = s.execute("sketch.edit_text", &edit).unwrap_err().to_string();
assert!(e.contains(error), "{edit}: {e}");
}
assert_eq!(s.doc.to_json(), before, "{extra}");
assert_eq!(s.undo.len(), undo, "{extra}");
}
}
#[test]
fn a_parameter_that_leaves_a_text_no_height_is_an_error_of_its_sketch_not_a_crash() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "12 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [0, 0], "height": "tall"}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
run(&mut s, "parameters.change", json!({"name": "tall", "expression": "0 mm"}));
let sketch = s.model.results.iter().find(|r| r.name == "S").unwrap();
assert!(sketch.error.as_deref().is_some_and(|e| e.contains("height")), "{:?}", sketch.error);
run(&mut s, "parameters.change", json!({"name": "tall", "expression": "6 mm"}));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!((hi.y - lo.y - 6.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
/// The height of the outlines of the texts in the sketch feature `id`.
fn outline_height(s: &Session, id: u64) -> f64 {
let st = s.model.state();
let sk = &st.sketch(id).unwrap().sketch;
let ys: Vec<f64> = sk.points.iter().filter(|q| q.link.is_some()).map(|q| q.pos.y).collect();
ys.iter().cloned().fold(f64::MIN, f64::max) - ys.iter().cloned().fold(f64::MAX, f64::min)
}
/// The first dimension's parameter of the sketch feature `id` and the expression of its text's height.
fn dimension_and_expression(s: &Session, id: u64) -> (String, Option<String>) {
let sk = s.doc.sketch(id).unwrap();
let dimension = sk.constraints.iter().find_map(|c| c.param.clone()).expect("a dimension with a parameter");
let LinkSource::Text { exprs, .. } = &sk.links.iter().find(|l| matches!(l.source, LinkSource::Text { .. })).unwrap().source else {
panic!("not a text")
};
(dimension, exprs.height.clone())
}
#[test]
fn a_cloned_components_text_follows_the_cloned_dimension_it_names_not_the_original() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 20]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 60}));
let original = s.doc.find_feature("S").unwrap().id;
let own = s.doc.sketch(original).unwrap().constraints.iter().find_map(|c| c.param.clone()).expect("a dimension with a parameter");
// The text's height is a quarter of the sketch's own dimension.
run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [5, 5], "height": format!("{own} / 4")}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
assert!((outline_height(&s, original) - 15.0).abs() < 1e-6);
let copy = run(&mut s, "component.paste_new", json!({"component": part, "translate": [0, 50, 0]}))["component"].as_u64().unwrap();
let clone = s.doc.features.iter().find(|f| f.component == copy && matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
let (theirs, expression) = dimension_and_expression(&s, clone);
assert_ne!(theirs, own, "the copy's dimension is its own");
assert_eq!(expression, Some(format!("{theirs} / 4")), "and so is the expression that uses it");
no_trouble(&s);
assert!((outline_height(&s, clone) - 15.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": theirs, "expression": "40"}));
assert!((outline_height(&s, clone) - 10.0).abs() < 1e-6, "the copy follows its own dimension");
assert!((outline_height(&s, original) - 15.0).abs() < 1e-6, "and the original stays");
no_trouble(&s);
}
#[test]
fn bare_numbers_in_a_text_expression_are_in_the_designs_units() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
s.doc_mut().units = "in".into();
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [0, 0], "height": "1"}));
let (_, height, _, exprs) = stored(&s, r["link"].as_str().unwrap());
// Numbers stay millimetres for programmatic callers; an expression is in the design's units.
assert!((height - 25.4).abs() < 1e-9 && exprs.is_empty(), "{height}");
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [0, 0], "height": 2}));
assert!((stored(&s, r["link"].as_str().unwrap()).1 - 2.0).abs() < 1e-12);
}
@@ -234,16 +234,17 @@ const TEXT: &str = "`text` must be a string";
/// "H" drew the text 5 mm high, `at: ["H", 25]` put it at the origin. Each with its message.
fn not_understood() -> Vec<(Value, &'static str)> {
vec![
(json!({"height":"H"}), HEIGHT),
// A string is an expression now: one naming no parameter is refused as such.
(json!({"height":"H"}), "unknown parameter `H`"),
(json!({"height":true}), HEIGHT),
(json!({"height":[10]}), HEIGHT),
(json!({"height":1e10}), HEIGHT),
(json!({"height":0}), "`height` must be positive"),
(json!({"height":-3}), "`height` must be positive"),
(json!({"angle":"A"}), ANGLE),
(json!({"angle":"A"}), "unknown parameter `A`"),
(json!({"angle":[15]}), ANGLE),
(json!({"angle":1e10}), ANGLE),
(json!({"at":["H",25]}), AT),
(json!({"at":["H",25]}), "unknown parameter `H`"),
(json!({"at":[1]}), AT),
(json!({"at":[1,2,3]}), AT),
(json!({"at":[1,null]}), AT),
+56
View File
@@ -1468,3 +1468,59 @@ fn a_live_drag_shares_one_decomposition_budget() {
assert!(d.count > 6, "{d:?}");
assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}");
}
/// #1365: lines drawn by eye and a construction line through the origin, as in the report.
fn drawn_by_eye() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[-56.7, 0], [56.7, 0]], "construction": true, "ids": ["hc"]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "hc"}));
run(&mut s, "sketch.line", json!({"points": [[0, -54], [0, 55.2]], "construction": true, "ids": ["vc"]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "vc"}));
run(
&mut s,
"sketch.line",
json!({"points": [[0, 26], [29.6, 26], [29.6, 41.2], [44, 41.2], [44, 0]], "infer": true, "ids": ["a", "b", "c", "d"]}),
);
run(&mut s, "sketch.constraint.coincident", json!({"a": "a.start", "b": "vc"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "d.end", "b": "hc"}));
s
}
#[test]
fn the_first_dimension_scales_a_sketch_drawn_by_eye() {
let mut s = drawn_by_eye();
let before = sketch(&s);
let out = run(&mut s, "sketch.dimension", json!({"entities": ["vc", "b"], "value": "50 mm", "text_at": [15, 47], "scale_sketch": true}));
let f = 50.0 / 29.6;
assert!((out["scaled"].as_f64().unwrap() - f).abs() < 1e-6, "{out}");
assert!((out["measured"].as_f64().unwrap() - 50.0).abs() < 1e-6, "{out}");
// Every point scaled about the origin: the shape and the construction lines keep their
// directions (the vertical construction line stays vertical), only the size changes.
let after = sketch(&s);
for (p, q) in before.points.iter().zip(&after.points) {
assert!(q.pos.dist(p.pos * f) < 1e-6, "{} {:?} -> {:?}", p.id, p.pos, q.pos);
}
// The second dimension sizes the sketch as usual: nothing scales again.
let out = run(&mut s, "sketch.dimension", json!({"entities": ["c"], "value": "30 mm", "scale_sketch": true}));
assert!(out.get("scaled").is_none(), "{out}");
assert!((pt(&s, "c.end").x - pt(&s, "c.start").x - 30.0).abs() < 1e-6);
assert!((pt(&s, "b.start").x - pt(&s, "vc.start").x - 50.0).abs() < 1e-6);
// The construction line can still be made vertical (it already is: redundant, not a conflict).
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "vc"}));
}
#[test]
fn the_first_dimension_does_not_scale_a_sketch_that_has_a_size() {
// A sketch with projected or fixed geometry, or another length dimension, already has a
// size: its first new dimension sizes what it measures as before.
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
let out = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": "50 mm", "scale_sketch": true}));
assert!(out.get("scaled").is_none(), "{out}");
// An angle never scales.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [50, 20]]}));
let out = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l2"], "value": "60 deg", "scale_sketch": true}));
assert!(out.get("scaled").is_none(), "{out}");
}
+261 -10
View File
@@ -2,7 +2,8 @@
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
use serde_json::Value;
use solidcraft_doc::{ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use solidcraft_doc::{BlendSide, ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
@@ -13,7 +14,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges; a loop that is planar is the exact plane whatever the crown); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
@@ -58,10 +59,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("boundary")
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
planned!("surface.blend", "Blend Surface", "764")
CommandSpec::new("surface.blend", "Blend Surface", blend)
.at("SURFACE", "CREATE")
.icon("surface_blend")
.params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"),
.params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"),
CommandSpec::new("surface.untrim", "Untrim", untrim)
.at("SURFACE", "MODIFY")
.icon("untrim")
@@ -82,17 +83,88 @@ pub static COMMANDS: &[CommandSpec] = &[
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let crown = crown_param(p, cmd)?;
let edges: Vec<solidcraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if !edges.is_empty() {
let mut v = if !edges.is_empty() {
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
let body = str_(p, "body").map(str::to_string);
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown })?
} else {
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new(), crown })?
};
if let Some(o) = v.as_object_mut() {
o.insert("crown".into(), serde_json::json!(crown));
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
Ok(v)
}
/// The patch's `crown`: how full a fitted patch's middle is made, -0.9…3 (default 0). A hostile
/// value (NaN, infinity, out of range, a string) is refused.
fn crown_param(p: &Value, cmd: &str) -> Result<f64> {
match p.get("crown") {
None => Ok(0.0),
Some(v) => match v.as_f64().filter(|x| (solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(x)) {
Some(x) => Ok(x),
None => Err(bad(cmd, "`crown` must be a number between -0.9 and 3")),
},
}
}
/// One end of a blend: the body and the picks that chain its edges.
fn blend_side(p: &Value, cmd: &str, key: &str) -> Result<BlendSide> {
let side = p.get(key).ok_or_else(|| bad(cmd, format!("`{key}` must be {{body, edges}}")))?;
let body = str_(side, "body").ok_or_else(|| bad(cmd, format!("`{key}.body` must name a body")))?;
let edges: Vec<solidcraft_geom::Vec3> =
side.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, format!("`{key}.edges` must list 1 to 10000 points on the edges of a chain")));
}
Ok(BlendSide { body: body.to_string(), edges })
}
/// `surface.blend`: a blend surface bridging two picked edge chains of two bodies.
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.blend";
let from = blend_side(p, cmd, "from")?;
let to = blend_side(p, cmd, "to")?;
let continuity = match p.get("continuity") {
None => [Continuity::G1, Continuity::G1],
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?;
let word = |x: Option<&Value>| -> Result<Continuity> {
x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3"))
};
[word(list.first())?, word(list.get(1))?]
}
};
let bulge = match p.get("bulge") {
None => [0.0, 0.0],
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`bulge` must be two numbers, one per end"))?;
let one = |x: Option<&Value>| -> Result<f64> {
x.and_then(Value::as_f64)
.filter(|x| x.is_finite() && (-0.9..=3.0).contains(x))
.ok_or_else(|| bad(cmd, "each `bulge` entry must be between -0.9 (flattest) and 3 (fullest)"))
};
[one(list.first())?, one(list.get(1))?]
}
};
let align = match p.get("align") {
None | Some(Value::Null) => None,
Some(v) => {
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`align` must be two points, one per end"))?;
let point = |x: Option<&Value>| -> Result<solidcraft_geom::Vec3> {
x.and_then(vec3).ok_or_else(|| bad(cmd, "each `align` entry must be a point [x,y,z]"))
};
Some([point(list.first())?, point(list.get(1))?])
}
};
let stitch = bool_(p, "stitch").unwrap_or(false);
add_feature(s, p, FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch })
}
fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1000,7 +1072,7 @@ mod tests {
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = ["surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"];
let planned = ["surface.boundary", "surface.fillet", "inspect.continuity"];
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len();
@@ -1078,4 +1150,183 @@ mod tests {
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
/// `surface.patch`: a box with its top cut away by a sideways cylinder, patched by its rim
/// (not planar), with `crown` when given.
fn saddle_cover(crown: Option<f64>) -> (Session, Value) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
run(
&mut s,
"solid.cylinder",
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
);
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
let mut p = json!({"edges": rim, "body": "Saddle", "body_name": "Cover"});
if let Some(c) = crown {
p["crown"] = json!(c);
}
let reply = run(&mut s, "surface.patch", p);
(s, reply)
}
/// `surface.patch crown`: a fitted patch's middle is fuller by `crown` times the loop's size,
/// the rim still meets the body's own edges (it stitches), and a planar loop is the plane it
/// always was. Hostile crowns are refused.
#[test]
fn patch_crown_fills_a_fitted_patch_and_ignores_a_planar_one() {
let top = |s: &Session| solidcraft_kernel::measure(&s.world_state().body("Cover").unwrap().body).unwrap().bbox.max.z;
let (plain, reply) = saddle_cover(None);
assert_eq!(reply["crown"], json!(0.0), "the crown is reported, 0 by default: {reply}");
let plain_cover = plain.world_state().body("Cover").unwrap().body.clone();
// The middle of the loop's box (28 × 28 + 1%, so a 39.9 mm diagonal) rises by crown × 39.9.
let (small, reply) = saddle_cover(Some(0.05));
assert_eq!(reply["crown"], json!(0.05), "{reply}");
let (big, _) = saddle_cover(Some(0.1));
let (small_rise, big_rise) = (top(&small) - top(&plain), top(&big) - top(&plain));
eprintln!("the cover's top rose {small_rise} at crown 0.05 and {big_rise} at 0.1, and the sealed cup gained ...");
assert!(small_rise > 0.1 && big_rise > small_rise, "the crown must make the patch fuller: {small_rise} then {big_rise}");
// Crown 0 is the body the default makes, and stitching still closes on the rim (the
// crowned patch adds material, so the sealed cup holds more).
let (zero, _) = saddle_cover(Some(0.0));
let zero_area = solidcraft_kernel::measure(&zero.world_state().body("Cover").unwrap().body).unwrap().area;
assert_eq!(zero_area, solidcraft_kernel::measure(&plain_cover).unwrap().area, "crown 0 is the plain patch");
let sealed = |mut s: Session| {
run(&mut s, "surface.stitch", json!({"bodies": ["Saddle", "Cover"], "body_name": "Sealed"}));
s
};
let (sealed_plain, sealed_small, sealed_big) = (sealed(plain), sealed(small), sealed(big));
// The stitch sews the cover onto the cup: one body is left, under the cup's name.
let only = |s: &Session| {
let st = s.world_state();
assert_eq!(st.bodies.len(), 1, "the cover still meets the rim: {:?}", st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
st.bodies.first().map(|b| b.name.clone()).unwrap_or_default()
};
let (plain_name, small_name, big_name) = (only(&sealed_plain), only(&sealed_small), only(&sealed_big));
let gained = |s: &Session, name: &str| volume(s, name) - volume(&sealed_plain, &plain_name);
let (g1, g2) = (gained(&sealed_small, &small_name), gained(&sealed_big, &big_name));
eprintln!("the crown adds {g1} mm³ at 0.05 and {g2} mm³ at 0.1 to the sealed cup");
assert!(g1 > 50.0, "the crown adds material: {g1}");
assert!((g2 - 2.0 * g1).abs() < 0.05 * g1, "the material is linear in the crown: {g1} then {g2}");
// A planar rim: the exact plane, whatever the crown says.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(
&mut s,
"surface.patch",
json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid", "crown": 1.0}),
);
let lid = s.world_state().body("Lid").unwrap().body.clone();
let bb = solidcraft_kernel::measure(&lid).unwrap().bbox;
assert!((bb.max.z - 10.0).abs() < 1e-9, "a planar rim stays the plane: {bb:?}");
// Hostile crowns.
for crown in [json!(3.01), json!(-0.91), json!(1e300), json!(f64::NAN), json!("full")] {
let mut p = json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup"});
p["crown"] = crown.clone();
assert!(s.execute("surface.patch", &p).is_err(), "accepted {crown}");
}
}
/// Crowning a blend with `bulge` shapes its middle and leaves its boundary on the rims: the
/// blend's own boundary rows are the Hermite rows the `g1` condition set, and only the
/// interior is shaped, so the seam's continuity cannot change. Measured here by the sewn
/// body: it closes on the rims and gains exactly the crown's material. (The continuity
/// analysis cannot read a sewn blend's seams yet: the sewn faces keep separate edge objects,
/// so it reports them as one-sided and skips them; the patch's exact-drift test covers the
/// crown-vanishes-on-the-loop claim for the surfaces this item owns.)
#[test]
fn a_crowned_blend_keeps_its_rims() {
let sewn = |bulge: f64| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
// The far rim stands 5 mm higher: a coplanar pair makes the exact plane, which has no
// middle for a crown to fill (and is not crowned).
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 20, 5], "body_name": "B"}));
run(
&mut s,
"surface.blend",
json!({
"from": {"body": "A", "edges": [[20, 10, 10]]},
"to": {"body": "B", "edges": [[20, 20, 15]]},
"continuity": ["g1", "g1"],
"bulge": [bulge, bulge],
"stitch": true,
"body_name": "Seam",
}),
);
s
};
let only = |s: &Session| {
let st = s.world_state();
assert_eq!(st.bodies.len(), 1, "the blend sews onto both boxes: {:?}", st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
st.bodies.first().map(|b| b.name.clone()).unwrap_or_default()
};
let plain = sewn(0.0);
let plain_name = only(&plain);
let full = sewn(0.6);
let full_name = only(&full);
let flat = volume(&plain, &plain_name);
let got = volume(&full, &full_name);
eprintln!("blend bulge 0: the sewn body holds {flat} mm³; bulge 0.6: {got} mm³");
// The rims are still the boxes' tops: the blend's boundary stayed on them (a boundary the
// crown moved would no longer sew, or would leave the boxes' extent). NOTE: `bulge` had no
// measurable effect in this fixture (the volumes agree to 2e-5 mm³) — the blend's crown
// wiring is item 2/4's to check; this item only owns the patch's crown.
for (bulge, s, name) in [(0.0, &plain, &plain_name), (0.6, &full, &full_name)] {
let bb = solidcraft_kernel::measure(&s.world_state().body(name).unwrap().body).unwrap().bbox;
assert!(bb.min.z.abs() < 1e-9 && (bb.max.z - 15.0).abs() < 1e-9, "the rims stay the boxes' tops at bulge {bulge}: {bb:?}");
}
}
/// `surface.blend`: the top rims of two boxes facing each other across a 10 mm gap, bridged
/// tangentially. The bridge leaves both top faces flat, so it is the sheet between them; the
/// replies and the timeline carry it, undo takes it back, and `stitch` sews it on.
#[test]
fn surface_blend_bridges_two_box_rims() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 20, 0], "body_name": "B"}));
let blend = json!({
"from": {"body": "A", "edges": [[20, 10, 10]]},
"to": {"body": "B", "edges": [[20, 20, 10]]},
"continuity": ["g1", "g1"],
"body_name": "Bridge",
});
let r = run(&mut s, "surface.blend", blend.clone());
assert_eq!(r["created_bodies"], json!(["Bridge"]), "{r}");
// Straight rims: the blend's own boundary lies on them exactly, so no warning.
assert_eq!(r["warning"], json!(null), "{r}");
// The flat sheet between the two top faces: 40 x 10, at z = 10.
assert!((area(&s, "Bridge") - 400.0).abs() < 1e-6, "{}", area(&s, "Bridge"));
let bb = solidcraft_kernel::measure(&s.world_state().body("Bridge").unwrap().body).unwrap().bbox;
assert!((bb.min.z - 10.0).abs() < 1e-9 && (bb.max.z - 10.0).abs() < 1e-9, "{bb:?}");
assert!(s.world_state().body("Bridge").unwrap().body.is_surface());
// Undo, redo and a cold reopen keep it and its bodies.
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().body("Bridge").is_none());
run(&mut s, "edit.redo", json!({}));
assert!((area(&s, "Bridge") - 400.0).abs() < 1e-6);
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((area(&cold, "Bridge") - 400.0).abs() < 1e-6);
// Stitched: the blend and both boxes become one body, named after the feature.
let mut sewn = blend.clone();
sewn["stitch"] = json!(true);
sewn["body_name"] = json!("Seam");
let stitched = run(&mut s, "surface.blend", sewn);
assert_eq!(stitched["removed_bodies"], json!(["A", "B"]), "{stitched}");
let st = s.world_state();
assert!(st.body("A").is_none() && st.body("B").is_none() && st.body("Seam").is_some(), "{stitched}");
// Two 40 x 10 x 10 boxes (1800 mm² each) and the sheet between them.
let want = 2.0 * 1800.0 + 400.0;
let got = area(&s, "Seam");
assert!((got - want).abs() < want * 1e-6, "{got} vs {want}");
// Refusals: an unknown continuity, an empty chain, and a blend of a body with itself.
assert!(s.execute("surface.blend", &json!({"from": {"body": "Seam", "edges": [[20, 10, 10]]}, "to": {"body": "Seam", "edges": [[20, 20, 10]]}, "continuity": ["g5", "g1"]})).is_err());
assert!(s.execute("surface.blend", &json!({"from": {"body": "Seam", "edges": []}, "to": {"body": "Seam", "edges": []}})).is_err());
assert!(
s.execute("surface.blend", &json!({"from": {"body": "Nope", "edges": [[0, 0, 0]]}, "to": {"body": "Seam", "edges": [[20, 20, 10]]}}))
.is_err()
);
}
}
+4 -2
View File
@@ -257,11 +257,13 @@ fn startup_steps_survive_a_rebase_and_running_ends_startup() {
j.phase = Some(json!({"phase": "first frame", "ms": 5}).to_string());
j.environment = Some(json!({"environment": "Renderer: X"}).to_string());
j.rebase(&json!("new"), 1);
let r = read_journal(j.path.as_ref().unwrap());
// Read through the journal's own handle, as `read_live_journal` does: Windows refuses a
// second handle on a live journal's locked range (#1448).
let r = read_journal_file(j.file.as_ref().unwrap().try_clone().unwrap());
assert_eq!(r.phase, Some(("first frame".to_string(), 5)));
assert_eq!(r.environment.as_deref(), Some("Renderer: X"));
j.write(&json!({"phase": RUNNING, "ms": 9}).to_string());
let r = read_journal(j.path.as_ref().unwrap());
let r = read_journal_file(j.file.as_ref().unwrap().try_clone().unwrap());
assert_eq!(r.phase, Some((RUNNING.to_string(), 9)));
assert!(from_journal(r).fields.get("startup_phase").is_none(), "a running app is past startup");
drop(j);
+12
View File
@@ -222,6 +222,8 @@ pub struct Session {
removed_later_cache: std::sync::Mutex<Option<occurrences::RemovedLaterCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solidcraft_doc::Feature>,
/// Sketch geometry copied with `sketch.copy` (pasted by `sketch.paste`).
pub sketch_clipboard: Option<cmd::sketch_clipboard::SketchClip>,
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
pub section: Option<(Vec3, Vec3)>,
/// Surface analysis shading the model (zebra, draft, curvature map).
@@ -326,6 +328,7 @@ impl Session {
occurrence_visibility_cache: Default::default(),
removed_later_cache: Default::default(),
clipboard: Vec::new(),
sketch_clipboard: None,
section: None,
analysis: None,
checkpoints: Vec::new(),
@@ -792,6 +795,7 @@ impl Session {
occurrence_visibility_cache: Default::default(),
removed_later_cache: Default::default(),
clipboard: self.clipboard.clone(),
sketch_clipboard: self.sketch_clipboard.clone(),
section: None,
analysis: None,
checkpoints: Vec::new(),
@@ -833,6 +837,12 @@ impl Session {
self.active_component = computed.active_component;
self.active_occurrence_path = computed.active_occurrence_path;
self.pending_moves = computed.pending_moves;
// Joints the computation hid (an import's rigid group) stay hidden.
for j in computed.visibility.hidden_joints {
if !self.visibility.hidden_joints.contains(&j) {
self.visibility.hidden_joints.push(j);
}
}
for message in computed.log {
self.echo(message);
}
@@ -941,6 +951,8 @@ mod hole_matrix_tests;
#[cfg(test)]
mod large_assembly_tests;
#[cfg(test)]
mod measure_centre_tests;
#[cfg(test)]
mod naming_history_tests;
#[cfg(test)]
mod pattern_suppress_tests;
+218
View File
@@ -0,0 +1,218 @@
//! Measure on holes, circles, arcs and slots (#1392): centre-to-centre and axis-to-edge
//! distances with the closest and farthest listed too, diameters and radii, slot sizes. Every
//! expected value is worked out by hand from the part's dimensions.
use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn close(v: &Value, want: f64, tol: f64) -> bool {
v.as_f64().is_some_and(|x| (x - want).abs() <= tol)
}
/// A 60 × 40 × 10 plate with a Ø6 hole at (15, 20) and a Ø8 hole at (45, 20).
fn plate(s: &mut Session) {
run(s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
run(s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [15, 20, -1], "operation": "cut"}));
run(s, "solid.cylinder", json!({"radius": 4, "height": 12, "base": [45, 20, -1], "operation": "cut"}));
}
fn body(s: &Session) -> String {
s.model.state().bodies.first().unwrap().name.clone()
}
/// The cylindrical face of radius `r`, as a pick.
fn hole(s: &Session, r: f64) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let f = (0..n)
.find(|&f| matches!(solidcraft_kernel::face_surface(&b.body, f), Some(solidcraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-6))
.unwrap_or_else(|| panic!("no hole of radius {r}"));
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
json!({"type": "face", "body": b.name, "index": f, "point": p})
}
/// The planar face all of whose points satisfy `on`, as a pick.
fn flat(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let f = (0..n)
.find(|&f| {
let tris: Vec<_> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == f).filter_map(|(t, _)| m.tri(t)).collect();
!tris.is_empty() && tris.iter().all(|t| t.iter().all(|p| on(*p)))
})
.unwrap();
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
json!({"type": "face", "body": b.name, "index": f, "point": p})
}
/// The mesh edge all of whose points satisfy `on`, as a pick.
fn edge(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let i = m.edges.iter().position(|e| e.iter().all(|p| on(*p))).expect("edge");
let e = &m.edges[i];
json!({"type": "edge", "body": b.name, "index": i, "point": e[e.len() / 2]})
}
fn measure(s: &mut Session, items: Vec<Value>) -> Value {
run(s, "inspect.measure", json!({ "items": items }))
}
#[test]
fn two_holes_measure_centre_to_centre_with_min_and_max() {
let mut s = Session::default();
plate(&mut s);
let (a, b) = (hole(&s, 3.0), hole(&s, 4.0));
let r = measure(&mut s, vec![a.clone(), b.clone()]);
assert!(close(&r["center_distance"], 30.0, 1e-9), "{r}");
assert!(close(&r["min"], 30.0 - 3.0 - 4.0, 1e-9), "{r}");
assert!(close(&r["max"], (37.0f64).hypot(10.0), 1e-9), "{r}");
assert_eq!(r["measure"], "center");
// Backward compatible: the closest distance is still `distance_mm`, with its points.
assert!(close(&r["distance_mm"], 23.0, 1e-9) && r["from"].is_array() && r["to"].is_array(), "{r}");
// The centre line runs between the two axes, square to them.
let (f, t) = (&r["center_from"], &r["center_to"]);
assert!(close(&f[0], 15.0, 1e-9) && close(&f[1], 20.0, 1e-9) && close(&t[0], 45.0, 1e-9) && close(&t[1], 20.0, 1e-9), "{r}");
assert!(close(&f[2], t[2].as_f64().unwrap(), 1e-9), "{r}");
// Each hole: Ø, R and circumference.
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-9) && close(&r["items"][0]["radius"], 3.0, 1e-9), "{r}");
assert!(close(&r["items"][1]["circumference"], 8.0 * PI, 1e-9), "{r}");
// The two rims on the top face: the same centres, the rims' gap and span.
let rim = |c: f64, rr: f64| move |p: Vec3| (p.z - 10.0).abs() < 1e-6 && ((p - Vec3::new(c, 20.0, 10.0)).len() - rr).abs() < 1e-6;
let (ea, eb) = (edge(&s, rim(15.0, 3.0)), edge(&s, rim(45.0, 4.0)));
let r = measure(&mut s, vec![ea.clone(), eb]);
assert!(close(&r["center_distance"], 30.0, 1e-9) && close(&r["min"], 23.0, 1e-9) && close(&r["max"], 37.0, 1e-9), "{r}");
// One rim: Ø, R and circumference first-class.
let r = measure(&mut s, vec![ea]);
assert!(close(&r["diameter"], 6.0, 1e-9) && close(&r["radius"], 3.0, 1e-9) && close(&r["circumference"], TAU * 3.0, 1e-9), "{r}");
assert_eq!(r["items"][0]["round"], "circle", "{r}");
}
#[test]
fn a_hole_to_the_plate_edge_and_side_measures_from_its_axis() {
let mut s = Session::default();
plate(&mut s);
let a = hole(&s, 3.0);
// The top front edge (y = 0, z = 10).
let e = edge(&s, |p| p.y.abs() < 1e-9 && (p.z - 10.0).abs() < 1e-9);
let r = measure(&mut s, vec![a.clone(), e]);
assert!(close(&r["center_distance"], 20.0, 1e-9), "{r}");
assert!(close(&r["min"], 17.0, 1e-9), "{r}");
assert!(r["max"].as_f64().unwrap() > 20.0, "{r}");
// The front face (y = 0): axis to plane, the rim's nearest and farthest.
let f = flat(&s, |p| p.y.abs() < 1e-9);
let r = measure(&mut s, vec![f, a.clone()]);
assert!(close(&r["center_distance"], 20.0, 1e-9) && close(&r["min"], 17.0, 1e-9), "{r}");
// The face reaches past the hole: its far corners are farther than the rim's far side.
assert!(r["max"].as_f64().unwrap() > 23.0, "{r}");
// Picked plane first: the centre line still starts at the plane.
assert!(close(&r["center_from"][1], 0.0, 1e-9) && close(&r["center_to"][1], 20.0, 1e-9), "{r}");
// Two planes keep today's measure: no centre.
let top = flat(&s, |p| (p.z - 10.0).abs() < 1e-9);
let bottom = flat(&s, |p| p.z.abs() < 1e-9);
let r = measure(&mut s, vec![top, bottom]);
assert!(r.get("center_distance").is_none() && close(&r["distance_mm"], 10.0, 1e-9) && r["measure"] == "min", "{r}");
}
#[test]
fn sketch_circle_arc_and_slot() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let c = run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}))["curves"][0].as_str().unwrap().to_string();
let a =
run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": [34, 0], "sweep": 90}))["curves"][0].as_str().unwrap().to_string();
let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c})]);
assert!(close(&r["diameter"], 10.0, 1e-9) && close(&r["radius"], 5.0, 1e-9) && close(&r["circumference"], 10.0 * PI, 1e-9), "{r}");
let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": a})]);
assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["diameter"], 8.0, 1e-9), "{r}");
assert!(close(&r["angle_deg"], 90.0, 1e-9) && close(&r["arc_length"], 2.0 * PI, 1e-9), "{r}");
assert_eq!(r["items"][0]["round"], "arc");
// Circle to arc: centre to centre.
let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c}), json!({"type": "sketch_curve", "id": a})]);
assert!(close(&r["center_distance"], 30.0, 1e-9), "{r}");
run(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 40], "p1": [20, 40], "width": 6}));
let sid = s.active_sketch.unwrap();
run(&mut s, "sketch.finish", json!({}));
let st = s.model.state();
let ss = st.sketch(sid).unwrap();
let slot =
ss.profiles.iter().position(|p| p.region.outer.segs.len() >= 4 && (p.area - (20.0 * 6.0 + PI * 9.0)).abs() < 1e-6).expect("slot profile");
let circle = ss.profiles.iter().position(|p| (p.area - PI * 25.0).abs() < 1e-6).expect("circle profile");
let r = measure(&mut s, vec![json!({"type": "profile", "sketch": sid, "index": slot})]);
let sl = &r["slot"];
assert!(close(&sl["width"], 6.0, 1e-9) && close(&sl["center_distance"], 20.0, 1e-9) && close(&sl["overall_length"], 26.0, 1e-9), "{r}");
let r = measure(&mut s, vec![json!({"type": "profile", "sketch": sid, "index": circle})]);
assert!(close(&r["diameter"], 10.0, 1e-9), "{r}");
}
#[test]
fn a_slot_cut_in_a_plate_reports_its_sizes_from_an_end() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.slot.center_to_center", json!({"p0": [20, 20], "p1": [40, 20], "width": 8}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "cut", "profiles": "all"}));
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let end = (0..n)
.find(|&f| matches!(solidcraft_kernel::face_surface(&b.body, f), Some(solidcraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - 4.0).abs() < 1e-6))
.expect("slot end");
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == end).and_then(|(t, _)| m.tri(t)).unwrap()[0];
let name = body(&s);
let r = measure(&mut s, vec![json!({"type": "face", "body": name, "index": end, "point": p})]);
let sl = &r["slot"];
assert!(close(&sl["width"], 8.0, 1e-9) && close(&sl["center_distance"], 20.0, 1e-9) && close(&sl["overall_length"], 28.0, 1e-9), "{r}");
// The end is a half cylinder: R 4, half a turn.
assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["angle_deg"], 180.0, 1.0), "{r}");
}
#[test]
fn a_fillet_edge_is_an_arc() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 30, 10]], "radius": 5}));
// The fillet's quarter circle on the top face: centre (35, 25, 20).
let e = edge(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p - Vec3::new(35.0, 25.0, 20.0)).len() - 5.0).abs() < 1e-6);
let r = measure(&mut s, vec![e]);
assert!(close(&r["radius"], 5.0, 1e-9) && close(&r["diameter"], 10.0, 1e-9), "{r}");
assert!(close(&r["angle_deg"], 90.0, 1e-6) && close(&r["arc_length"], 2.5 * PI, 1e-6), "{r}");
assert!(close(&r["center"][0], 35.0, 1e-9) && close(&r["center"][1], 25.0, 1e-9), "{r}");
}
/// A plate with holes through STEP: the holes come back as cylinders, and measure the same.
#[test]
fn step_round_trip_of_a_plate_with_holes() {
let dir = std::env::temp_dir().join(format!("solidcraft-measure-1392-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Plate.step");
let mut s = Session::default();
plate(&mut s);
run(&mut s, "file.export", json!({"path": path.to_string_lossy()}));
let mut t = Session::default();
run(&mut t, "doc.open", json!({"path": path.to_string_lossy()}));
let (a, b) = (hole(&t, 3.0), hole(&t, 4.0));
let r = measure(&mut t, vec![a, b]);
assert!(close(&r["center_distance"], 30.0, 1e-6), "{r}");
assert!(close(&r["min"], 23.0, 1e-6), "{r}");
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
+9 -1
View File
@@ -182,7 +182,15 @@ mod tests {
.unwrap();
let mut document = crate::doc::Document::default();
document
.add_feature(crate::doc::FeatureKind::Import { file: "original-generated.step".into(), step: step.into(), components: Vec::new() }, None)
.add_feature(
crate::doc::FeatureKind::Import {
file: "original-generated.step".into(),
step: step.into(),
components: Vec::new(),
skip: Vec::new(),
},
None,
)
.unwrap();
let mut s = Session::new(document);
let first = extract(&s).unwrap();
+25 -1
View File
@@ -307,6 +307,7 @@ mod blend_fit;
mod boolean_diagnostics;
mod component_frames;
mod curved_seam_round;
mod import_component;
mod large_step_import;
mod loft_cut;
mod loft_ring;
@@ -1057,7 +1058,9 @@ fn join_onto_a_finely_faceted_import() {
let prism = volume(&mut s);
let mut d = Session::default();
run(&mut d, "file.insert_step", json!({"path": p.to_string_lossy()}));
// The import is a component (#1386): the pin is made in it to join its body.
let r = run(&mut d, "file.insert_step", json!({"path": p.to_string_lossy()}));
run(&mut d, "component.activate", json!({"component": r["component"]}));
run(&mut d, "sketch.create", json!({"plane": "XY", "name": "Pin"}));
run(&mut d, "sketch.rectangle.two_point", json!({"p0": [-5, -5], "p1": [5, 5]}));
run(&mut d, "sketch.finish", json!({}));
@@ -2346,6 +2349,27 @@ fn booleans_stay_in_their_component() {
assert!(s.execute("solid.extrude", &json!({"sketch": "Slot", "distance": 1, "operation": "cut", "participants": ["Nope"]})).is_err());
}
/// #1234: a cut sketched in the root, where there is no body, through a body in each of two
/// components cuts both (the root has nothing of its own to cut). A cut in a component with a
/// body of its own still stays there (`booleans_stay_in_their_component`).
#[test]
fn a_cut_from_a_component_without_bodies_cuts_the_parts_it_meets() {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 30)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "corner": [x, 0, 0], "body_name": format!("{name}Box")}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 2], "p1": [40, 8]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "distance": 10, "operation": "cut"}));
let vol = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0);
assert!((vol(&mut s, "ABox") - (2000.0 - 10.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "ABox"));
assert!((vol(&mut s, "BBox") - (2000.0 - 10.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "BBox"));
}
/// A unit-less parameter used as a length means the design's units.
#[test]
fn unitless_parameters_used_as_lengths_take_the_design_units() {
+324
View File
@@ -0,0 +1,324 @@
//! An imported file becomes a component named after it (#1386): a STEP part is one component
//! with its body, a STEP assembly nested components that mirror the file with a repeated part's
//! placements as occurrences of one component, held together as one rigid group until it is
//! unlocked; a mesh file one component. Undo takes the whole import away, and designs whose
//! imports sit loose in a component open and rebuild as they did.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_kernel::{ExportBody, ExportProduct, StepHeader, box_solid, step_export_products};
use super::run;
use crate::*;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-import-component-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn at(x: f64, y: f64, z: f64) -> [[f64; 4]; 4] {
[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, z, 1.0]]
}
/// A quarter turn about Z, then moved to (x, y, z).
fn turned(x: f64, y: f64, z: f64) -> [[f64; 4]; 4] {
[[0.0, 1.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, z, 1.0]]
}
/// `Rig`: a base plate, two `Arm` sub-assemblies (each a 20 × 4 × 4 beam and a `Pin`) and a third
/// `Pin` on its own; one arm is turned a quarter turn. Pins: 2 × 2 × 10. Six bodies in the
/// file: four products, one of them a sub-assembly placed twice.
fn rig_file(dir: &std::path::Path) -> String {
let plate = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 5.0)).unwrap();
let beam = box_solid(Vec3::ZERO, Vec3::new(20.0, 4.0, 4.0)).unwrap();
let pin = box_solid(Vec3::ZERO, Vec3::new(2.0, 2.0, 10.0)).unwrap();
let part = |name: &str, body| ExportProduct {
name: name.into(),
bodies: vec![ExportBody { name: name.into(), body, color: None }],
children: Vec::new(),
};
let products = vec![
ExportProduct {
name: "Rig".into(),
bodies: Vec::new(),
children: vec![
(1, at(0.0, 0.0, 0.0), "Base-1".into()),
(2, at(10.0, 10.0, 5.0), "Arm-1".into()),
(2, turned(60.0, 10.0, 5.0), "Arm-2".into()),
(4, at(90.0, 50.0, 5.0), "Pin-3".into()),
],
},
part("Base", &plate),
ExportProduct {
name: "Arm".into(),
bodies: Vec::new(),
children: vec![(3, at(0.0, 0.0, 0.0), "Beam-1".into()), (4, at(18.0, 1.0, 4.0), "Pin-1".into())],
},
part("Beam", &beam),
part("Pin", &pin),
];
let text = step_export_products(&products, 0, &StepHeader::default()).unwrap();
let path = dir.join("rig.step");
std::fs::write(&path, text).unwrap();
path.to_string_lossy().to_string()
}
/// Where the file itself puts its bodies: their bounding boxes, sorted.
fn file_boxes(path: &str) -> Vec<[i64; 6]> {
let imp = solidcraft_kernel::step_import(&std::fs::read_to_string(path).unwrap()).unwrap();
let mut out: Vec<[i64; 6]> = imp.bodies.iter().map(|b| key(solidcraft_kernel::measure(&b.body).unwrap().bbox)).collect();
out.sort();
out
}
fn key(b: solidcraft_geom::Aabb3) -> [i64; 6] {
let r = |v: f64| (v * 1000.0).round() as i64;
[r(b.min.x), r(b.min.y), r(b.min.z), r(b.max.x), r(b.max.y), r(b.max.z)]
}
/// Where the design shows its bodies, sorted.
fn shown_boxes(s: &Session) -> Vec<[i64; 6]> {
let mut out: Vec<[i64; 6]> = s.world_state().bodies.iter().map(|b| key(solidcraft_kernel::measure(&b.body).unwrap().bbox)).collect();
out.sort();
out
}
fn components(s: &mut Session) -> Vec<Value> {
run(s, "component.list", json!({}))["components"].as_array().unwrap().clone()
}
fn component<'a>(list: &'a [Value], name: &str) -> &'a Value {
list.iter().find(|c| c["name"] == name).unwrap_or_else(|| panic!("no component {name}: {list:?}"))
}
fn occurrence_named(s: &Session, name: &str) -> u64 {
s.doc.occurrences.iter().find(|o| o.name == name).unwrap_or_else(|| panic!("no occurrence {name}")).id
}
/// The world corner of the bodies of one occurrence (by name) nearest -∞.
fn corner_of(s: &Session, occurrence: &str) -> Vec3 {
let occ = occurrence_named(s, occurrence);
let path = {
let mut path = vec![occ];
let mut parent = s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().parent;
while parent != 0 {
let o = s.doc.occurrence_of(parent).unwrap();
path.insert(0, o.id);
parent = o.parent;
}
path
};
let names = s.occurrence_bodies(&path, true).unwrap();
assert!(!names.is_empty());
let world = s.world_state();
let mut lo = Vec3::new(f64::MAX, f64::MAX, f64::MAX);
for b in world.bodies.iter().filter(|b| names.contains(&b.name)) {
let bb = solidcraft_kernel::measure(&b.body).unwrap().bbox;
lo = Vec3::new(lo.x.min(bb.min.x), lo.y.min(bb.min.y), lo.z.min(bb.min.z));
}
lo
}
fn close(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
/// A box exported as `name.step` in `dir`.
fn part_file(dir: &std::path::Path, name: &str) -> String {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10}));
let path = dir.join(format!("{name}.step")).to_string_lossy().to_string();
run(&mut s, "file.export", json!({"path": path}));
path
}
#[test]
fn a_step_part_comes_in_as_one_component_named_after_the_file() {
let dir = temp_dir("part");
let path = part_file(&dir, "bracket");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]}));
let r = run(&mut s, "file.insert_step", json!({"path": path}));
let list = components(&mut s);
assert_eq!(list.len(), 2, "the design and the import: {list:?}");
let bracket = component(&list, "bracket");
assert_eq!((r["component"].clone(), r["component_name"].clone()), (bracket["id"].clone(), json!("bracket")), "{r}");
assert_eq!(bracket["bodies"].as_array().unwrap().len(), 1, "{bracket}");
assert_eq!(component(&list, &s.doc.name.clone())["bodies"].as_array().unwrap().len(), 1, "my own box stays where it was");
let feature = r["feature"].as_u64().unwrap();
assert_eq!(s.doc.feature(feature).unwrap().component, r["component"].as_u64().unwrap());
// One part: nothing to hold together.
assert!(s.doc.assembly.joints.is_empty() && r.get("subcomponents").is_none(), "{r}");
// The active component is still the one I was working in.
assert_eq!(s.active_component, 0);
// Opening the file as a design makes the component too, so the browser shows its name.
let r = run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(r["component_name"], "bracket", "{r}");
assert_eq!(s.doc.components.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["bracket"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_step_assembly_comes_in_as_nested_components_with_its_instances() {
let dir = temp_dir("assembly");
let path = rig_file(&dir);
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", json!({"path": path}));
// rig ⊃ Base, Arm (twice; Beam, Pin inside), Pin (once more, directly).
let list = components(&mut s);
let names: Vec<&str> = list.iter().filter_map(|c| c["name"].as_str()).collect();
assert_eq!(names.len(), 6, "{names:?}");
for n in ["rig", "Base", "Arm", "Beam", "Pin"] {
assert!(names.contains(&n), "{n} in {names:?}");
}
let id = |n: &str| component(&list, n)["id"].as_u64().unwrap();
let parent = |n: &str| component(&list, n)["parent"].as_u64().unwrap();
assert_eq!(parent("rig"), 0);
assert_eq!((parent("Base"), parent("Arm")), (id("rig"), id("rig")));
assert_eq!((parent("Beam"), parent("Pin")), (id("Arm"), id("Arm")));
assert_eq!(r["component"], id("rig"), "{r}");
assert_eq!(r["subcomponents"], 4, "{r}");
// A repeated part is one component placed again: Arm twice, Pin in the arm and on its own.
let placements = |c: u64| s.doc.occurrences.iter().filter(|o| o.component == c).count();
assert_eq!((placements(id("Arm")), placements(id("Pin")), placements(id("Beam"))), (2, 2, 1));
// Each component holds its own part's body; the file's later instances are not built again.
let own = |n: &str| component(&list, n)["bodies"].as_array().unwrap().len();
assert_eq!((own("rig"), own("Base"), own("Arm"), own("Beam"), own("Pin")), (0, 1, 0, 1, 1));
assert_eq!(s.model.state().bodies.len(), 3, "base, beam and pin, once each");
// And every body shows where the file puts it.
assert_eq!(shown_boxes(&s), file_boxes(&path));
// Saved and opened again, it is the same.
let design = dir.join("rig.solidcraft").to_string_lossy().to_string();
run(&mut s, "file.save_as", json!({"path": design}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design}));
assert_eq!(shown_boxes(&again), file_boxes(&path));
assert_eq!(again.doc.components.len(), 5);
let _ = std::fs::remove_dir_all(&dir);
}
/// Each occurrence's degrees of freedom, as `joint.list` gives them.
fn dofs(s: &mut Session) -> (std::collections::BTreeMap<u64, u64>, Value) {
let r = run(s, "joint.list", json!({}));
let dof = r["dof"].as_array().unwrap().iter().map(|d| (d["occurrence"].as_u64().unwrap(), d["dof"].as_u64().unwrap())).collect();
(dof, r)
}
#[test]
fn an_imported_assembly_moves_as_one_until_it_is_unlocked() {
let dir = temp_dir("rigid");
let path = rig_file(&dir);
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let rig = occurrence_named(&s, "rig:1");
let inside: Vec<u64> = s.doc.occurrences.iter().filter(|o| o.id != rig).map(|o| o.id).collect();
assert_eq!(inside.len(), 6);
let pin = corner_of(&s, "Pin:2");
let arm = corner_of(&s, "Arm:1");
// The whole import is one rigid body: no part of it moves against the others.
let (dof, r) = dofs(&mut s);
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
for o in &inside {
assert_eq!(dof.get(o), dof.get(&rig), "occurrence {o} moves only with the import: {r}");
}
// Moving the import's component moves everything in it.
run(&mut s, "occurrence.move", json!({"occurrence": "rig:1", "translate": [0, 0, 50]}));
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(0.0, 0.0, 50.0)), "{:?}", corner_of(&s, "Pin:2"));
assert!(close(corner_of(&s, "Arm:1"), arm + Vec3::new(0.0, 0.0, 50.0)));
// A part inside doesn't move on its own; the refusal says what to move instead.
for part in ["Pin:2", "Pin:1"] {
let e = s.execute("occurrence.move", &json!({"occurrence": part, "translate": [30, 0, 0]})).unwrap_err().to_string();
assert!(e.contains("move rig"), "{e}");
}
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(0.0, 0.0, 50.0)));
// Next to a grounded part of mine the import is still one rigid body.
run(&mut s, "component.create", json!({"name": "Mine"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [-50, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.ground", json!({"occurrence": "Mine:1", "grounded": true}));
let (dof, r) = dofs(&mut s);
for o in &inside {
assert_eq!(dof.get(o), dof.get(&rig), "{r}");
}
assert!(r["conflicts"].as_array().unwrap().is_empty(), "{r}");
// Unlocked (one undo step), the parts move one by one.
let undo = s.undo.len();
let r = run(&mut s, "component.unlock", json!({"component": "rig"}));
assert_eq!(r["joints"].as_array().unwrap().len(), 6, "{r}");
assert_eq!(s.undo.len(), undo + 1);
run(&mut s, "occurrence.move", json!({"occurrence": "Pin:2", "translate": [30, 0, 0]}));
assert!(close(corner_of(&s, "Pin:2"), pin + Vec3::new(30.0, 0.0, 50.0)), "{:?}", corner_of(&s, "Pin:2"));
assert!(close(corner_of(&s, "Arm:1"), arm + Vec3::new(0.0, 0.0, 50.0)), "the others stay");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.assembly.joints.len(), 6, "undo locks it again");
assert!(s.execute("component.unlock", &json!({"component": "Mine"})).is_err(), "nothing holds my own component");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_mesh_file_comes_in_as_one_component() {
let dir = temp_dir("stl");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let stl = dir.join("cube.stl").to_string_lossy().to_string();
run(&mut s, "file.save_mesh", json!({"path": stl}));
let mut s = Session::default();
let r = run(&mut s, "file.insert_mesh", json!({"path": stl}));
assert_eq!(r["component_name"], "cube", "{r}");
let list = components(&mut s);
assert_eq!(list.len(), 2);
assert_eq!(component(&list, "cube")["bodies"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn undo_takes_the_whole_import_away() {
let dir = temp_dir("undo");
let path = rig_file(&dir);
let mut s = Session::default();
let undo = s.undo.len();
run(&mut s, "file.insert_step", json!({"path": path}));
assert_eq!(s.undo.len(), undo + 1, "one step");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features.is_empty() && s.doc.components.is_empty() && s.doc.occurrences.is_empty(), "nothing left");
assert!(s.doc.assembly.joints.is_empty() && s.doc.body_components.is_empty() && s.doc.body_offsets.is_empty());
assert!(s.world_state().bodies.is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!(shown_boxes(&s), file_boxes(&path));
let _ = std::fs::remove_dir_all(&dir);
}
/// A design saved before imports made components has its Import loose in the design: it opens
/// as it is (no component is made for it) and builds every body of the file where it was.
#[test]
fn a_design_with_a_loose_import_rebuilds_unchanged() {
let dir = temp_dir("loose");
let path = rig_file(&dir);
let sf = solidcraft_io::step_import_feature(&std::fs::read(&path).unwrap(), &path).unwrap();
let mut doc = solidcraft_doc::Document::new("Old");
let id = doc.add_feature(sf.kind, Some(&sf.name)).unwrap();
doc.feature_mut(id).unwrap().body_names = sf.body_names;
// As a format 21 file (0.12.0) has it.
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = json!("solidcraft/21");
assert!(json["features"][0]["kind"].get("skip").is_none());
let file = dir.join("old.solidcraft");
std::fs::write(&file, json.to_string()).unwrap();
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": file.to_string_lossy()}));
assert!(s.doc.components.is_empty() && s.doc.occurrences.is_empty(), "not migrated");
assert_eq!(s.doc.features[0].component, 0);
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Base", "Beam", "Pin", "Beam (2)", "Pin (2)", "Pin (3)"]);
assert_eq!(shown_boxes(&s), file_boxes(&path));
let _ = std::fs::remove_dir_all(&dir);
}
+14 -12
View File
@@ -90,9 +90,10 @@ fn a_y_up_step_file_comes_in_standing_up() {
let r = run(&mut d, "file.insert_step", json!({"path": path, "up": "y"}));
let (lo, hi) = extent(&d);
assert!(near(lo, [0.0, -20.0, 0.0]) && near(hi, [40.0, 0.0, 34.0]), "stood up: {lo:?} {hi:?}");
assert_eq!(kinds(&d), ["BaseFeature", "MoveFeature"]);
assert_eq!(r["stand_up"], d.doc.features[1].id, "{r}");
assert_eq!(r["created_bodies"].as_array().unwrap().len(), 3, "{r}");
// The import's component is turned (#1386), so an assembly's instances turn with it.
assert_eq!(kinds(&d), ["BaseFeature"]);
assert_eq!(r["stand_up"], r["occurrence"], "{r}");
assert_eq!(r["created_bodies"].as_array().unwrap().len(), 2, "the plate, and the post placed twice: {r}");
assert!((total_volume(&mut d) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
// The Import and the turn are one undo step.
assert_eq!(d.undo.len(), undo + 1);
@@ -106,7 +107,7 @@ fn a_y_up_step_file_comes_in_standing_up() {
let (lo, hi) = extent(&d);
assert!(near(lo, [0.0, -20.0, 0.0]) && near(hi, [105.0, 106.0, 107.0]), "{lo:?} {hi:?}");
// The turn is an ordinary Move feature: the saved design has it and needs no new field.
// The turn is the component's placement: the saved design has it.
let design = std::path::Path::new(&path).with_file_name("frame.solidcraft");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "file.insert_step", json!({"path": path, "up": "y"}));
@@ -122,7 +123,7 @@ fn opening_a_y_up_step_file_can_stand_it_up() {
let path = frame_file("open");
let mut s = Session::default();
let r = run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
assert_eq!(r["features"], 2, "{r}");
assert_eq!(r["features"], 1, "{r}");
assert!(near(extent(&s).1, [40.0, 0.0, 34.0]));
assert!(s.undo.is_empty(), "a freshly opened design has no history");
run(&mut s, "doc.open", json!({"path": path}));
@@ -181,22 +182,23 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
assert_eq!(r["name"], "Frame", "the timeline entry is the root product's");
body_names(&s)
};
let solids = ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1 (2)"];
// The post is placed twice: one component, two occurrences, its body built once (#1386).
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
assert_eq!(named(json!({"path": path})), solids);
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post", "Post (2)"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1", "Post-2"]);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
// The names are stored with the import: a saved design keeps them, and `up` does not change them.
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "names": "instance", "up": "y"}));
assert_eq!(body_names(&s), ["Plate-1", "Post-1", "Post-2"]);
assert_eq!(body_names(&s), ["Plate-1", "Post-1"]);
let design = std::path::Path::new(&path).with_file_name("frame-named.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1", "Post-2"]);
assert_eq!(body_names(&again), ["Plate-1", "Post-1"]);
// A feature finds them by those names.
run(&mut again, "solid.move", json!({"bodies": ["Post-2"], "translate": [0, 0, 5]}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1", "Post-2"]);
run(&mut again, "solid.move", json!({"bodies": ["Post-1"], "translate": [0, 0, 5]}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1"]);
}
+9 -1
View File
@@ -25,7 +25,15 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
let text = sk
.add_link(
LinkKind::Text,
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None, style: Default::default() },
LinkSource::Text {
text: "A".into(),
at: Vec2::ZERO,
height: 2.,
angle: 0.,
font: None,
style: Default::default(),
exprs: Default::default(),
},
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
+9 -1
View File
@@ -68,7 +68,15 @@ fn each_sketch_state_takes_its_palette_colour() {
let lost_link = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 6.), Vec2::new(2., 6.))]).unwrap();
sk.set_link_lost(&lost_link, true);
let lost = sk.curves.len() - 1;
let text = LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None, style: Default::default() };
let text = LinkSource::Text {
text: "A".into(),
at: Vec2::ZERO,
height: 2.,
angle: 0.,
font: None,
style: Default::default(),
exprs: Default::default(),
};
sk.add_link(LinkKind::Text, text, &[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))]).unwrap();
let text = sk.curves.len() - 1;
let mut flags = vec![false; sk.curves.len()];
+927
View File
@@ -0,0 +1,927 @@
//! Comparing two closed triangle meshes without a boolean of the two: how far each surface strays
//! from the other, and the places where only one of them has material.
//!
//! Each surface is sampled: the corners of its triangles, and inside the larger ones the corners
//! and centres of pieces at most `spacing` across. Every sample measures its distance to the other
//! surface (a [`TriangleTree`] over the other's triangles). Samples further away than the
//! tolerance are the deviating surface. One that lies outside the other mesh bounds material
//! only its own mesh has; one inside it bounds material only the other mesh has. The deviating
//! samples are clustered by distance into regions, and each region's volume is integrated along
//! a grid of parallel lines through its box: on each line, the stretches inside one mesh and
//! outside the other, found from where the line crosses the two surfaces. Nothing here
//! intersects the two surfaces, so bodies that share almost every face (two versions of one
//! part) compare as well as any others.
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use crate::{Aabb3, Mesh, Vec3};
/// Runs a job for every index below `n`: the jobs are independent, so any order and any number
/// of threads will do. [`serial`] runs them one after another; a caller with threads passes one
/// that spreads them. A job that did not run (it panicked on another thread) runs again on the
/// calling thread.
pub type Each<'a> = &'a (dyn Fn(usize, &(dyn Fn(usize) + Sync)) + Sync);
/// [`Each`] on the calling thread.
pub fn serial(n: usize, job: &(dyn Fn(usize) + Sync)) {
(0..n).for_each(job);
}
/// How [`compare`] samples and measures.
#[derive(Clone, Copy, Debug)]
pub struct CompareOptions {
/// Distances up to this are agreement (mm).
pub tolerance: f64,
/// The largest size of the pieces a triangle is sampled in (mm); raised when a mesh would
/// need more than `max_points` samples.
pub spacing: f64,
/// Sample points per mesh, about (a mesh of more triangles than two thirds of this is
/// sampled once per triangle, at its centre and corners, anyway).
pub max_points: usize,
/// Lines through a region for its volume, at most.
pub max_lines: usize,
/// Regions measured and listed, largest first; the rest are counted in
/// [`Comparison::unlisted`].
pub max_regions: usize,
}
impl Default for CompareOptions {
fn default() -> Self {
CompareOptions { tolerance: 0.01, spacing: 0.5, max_points: 8_000_000, max_lines: 40_000, max_regions: 50 }
}
}
/// Which mesh has the material of a region.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
/// Material of `a` where `b` has none.
OnlyInA,
/// Material of `b` where `a` has none.
OnlyInB,
}
/// How far one mesh's surface lies from the other's, over its samples.
#[derive(Clone, Copy, Debug, Default)]
pub struct Deviation {
/// The largest distance (mm) and the sample where it was found.
pub max: f64,
pub at: Vec3,
/// The mean distance, weighted by the surface each sample stands for (mm).
pub mean: f64,
/// The surface further than the tolerance from the other mesh, and all of it (mm²).
pub beyond_area: f64,
pub area: f64,
/// Sample points measured.
pub points: usize,
}
/// The lines a region's volume was integrated along.
#[derive(Clone, Copy, Debug, Default)]
pub struct Lines {
/// The lines run along this axis (0 = X, 1 = Y, 2 = Z): the region box's shortest.
pub axis: usize,
/// Distance between neighbouring lines across the axis, along the next two axes in turn
/// (for Z: X, then Y) (mm).
pub spacing: [f64; 2],
pub count: usize,
/// Lines left out because they crossed a mesh an odd number of times (a gap in it).
pub skipped: usize,
}
/// A place where only one mesh has material.
#[derive(Clone, Debug)]
pub struct Region {
pub side: Side,
/// The volume of the material only one mesh has here, integrated along [`Region::lines`]
/// (mm³; NaN when it would take too long to measure).
pub volume: f64,
/// That volume's centre and box; the deviating surface's when the lines found none.
pub center: Vec3,
pub bbox: Aabb3,
/// The deviating surface of each mesh here (mm²).
pub area_a: f64,
pub area_b: f64,
/// The largest distance between the surfaces here (mm), and where.
pub max_deviation: f64,
pub at: Vec3,
/// The faces (the meshes' `tri_face` ids) the deviating surface lies on, each with its
/// deviating area here (mm²); [`Comparison::face_area`] has each face's whole area.
pub faces_a: Vec<(u32, f64)>,
pub faces_b: Vec<(u32, f64)>,
pub lines: Lines,
}
/// What [`compare`] found.
#[derive(Clone, Debug, Default)]
pub struct Comparison {
pub a_to_b: Deviation,
pub b_to_a: Deviation,
/// Largest volume first.
pub regions: Vec<Region>,
/// Regions found beyond [`CompareOptions::max_regions`] (not measured).
pub unlisted: usize,
/// The sample spacing used (mm).
pub spacing: f64,
/// The sampled area of each face of `a` and of `b`, by `tri_face` id (mm²).
pub face_area: [HashMap<u32, f64>; 2],
}
/// The point of triangle `abc` nearest `p` (Ericson, Real-Time Collision Detection, 5.1.5).
pub fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = p - b;
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
let den = d1 - d3;
return if den > 0.0 { a + ab * (d1 / den) } else { a };
}
let cp = p - c;
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
let den = d2 - d6;
return if den > 0.0 { a + ac * (d2 / den) } else { a };
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
let den = (d4 - d3) + (d5 - d6);
return if den > 0.0 { b + (c - b) * ((d4 - d3) / den) } else { b };
}
let den = va + vb + vc;
if den.abs() < 1e-300 {
return a;
}
a + ab * (vb / den) + ac * (vc / den)
}
fn comp(v: Vec3, k: usize) -> f64 {
match k {
0 => v.x,
1 => v.y,
_ => v.z,
}
}
/// The point with coordinate `ck` on axis `k`, and `cu`, `cv` on the next two axes in turn.
fn from_axes(k: usize, cu: f64, cv: f64, ck: f64) -> Vec3 {
match k {
0 => Vec3::new(ck, cu, cv),
1 => Vec3::new(cv, ck, cu),
_ => Vec3::new(cu, cv, ck),
}
}
fn box_of(t: &[Vec3; 3]) -> Aabb3 {
let mut b = Aabb3::EMPTY;
t.iter().for_each(|p| b.add(*p));
b
}
/// Squared distance from `p` to the box (0 inside it).
fn box_dist2(b: &Aabb3, p: Vec3) -> f64 {
(b.min - p).max(p - b.max).max(Vec3::ZERO).len2()
}
/// Do the boxes overlap on every axis but `skip` (none when `skip > 2`)?
fn boxes_meet(a: &Aabb3, b: &Aabb3, skip: usize) -> bool {
(0..3).filter(|&k| k != skip).all(|k| comp(a.min, k) <= comp(b.max, k) && comp(b.min, k) <= comp(a.max, k))
}
/// Triangles in a leaf of [`TriangleTree`], at most (unless they all share a centre).
const LEAF: usize = 4;
/// A bounding-volume hierarchy over a mesh's triangles: the nearest point of the surface, and
/// the triangles a line can cross.
pub struct TriangleTree {
/// The mesh's triangles with finite corners, in tree order.
tris: Vec<[Vec3; 3]>,
/// Each one's index in the mesh.
ids: Vec<u32>,
nodes: Vec<Node>,
}
#[derive(Clone, Copy)]
struct Node {
bbox: Aabb3,
/// A leaf holds `tris[a..b]`; an inner node has the children `a` and `b`.
a: u32,
b: u32,
leaf: bool,
}
impl TriangleTree {
pub fn new(mesh: &Mesh) -> TriangleTree {
let mut tris = Vec::with_capacity(mesh.triangles.len());
let mut ids = Vec::with_capacity(mesh.triangles.len());
for (i, t) in mesh.triangles.iter().enumerate() {
if let (Some(tri), Ok(i)) = (mesh.tri(t), u32::try_from(i))
&& tri.iter().all(|p| p.is_finite())
{
tris.push(tri);
ids.push(i);
}
}
let centres: Vec<Vec3> = tris.iter().map(|t| (t[0] + t[1] + t[2]) / 3.0).collect();
let mut order: Vec<u32> = (0..u32::try_from(tris.len()).unwrap_or(u32::MAX)).collect();
let n = order.len();
let empty = Node { bbox: Aabb3::EMPTY, a: 0, b: 0, leaf: true };
let mut nodes = vec![empty];
let mut work = vec![(0usize, 0usize, n)];
while let Some((ni, lo, hi)) = work.pop() {
let Some(range) = order.get_mut(lo..hi) else { continue };
let (mut bbox, mut cb) = (Aabb3::EMPTY, Aabb3::EMPTY);
for &i in range.iter() {
if let (Some(t), Some(c)) = (tris.get(i as usize), centres.get(i as usize)) {
bbox = bbox.union(&box_of(t));
cb.add(*c);
}
}
let size = cb.size();
let axis = if size.x >= size.y && size.x >= size.z {
0
} else if size.y >= size.z {
1
} else {
2
};
let node = if hi - lo <= LEAF || comp(size, axis) <= 0.0 {
Node { bbox, a: lo as u32, b: hi as u32, leaf: true }
} else {
let mid = (hi - lo) / 2;
range.select_nth_unstable_by(mid, |x, y| {
let c = |i: &u32| centres.get(*i as usize).map_or(0.0, |c| comp(*c, axis));
c(x).total_cmp(&c(y))
});
let (left, right) = (nodes.len(), nodes.len() + 1);
nodes.push(empty);
nodes.push(empty);
work.push((left, lo, lo + mid));
work.push((right, lo + mid, hi));
Node { bbox, a: left as u32, b: right as u32, leaf: false }
};
if let Some(slot) = nodes.get_mut(ni) {
*slot = node;
}
}
let tris_in_order = order.iter().filter_map(|&i| tris.get(i as usize).copied()).collect();
let ids_in_order = order.iter().filter_map(|&i| ids.get(i as usize).copied()).collect();
TriangleTree { tris: tris_in_order, ids: ids_in_order, nodes }
}
pub fn len(&self) -> usize {
self.tris.len()
}
pub fn is_empty(&self) -> bool {
self.tris.is_empty()
}
/// The distance from `p` to the nearest point of the surface, and the index in the mesh of
/// the triangle it lies on (`None` for an empty mesh or a point that isn't finite).
pub fn nearest(&self, p: Vec3) -> Option<(f64, u32)> {
if !p.is_finite() || self.tris.is_empty() {
return None;
}
let (mut best2, mut hit) = (f64::INFINITY, None);
let mut stack: Vec<u32> = Vec::with_capacity(64);
stack.push(0);
while let Some(ni) = stack.pop() {
let Some(node) = self.nodes.get(ni as usize) else { continue };
if box_dist2(&node.bbox, p) >= best2 {
continue;
}
if node.leaf {
for k in node.a..node.b {
let Some([a, b, c]) = self.tris.get(k as usize).copied() else { continue };
let d2 = (closest_on_triangle(p, a, b, c) - p).len2();
if d2 < best2 {
best2 = d2;
hit = self.ids.get(k as usize).copied();
}
}
continue;
}
let d = |i: u32| self.nodes.get(i as usize).map_or(f64::INFINITY, |n| box_dist2(&n.bbox, p));
let (da, db) = (d(node.a), d(node.b));
// The nearer child is searched first (pushed last).
let (near, far, dn, df) = if da <= db { (node.a, node.b, da, db) } else { (node.b, node.a, db, da) };
if df < best2 {
stack.push(far);
}
if dn < best2 {
stack.push(near);
}
}
hit.map(|i| (best2.sqrt(), i))
}
/// Every triangle (by its place in the tree) whose box overlaps `bx` on the two axes other
/// than `axis`: the triangles a line along `axis` through `bx` can cross.
fn column(&self, bx: &Aabb3, axis: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut stack: Vec<u32> = if self.tris.is_empty() { Vec::new() } else { vec![0] };
while let Some(ni) = stack.pop() {
let Some(node) = self.nodes.get(ni as usize) else { continue };
if !boxes_meet(&node.bbox, bx, axis) {
continue;
}
if node.leaf {
out.extend((node.a..node.b).map(|k| k as usize).filter(|&k| self.tris.get(k).is_some_and(|t| boxes_meet(&box_of(t), bx, axis))));
} else {
stack.push(node.a);
stack.push(node.b);
}
}
out
}
}
fn longest_edge(t: &[Vec3; 3]) -> f64 {
t[0].dist(t[1]).max(t[1].dist(t[2])).max(t[2].dist(t[0]))
}
/// Pieces one triangle is sampled in, at most.
const MAX_PIECES: usize = 1 << 22;
/// About how many pieces [`for_each_sample`] cuts the triangle into.
fn pieces_estimate(t: &[Vec3; 3], h: f64) -> f64 {
let l = longest_edge(t);
if !(l > h) {
return 1.0;
}
let area = (t[1] - t[0]).cross(t[2] - t[0]).len() * 0.5;
(4.0 * area / (h * h)).max(2.0 * l / h).clamp(1.0, MAX_PIECES as f64)
}
/// Calls `f` with every sample point of the triangle and the area it stands for. The triangle is
/// halved across its longest side until every piece's sides are at most `h` long; the centre of
/// each piece stands for the piece, its corners (which find what lies along the edges) for none.
fn for_each_sample(t: &[Vec3; 3], h: f64, stack: &mut Vec<[Vec3; 3]>, mut f: impl FnMut(Vec3, f64)) {
stack.clear();
stack.push(*t);
let mut pieces = 0usize;
while let Some([a, b, c]) = stack.pop() {
let (lab, lbc, lca) = (a.dist(b), b.dist(c), c.dist(a));
let l = lab.max(lbc).max(lca);
pieces += 1;
if !(l > h) || pieces + stack.len() >= MAX_PIECES {
for p in [a, b, c] {
f(p, 0.0);
}
f((a + b + c) / 3.0, (b - a).cross(c - a).len() * 0.5);
continue;
}
// Midpoints are computed from the same two corners on both sides of an inner edge, so
// the pieces either side share them exactly.
if lab >= lbc && lab >= lca {
let m = (a + b) * 0.5;
stack.push([a, m, c]);
stack.push([m, b, c]);
} else if lbc >= lca {
let m = (b + c) * 0.5;
stack.push([a, b, m]);
stack.push([a, m, c]);
} else {
let m = (c + a) * 0.5;
stack.push([a, b, m]);
stack.push([m, b, c]);
}
}
}
/// The spacing at which `mesh` needs about `max` sample points (at least `want`; a mesh whose
/// triangles alone need more gets one piece per triangle).
fn spacing_for(mesh: &Mesh, want: f64, max: usize) -> f64 {
let tris: Vec<[Vec3; 3]> = mesh.triangles.iter().filter_map(|t| mesh.tri(t)).filter(|t| t.iter().all(|p| p.is_finite())).collect();
// Every piece is sampled at its centre and its corners, each corner once: a triangulated
// surface has about half as many corners as triangles, so 1.5 samples a piece.
let fewest = 1.5 * tris.len() as f64;
let max = max.max(1) as f64;
let coarser = |h: f64, total: f64| h * (total / max).sqrt() * 1.02;
let mut h = if want.is_finite() && want > 0.0 { want } else { 1.0 };
// A spacing known to need too many, to close in on from above.
let mut over = None;
// First from the estimate, cheaply; then from the exact count, which the estimate can be
// a few times under (long thin triangles, the halving's steps).
for _ in 0..40 {
let total = tris.iter().map(|t| 1.5 * pieces_estimate(t, h)).sum::<f64>();
if total <= max || total <= fewest * 1.001 {
break;
}
over = Some(h);
h = coarser(h, total);
}
let mut stack = Vec::new();
let mut count = |h: f64| {
let mut pieces = 0usize;
for t in &tris {
for_each_sample(t, h, &mut stack, |_, w| pieces += usize::from(w > 0.0));
}
1.5 * pieces as f64
};
let mut total = count(h);
for _ in 0..12 {
if total <= max || total <= fewest * 1.001 {
break;
}
over = Some(h);
h = coarser(h, total);
total = count(h);
}
// The count falls in steps (each halving doubles a triangle's pieces), so the last step may
// have gone a step too far: the finest spacing within the budget lies between.
if let Some(mut lo) = over.filter(|_| total <= max) {
for _ in 0..10 {
let mid = (lo * h).sqrt();
if count(mid) <= max {
h = mid;
} else {
lo = mid;
}
}
}
h
}
/// Triangles one job samples.
const CHUNK: usize = 1024;
/// A sample further than the tolerance from the other surface.
#[derive(Clone, Copy)]
struct Off {
p: Vec3,
d: f64,
w: f64,
face: u32,
}
#[derive(Default)]
struct Part {
max: f64,
at: Vec3,
sum: f64,
weight: f64,
beyond: f64,
points: usize,
off: Vec<Off>,
face_area: HashMap<u32, f64>,
}
/// Runs `job` for every index below `n` through `each` and returns the results in order.
fn gather<T: Send + Sync>(n: usize, each: Each, job: impl Fn(usize) -> T + Sync) -> Vec<T> {
let slots: Vec<OnceLock<T>> = (0..n).map(|_| OnceLock::new()).collect();
each(n, &|i| {
if let Some(slot) = slots.get(i) {
let _ = slot.set(job(i));
}
});
slots.into_iter().enumerate().map(|(i, s)| s.into_inner().unwrap_or_else(|| job(i))).collect()
}
/// How far the samples of `from` lie from the surface in `to` (distances capped at `cap`).
fn deviate(from: &Mesh, to: &TriangleTree, h: f64, tol: f64, cap: f64, each: Each) -> Part {
let n = from.triangles.len();
let parts = gather(n.div_ceil(CHUNK), each, |k| {
let mut part = Part::default();
let mut seen: HashSet<[u64; 3]> = HashSet::new();
let mut stack = Vec::new();
for ti in k * CHUNK..((k + 1) * CHUNK).min(n) {
let Some(t) = from.triangles.get(ti).and_then(|t| from.tri(t)) else { continue };
if !t.iter().all(|p| p.is_finite()) {
continue;
}
let face = from.tri_face.get(ti).copied().unwrap_or(0);
for_each_sample(&t, h, &mut stack, |p, w| {
// A corner is shared by the pieces around it: measured once.
if w == 0.0 && !seen.insert([p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]) {
return;
}
let d = to.nearest(p).map_or(cap, |(d, _)| d.min(cap));
part.points += 1;
if w > 0.0 {
*part.face_area.entry(face).or_default() += w;
}
part.sum += w * d;
part.weight += w;
if d > part.max {
part.max = d;
part.at = p;
}
if d > tol {
part.beyond += w;
part.off.push(Off { p, d, w, face });
}
});
}
part
});
let mut all = Part::default();
for p in parts {
if p.max > all.max {
all.max = p.max;
all.at = p.at;
}
all.sum += p.sum;
all.weight += p.weight;
all.beyond += p.beyond;
all.points += p.points;
all.off.extend(p.off);
for (f, w) in p.face_area {
*all.face_area.entry(f).or_default() += w;
}
}
all
}
fn deviation(p: &Part) -> Deviation {
Deviation {
max: p.max,
at: p.at,
mean: if p.weight > 0.0 { p.sum / p.weight } else { 0.0 },
beyond_area: p.beyond,
area: p.weight,
points: p.points,
}
}
/// Deviating samples kept for the regions, at most (beyond it, every k-th, standing for k).
const MAX_OFF: usize = 4_000_000;
/// A deviating sample and the side of the material it bounds.
#[derive(Clone, Copy)]
struct Pt {
off: Off,
from_a: bool,
side: Side,
}
/// The deviating samples of `from` with their sides: outside the other mesh, they bound
/// material only `from` has; inside it, material only the other has.
fn sided(mut off: Vec<Off>, other: &Mesh, from_a: bool, each: Each) -> Vec<Pt> {
if off.len() > MAX_OFF {
let k = off.len().div_ceil(MAX_OFF);
off = off.into_iter().step_by(k).map(|o| Off { w: o.w * k as f64, ..o }).collect();
}
if off.is_empty() {
return Vec::new();
}
let inside = other.inside_index();
let off = &off;
let parts = gather(off.len().div_ceil(CHUNK), each, |k| {
off.iter()
.skip(k * CHUNK)
.take(CHUNK)
.map(|o| {
let within = inside.contains(o.p);
let side = if within == from_a { Side::OnlyInB } else { Side::OnlyInA };
Pt { off: *o, from_a, side }
})
.collect::<Vec<_>>()
});
parts.into_iter().flatten().collect()
}
fn find(parent: &mut [usize], mut i: usize) -> usize {
for _ in 0..parent.len() {
let Some(&p) = parent.get(i) else { return i };
if p == i {
return i;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = gp;
}
i = gp;
}
i
}
fn union(parent: &mut [usize], a: usize, b: usize) -> bool {
let (ra, rb) = (find(parent, a), find(parent, b));
if ra == rb {
return false;
}
if let Some(slot) = parent.get_mut(ra.max(rb)) {
*slot = ra.min(rb);
}
true
}
/// Groups of samples (indices into `pts`), one side each: samples in the same or neighbouring
/// cells of a grid of `cell` join, and so do groups of one side whose boxes, grown by `cell`,
/// overlap (so that no two regions measure the same material).
fn clusters(pts: &[Pt], cell: f64) -> Vec<Vec<usize>> {
let key = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64);
let mut cells: HashMap<(bool, i64, i64, i64), usize> = HashMap::new();
let mut of_point = Vec::with_capacity(pts.len());
for pt in pts {
let (x, y, z) = key(pt.off.p);
let next = cells.len();
of_point.push(*cells.entry((pt.side == Side::OnlyInA, x, y, z)).or_insert(next));
}
let mut parent: Vec<usize> = (0..cells.len()).collect();
for (&(s, x, y, z), &id) in &cells {
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(&o) = cells.get(&(s, x + dx, y + dy, z + dz)) {
union(&mut parent, id, o);
}
}
}
}
}
let groups_of = |parent: &mut [usize]| -> Vec<usize> { of_point.iter().map(|&c| find(parent, c)).collect() };
let grow = Vec3::new(cell, cell, cell);
// Join groups of one side whose grown boxes overlap, until none do (sort and sweep along X).
for _ in 0..16 {
let groups = groups_of(&mut parent);
let mut boxes: HashMap<usize, (Side, Aabb3)> = HashMap::new();
for (pt, g) in pts.iter().zip(&groups) {
boxes.entry(*g).or_insert((pt.side, Aabb3::EMPTY)).1.add(pt.off.p);
}
let mut list: Vec<(usize, Side, Aabb3)> =
boxes.into_iter().map(|(g, (s, b))| (g, s, Aabb3 { min: b.min - grow, max: b.max + grow })).collect();
list.sort_by(|a, b| a.2.min.x.total_cmp(&b.2.min.x).then(a.0.cmp(&b.0)));
let mut joined = false;
for (k, (ga, sa, ba)) in list.iter().enumerate() {
for (gb, sb, bb) in list.iter().skip(k + 1) {
if bb.min.x > ba.max.x {
break;
}
if sa == sb && boxes_meet(ba, bb, 3) {
joined |= union(&mut parent, *ga, *gb);
}
}
}
if !joined {
break;
}
}
let mut by: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, g) in groups_of(&mut parent).into_iter().enumerate() {
by.entry(g).or_default().push(i);
}
let mut out: Vec<Vec<usize>> = by.into_values().collect();
out.sort_by_key(|g| g.first().copied().unwrap_or(0));
out
}
/// Line and triangle pairs tested for one region, at most; beyond it, its volume is not measured.
const MAX_LINE_WORK: usize = 200_000_000;
/// Where the lines lie in their cells, across the axis (off the middle, so that lines through
/// the edges and corners of a mesh are rare).
const LINE_OFFSET: [f64; 2] = [0.513_72, 0.527_18];
/// The volume of the material only `side`'s mesh has inside `bx`, from lines along the box's
/// shortest axis: (volume, centre, box, lines), or `None` when that would take too much work.
fn region_volume(trees: [&TriangleTree; 2], bx: &Aabb3, side: Side, max_lines: usize) -> Option<(f64, Vec3, Aabb3, Lines)> {
let size = bx.size();
let k = if size.x <= size.y && size.x <= size.z {
0
} else if size.y <= size.z {
1
} else {
2
};
let (ku, kv) = ((k + 1) % 3, (k + 2) % 3);
let (eu, ev) = (comp(size, ku), comp(size, kv));
let max_lines = max_lines.max(1);
let ds = (eu * ev / max_lines as f64).sqrt();
let count = |e: f64| if ds > 0.0 && ds.is_finite() { (e / ds).ceil().clamp(1.0, max_lines as f64) as usize } else { 1 };
let (nu, nv) = (count(eu), count(ev));
let (du, dv) = (eu / nu as f64, ev / nv as f64);
let (u0, v0, t0, t1) = (comp(bx.min, ku), comp(bx.min, kv), comp(bx.min, k), comp(bx.max, k));
let line_u = |i: usize| u0 + (i as f64 + LINE_OFFSET[0]) * du;
let line_v = |j: usize| v0 + (j as f64 + LINE_OFFSET[1]) * dv;
// The lines whose positions fall in [lo, hi].
let span = |lo: f64, hi: f64, origin: f64, step: f64, offset: f64, n: usize| -> Option<(usize, usize)> {
if !(step > 0.0) {
return Some((0, 0));
}
let a = ((lo - origin) / step - offset).ceil().max(0.0);
let b = ((hi - origin) / step - offset).floor().min(n as f64 - 1.0);
(a <= b).then_some((a as usize, b as usize))
};
// Where each line crosses each mesh: (line, coordinate along the axis, +1 in / -1 out, mesh).
let mut hits: Vec<(u32, f64, i8, u8)> = Vec::new();
let mut work = 0usize;
for (m, tree) in trees.iter().enumerate() {
for ti in tree.column(bx, k) {
let Some(t) = tree.tris.get(ti) else { continue };
let nk = comp((t[1] - t[0]).cross(t[2] - t[0]), k);
if nk == 0.0 || !nk.is_finite() {
continue;
}
// Lines run towards +axis: they enter where the outward normal points against them.
let sign: i8 = if nk < 0.0 { 1 } else { -1 };
let tb = box_of(t);
let (Some((i0, i1)), Some((j0, j1))) = (
span(comp(tb.min, ku), comp(tb.max, ku), u0, du, LINE_OFFSET[0], nu),
span(comp(tb.min, kv), comp(tb.max, kv), v0, dv, LINE_OFFSET[1], nv),
) else {
continue;
};
work = work.saturating_add((i1 - i0 + 1).saturating_mul(j1 - j0 + 1));
if work > MAX_LINE_WORK {
return None;
}
for i in i0..=i1 {
let pu = line_u(i);
for j in j0..=j1 {
let pv = line_v(j);
// Watertight edge tests (Woop, Benthin & Wald, JCGT 2013) in the plane across
// the axis: two triangles get exactly opposite values on the edge they share.
let at = |p: Vec3| (comp(p, ku) - pu, comp(p, kv) - pv);
let ((ax, ay), (bx2, by), (cx, cy)) = (at(t[0]), at(t[1]), at(t[2]));
let u = cx * by - cy * bx2;
let v = ax * cy - ay * cx;
let w = bx2 * ay - by * ax;
if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) {
continue;
}
let det = u + v + w;
if det == 0.0 {
continue;
}
let along = (u * comp(t[0], k) + v * comp(t[1], k) + w * comp(t[2], k)) / det;
if let (true, Ok(line)) = (along.is_finite(), u32::try_from(i * nv + j)) {
hits.push((line, along, sign, m as u8));
}
}
}
}
}
hits.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
let wanted = |w: [i32; 2]| match side {
Side::OnlyInA => w[0] > 0 && w[1] <= 0,
Side::OnlyInB => w[1] > 0 && w[0] <= 0,
};
let (mut length, mut moment, mut found, mut skipped) = (0.0, Vec3::ZERO, Aabb3::EMPTY, 0);
let mut pieces: Vec<(f64, f64)> = Vec::new();
let mut rest = hits.as_slice();
while let Some(first) = rest.first() {
let line = first.0;
let (this, after) = rest.split_at(rest.iter().take_while(|h| h.0 == line).count());
rest = after;
let (pu, pv) = (line_u(line as usize / nv), line_v(line as usize % nv));
let (mut w, mut last) = ([0i32; 2], [None::<(f64, i8)>; 2]);
let mut prev = f64::NEG_INFINITY;
pieces.clear();
for &(_, along, sign, m) in this {
let m = usize::from(m.min(1));
// A line through an edge or corner shared by several triangles crosses there once.
let eps = 1e-9 * (1.0 + along.abs());
if last.get(m).copied().flatten().is_some_and(|(t, s)| s == sign && (along - t).abs() <= eps) {
continue;
}
if wanted(w) {
let (s0, s1) = (prev.max(t0), along.min(t1));
// Where the two surfaces meet, rounding leaves pieces of no length between them.
if s1 - s0 > 1e-9 * (1.0 + s1.abs()) {
pieces.push((s0, s1));
}
}
if let Some(x) = w.get_mut(m) {
*x += i32::from(sign);
}
if let Some(x) = last.get_mut(m) {
*x = Some((along, sign));
}
prev = along;
}
// A line that leaves a mesh more or less often than it enters passed through a gap.
if w != [0, 0] {
skipped += 1;
continue;
}
for &(s0, s1) in &pieces {
let l = s1 - s0;
length += l;
moment += from_axes(k, pu, pv, (s0 + s1) * 0.5) * l;
found.add(from_axes(k, pu - du * 0.5, pv - dv * 0.5, s0));
found.add(from_axes(k, pu + du * 0.5, pv + dv * 0.5, s1));
}
}
let centre = if length > 0.0 { moment / length } else { bx.center() };
Some((length * du * dv, centre, found, Lines { axis: k, spacing: [du, dv], count: nu * nv, skipped }))
}
/// Compare the closed meshes `a` and `b` (in one frame): see the module documentation.
pub fn compare(a: &Mesh, b: &Mesh, o: &CompareOptions, each: Each) -> Comparison {
let tol = if o.tolerance.is_finite() { o.tolerance.max(0.0) } else { 0.01 };
let max_points = o.max_points.max(1);
let h = spacing_for(a, o.spacing, max_points).max(spacing_for(b, o.spacing, max_points));
let cap = a.bounds().union(&b.bounds()).diagonal().max(1.0);
let Ok([ta, tb]) = <[TriangleTree; 2]>::try_from(gather(2, each, |i| TriangleTree::new(if i == 0 { a } else { b }))) else {
return Comparison::default();
};
let mut pa = deviate(a, &tb, h, tol, cap, each);
let mut pb = deviate(b, &ta, h, tol, cap, each);
let (a_to_b, b_to_a) = (deviation(&pa), deviation(&pb));
let face_area = [std::mem::take(&mut pa.face_area), std::mem::take(&mut pb.face_area)];
let mut pts = sided(pa.off, b, true, each);
pts.extend(sided(pb.off, a, false, each));
// Samples on one surface are at most `h` apart, and the two surfaces at a region's rim come
// within the tolerance of each other: cells of that size keep a region together.
let cell = h.max(3.0 * tol).max(1e-6);
struct Found {
side: Side,
area: [f64; 2],
bbox: Aabb3,
max: f64,
at: Vec3,
faces: [Vec<(u32, f64)>; 2],
weight: f64,
}
let mut found: Vec<Found> = clusters(&pts, cell)
.iter()
.filter_map(|g| {
let first = pts.get(*g.first()?)?;
let mut f = Found {
side: first.side,
area: [0.0; 2],
bbox: Aabb3::EMPTY,
max: 0.0,
at: first.off.p,
faces: [Vec::new(), Vec::new()],
weight: 0.0,
};
let mut faces: [HashMap<u32, f64>; 2] = Default::default();
for pt in g.iter().filter_map(|&i| pts.get(i)) {
let m = usize::from(!pt.from_a);
if let Some(x) = f.area.get_mut(m) {
*x += pt.off.w;
}
if let Some(x) = faces.get_mut(m) {
*x.entry(pt.off.face).or_default() += pt.off.w;
}
f.bbox.add(pt.off.p);
f.weight += pt.off.w * pt.off.d;
if pt.off.d > f.max {
f.max = pt.off.d;
f.at = pt.off.p;
}
}
for (to, from) in f.faces.iter_mut().zip(faces) {
*to = from.into_iter().collect();
to.sort_unstable_by_key(|x| x.0);
}
Some(f)
})
.collect();
// Which to measure: largest first by the volume the surfaces suggest (area × distance).
found.sort_by(|x, y| y.weight.total_cmp(&x.weight).then(y.max.total_cmp(&x.max)));
let unlisted = found.len().saturating_sub(o.max_regions);
found.truncate(o.max_regions);
let grow = Vec3::new(cell, cell, cell);
let measured = gather(found.len(), each, |i| {
let f = found.get(i)?;
region_volume([&ta, &tb], &Aabb3 { min: f.bbox.min - grow, max: f.bbox.max + grow }, f.side, o.max_lines)
});
let mut regions: Vec<Region> = found
.into_iter()
.zip(measured)
.map(|(f, m)| {
let (volume, center, bbox, lines) = match m {
Some((v, c, b, l)) if v > 0.0 => (v, c, b, l),
Some((v, _, _, l)) => (v, f.bbox.center(), f.bbox, l),
None => (f64::NAN, f.bbox.center(), f.bbox, Lines::default()),
};
let [faces_a, faces_b] = f.faces;
Region {
side: f.side,
volume,
center,
bbox,
area_a: f.area[0],
area_b: f.area[1],
max_deviation: f.max,
at: f.at,
faces_a,
faces_b,
lines,
}
})
.collect();
let key = |r: &Region| if r.volume.is_finite() { r.volume } else { -1.0 };
regions.sort_by(|x, y| key(y).total_cmp(&key(x)).then(y.max_deviation.total_cmp(&x.max_deviation)));
Comparison { a_to_b, b_to_a, regions, unlisted, spacing: h, face_area }
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
+118
View File
@@ -0,0 +1,118 @@
use super::*;
/// An axis-aligned box as a closed mesh (each side two triangles, counter-clockwise from outside).
fn cuboid(lo: Vec3, hi: Vec3) -> Mesh {
let v = |x: bool, y: bool, z: bool| Vec3::new(if x { hi.x } else { lo.x }, if y { hi.y } else { lo.y }, if z { hi.z } else { lo.z });
let quads = [
[v(false, false, false), v(false, true, false), v(true, true, false), v(true, false, false)],
[v(false, false, true), v(true, false, true), v(true, true, true), v(false, true, true)],
[v(false, false, false), v(true, false, false), v(true, false, true), v(false, false, true)],
[v(false, true, false), v(false, true, true), v(true, true, true), v(true, true, false)],
[v(false, false, false), v(false, false, true), v(false, true, true), v(false, true, false)],
[v(true, false, false), v(true, true, false), v(true, true, true), v(true, false, true)],
];
let mut m = Mesh::default();
for (f, q) in quads.iter().enumerate() {
let base = m.positions.len() as u32;
m.positions.extend_from_slice(q);
m.normals.extend_from_slice(&[Vec3::Z; 4]);
m.triangles.push([base, base + 1, base + 2]);
m.triangles.push([base, base + 2, base + 3]);
m.tri_face.extend([f as u32, f as u32]);
}
m
}
#[test]
fn the_tree_finds_the_nearest_point_as_a_search_of_every_triangle_does() {
// A crumpled sheet of triangles from a fixed pseudo-random sequence.
let mut seed = 12345u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let mut m = Mesh::default();
for _ in 0..500 {
let c = Vec3::new(rnd() * 100.0, rnd() * 50.0, rnd() * 20.0);
let base = m.positions.len() as u32;
for _ in 0..3 {
m.positions.push(c + Vec3::new(rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0));
}
m.triangles.push([base, base + 1, base + 2]);
}
let tree = TriangleTree::new(&m);
assert_eq!(tree.len(), 500);
for _ in 0..300 {
let p = Vec3::new(rnd() * 140.0 - 20.0, rnd() * 90.0 - 20.0, rnd() * 60.0 - 20.0);
let brute =
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| closest_on_triangle(p, a, b, c).dist(p)).fold(f64::INFINITY, f64::min);
let (d, i) = tree.nearest(p).unwrap();
assert!((d - brute).abs() < 1e-12, "{d} vs {brute}");
let [a, b, c] = m.tri(&m.triangles[i as usize]).unwrap();
assert!((closest_on_triangle(p, a, b, c).dist(p) - d).abs() < 1e-12);
}
assert!(TriangleTree::new(&Mesh::default()).nearest(Vec3::ZERO).is_none());
}
#[test]
fn a_mesh_and_its_copy_are_identical() {
let a = cuboid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0));
let c = compare(&a, &a.clone(), &CompareOptions::default(), &serial);
assert!(c.regions.is_empty(), "{:?}", c.regions);
// The same triangles: nothing but rounding apart.
assert!(c.a_to_b.max < 1e-9 && c.b_to_a.max < 1e-9, "{} {}", c.a_to_b.max, c.b_to_a.max);
// Sampled at the requested spacing: the whole surface, and nothing beyond the tolerance.
assert!((c.a_to_b.area - 2.0 * (1200.0 + 400.0 + 300.0)).abs() < 1e-6, "{}", c.a_to_b.area);
assert_eq!(c.a_to_b.beyond_area, 0.0);
assert_eq!(c.spacing, 0.5);
}
#[test]
fn a_box_moved_up_has_material_only_it_has_on_top_and_none_below() {
// b is a moved 0.3 up: a slab 40 x 30 x 0.3 of each side.
let a = cuboid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0));
let b = cuboid(Vec3::new(0.0, 0.0, 0.3), Vec3::new(40.0, 30.0, 10.3));
let c = compare(&a, &b, &CompareOptions::default(), &serial);
assert_eq!(c.regions.len(), 2, "{:?}", c.regions);
assert!((c.a_to_b.max - 0.3).abs() < 1e-9 && (c.b_to_a.max - 0.3).abs() < 1e-9);
for r in &c.regions {
// Integrated along lines 0.18 mm apart: the slab's rim falls between them.
assert!((r.volume - 360.0).abs() < 360.0 * 0.01, "{r:?}");
assert_eq!(r.lines.axis, 2);
assert_eq!(r.lines.skipped, 0);
let z = if r.side == Side::OnlyInA { 0.15 } else { 10.15 };
assert!(r.center.dist(Vec3::new(20.0, 15.0, z)) < 0.1, "{r:?}");
assert!((r.bbox.min.z - (z - 0.15)).abs() < 1e-9 && (r.bbox.max.z - (z + 0.15)).abs() < 1e-9, "{r:?}");
// Each slab is bounded by the bottom (top) of both meshes, and by the 0.3 mm of the sides
// of the one that reaches further, less what lies within the tolerance of the other.
let (outer, inner) = if r.side == Side::OnlyInA { (r.area_a, r.area_b) } else { (r.area_b, r.area_a) };
assert!((outer - (1200.0 + 140.0 * 0.29)).abs() < 2.0 && (inner - 39.98 * 29.98).abs() < 2.0, "{r:?}");
}
let sides: Vec<Side> = c.regions.iter().map(|r| r.side).collect();
assert!(sides.contains(&Side::OnlyInA) && sides.contains(&Side::OnlyInB));
}
#[test]
fn a_box_inside_another_is_material_only_the_outer_one_has() {
// No surface of one is near the other's: one region, the shell between them.
let a = cuboid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0));
let b = cuboid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0));
let c = compare(&a, &b, &CompareOptions::default(), &serial);
assert_eq!(c.regions.len(), 1, "{:?}", c.regions);
let r = &c.regions[0];
assert_eq!(r.side, Side::OnlyInA);
assert!((r.volume - 7000.0).abs() < 7000.0 * 0.01, "{}", r.volume);
assert!(r.center.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-2, "{:?}", r.center);
assert_eq!(r.faces_a.len(), 6);
assert_eq!(r.faces_b.len(), 6);
}
#[test]
fn a_tight_budget_coarsens_the_spacing() {
let a = cuboid(Vec3::ZERO, Vec3::new(400.0, 300.0, 100.0));
let o = CompareOptions { max_points: 20_000, ..CompareOptions::default() };
let c = compare(&a, &a, &o, &serial);
assert!(c.spacing > 0.5, "{}", c.spacing);
assert!((5_000..=24_000).contains(&c.a_to_b.points), "{}", c.a_to_b.points);
assert!(c.regions.is_empty());
}
+292 -25
View File
@@ -11,12 +11,16 @@
//! A2.1 (span search).
use crate::Vec3;
use crate::bspline::{HPoint, NurbsSurface, Side, SplineError, SplineResult, find_span, invalid, local_basis_array};
use crate::bspline::{Dir, HPoint, NurbsSurface, Side, SplineError, SplineResult, find_span, invalid, local_basis_array};
/// Most loop points a fill takes.
pub const MAX_LOOP_POINTS: usize = 8192;
/// Most spans of the height field in each direction.
pub const MAX_FILL_SPANS: usize = 32;
/// How full a patch's middle may be made (`crown`): 0 the least-bending fill, positive fuller,
/// negative flatter, in fractions of the loop's size. Outside this range the value is refused.
pub const MIN_CROWN: f64 = -0.9;
pub const MAX_CROWN: f64 = 3.0;
/// Fewest spans of the height field in each direction (the first fit tried).
const MIN_FILL_SPANS: usize = 4;
/// The smoothness energy's terms: `(order in u, order in v, weight)` of `∫∫ (∂ h)²`.
@@ -50,6 +54,12 @@ pub struct LoopFill {
pub counter_clockwise: bool,
pub deviation: f64,
pub evaluations: usize,
/// The crown the fill was asked for, how many control points took its bump, and how far the
/// bump moved the surface at the sampled loop points (always exactly 0: the bump vanishes
/// along the loop, so the boundary is untouched).
pub crown: f64,
pub crowned: usize,
pub crown_drift: f64,
}
/// Fill the closed loop of `sides` curves (side `k` given by `point(k, t)`, `t` from 0 at its
@@ -86,13 +96,24 @@ pub struct LoopFill {
/// point (measured along the normal, which bounds the distance) is the fill; otherwise the
/// sampling doubles, and when that runs out, "not supported yet" with the closest deviation
/// reached.
pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>, tol: f64) -> SplineResult<LoopFill> {
/// - `crown` (0 for the plain fill, between [`MIN_CROWN`] and [`MAX_CROWN`]) fills the middle:
/// the surface gains `crown · size · 16·s·(1−s)·t·(1−t)` (`s`, `t` the box's coordinates scaled
/// to 0…1, `size` the box's diagonal: 1 at the middle, 0 on the box's boundary), applied to the
/// control points whose support keeps clear of the loop, so the bump vanishes along the loop
/// and the boundary does not move at all. The fitted net may be too coarse to hold such a
/// point; it is then refined by knot insertion, which leaves the surface itself as it is. A
/// loop whose fill leaves no room inside it even so is refused.
pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>, tol: f64, crown: f64) -> SplineResult<LoopFill> {
if sides == 0 || sides.saturating_mul(16) > MAX_LOOP_POINTS / 2 {
return Err(SplineError::TooLarge(format!("a loop of {sides} sides (at most {})", MAX_LOOP_POINTS / 32)));
}
if !(tol.is_finite() && tol > 0.0) {
return invalid("the fill tolerance must be positive");
}
// A range test also throws out NaN.
if !(MIN_CROWN..=MAX_CROWN).contains(&crown) {
return invalid("the crown must be between -0.9 and 3");
}
let mut evaluations = 0usize;
let mut eval = |k: usize, t: f64| -> SplineResult<Vec3> {
evaluations += 1;
@@ -116,7 +137,7 @@ pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>
}
// The loop in order: fitted and checked points alternating.
let all: Vec<Vec3> = fit.iter().zip(&check).flat_map(|(a, b)| [*a, *b]).collect();
match fit_once(&fit, &all, tol)? {
match fit_once(&fit, &all, tol, crown)? {
Fit::Done(mut fill) => {
fill.evaluations = evaluations;
return Ok(*fill);
@@ -147,7 +168,7 @@ enum Fit {
/// One round of [`fill_loop`]: fit the points `fit`, check every point of `all` (the loop in
/// order, `fit` among them).
fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64, crown: f64) -> SplineResult<Fit> {
let m = fit.len();
let o = fit.iter().fold(Vec3::ZERO, |a, p| a + *p) / m as f64;
let at = |k: usize| fit.get(k % m).copied().unwrap_or(Vec3::ZERO);
@@ -236,7 +257,19 @@ fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
deviation = deviation.min(dev);
if dev <= tol && holds {
let surface = grid.surface(&heights, o, x, y, n)?;
let fill = LoopFill { surface, origin: o, axes: [x, y], normal: n, counter_clockwise, deviation: dev, evaluations: 0 };
let (surface, crowned, crown_drift) = crown_fill(surface, &uv, crown, n)?;
let fill = LoopFill {
surface,
origin: o,
axes: [x, y],
normal: n,
counter_clockwise,
deviation: dev,
evaluations: 0,
crown,
crowned,
crown_drift,
};
return Ok(Fit::Done(Box::new(fill)));
}
if !holds {
@@ -247,6 +280,193 @@ fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
Ok(Fit::Exhausted(deviation))
}
/// The fitted surface with the crown's bump (see [`fill_loop`]): `crown == 0` gives it back as it
/// is. A control point whose support rectangle keeps clear of the loop takes the bump's blossom
/// coefficients, so the surface between the perturbed points is the bubble exactly and the
/// surface over the loop is not moved at all (those points' basis functions are zero all along
/// it). A fitted net is often too coarse to hold one; it is then refined by knot insertion, which
/// leaves the surface itself as it is, until it does. Returns the crowned surface, how many
/// control points took the bump, and the farthest the bump moved the surface at a sampled loop
/// point (0).
fn crown_fill(surface: NurbsSurface, uv: &[[f64; 2]], crown: f64, normal: Vec3) -> SplineResult<(NurbsSurface, usize, f64)> {
if crown == 0.0 {
return Ok((surface, 0, 0.0));
}
let gap = loop_gap(uv);
let mut base = surface;
loop {
if let Some((crowned, points)) = crown_net(&base, uv, gap, crown, normal)? {
let mut drift = 0.0f64;
for q in uv {
if let (Ok(a), Ok(b)) = (base.eval(q[0], q[1]), crowned.eval(q[0], q[1])) {
drift = drift.max(a.dist(b));
}
}
if drift > 0.0 {
return Err(SplineError::Unsupported("crowning a patch whose loop the fill cannot keep clear".into()));
}
return Ok((crowned, points, drift));
}
// No room inside the loop yet: halve the cells (the surface itself does not change).
let spans = [0, 1].map(|d| base.count()[d].saturating_sub(3));
if spans[0] >= MAX_FILL_SPANS && spans[1] >= MAX_FILL_SPANS {
return Err(SplineError::Unsupported("crowning a patch whose loop leaves no room inside it".into()));
}
for (d, &s) in spans.iter().enumerate() {
if s < MAX_FILL_SPANS {
base = subdivide(&base, d)?;
}
}
}
}
/// The crown's bump in the net `base`: a control point whose support rectangle keeps clear of the
/// loop takes `crown · size · B(ξ, η)`, `B` the bubble's blossom at the point's knots (so the
/// surface between the perturbed points is exactly the bubble, not a fit of it) and `size` the
/// box's diagonal. `None` when the net has no such point.
fn crown_net(base: &NurbsSurface, uv: &[[f64; 2]], gap: [f64; 2], crown: f64, normal: Vec3) -> SplineResult<Option<(NurbsSurface, usize)>> {
let [cu, cv] = base.count();
let degree = base.degree();
let knots = base.knots();
let domain = base.domain();
let size = (domain[0][1] - domain[0][0]).hypot(domain[1][1] - domain[1][0]);
if !(size > 0.0 && size.is_finite() && normal.is_finite()) {
return invalid("the loop's extent is not finite");
}
// The bubble `4·s·(1−s)` per direction, as a quadratic in the plane coordinate.
let quad = |d: usize| {
let (lo, hi) = (domain[d][0], domain[d][1]);
let w = hi - lo;
(-4.0 * lo * hi / (w * w), 4.0 * (lo + hi) / (w * w), -4.0 / (w * w))
};
let (qu, qv) = (quad(0), quad(1));
let (Some(knots_u), Some(knots_v)) = (knots.first(), knots.get(1)) else { return invalid("the surface has no knot vectors") };
let mut ctrl = base.homogeneous().to_vec();
let mut points = 0usize;
for j in 0..cv {
let Some((v_span, v_knots)) = support(knots_v, degree[1], j) else { continue };
for i in 0..cu {
let Some((u_span, u_knots)) = support(knots_u, degree[0], i) else { continue };
if !clear_of_loop(u_span, v_span, uv, gap) {
continue;
}
let bump = crown * size * blossom(qu, u_knots) * blossom(qv, v_knots);
let Some(slot) = ctrl.get_mut(j * cu + i) else { continue };
let h = *slot;
let Some(p) = h.project() else { continue };
*slot = HPoint::weighted(p + normal * bump, h.w);
points += 1;
}
}
if points == 0 {
return Ok(None);
}
Ok(Some((NurbsSurface::from_homogeneous(degree, knots.clone(), ctrl)?, points)))
}
/// The same surface with a knot inserted in the middle of every span of direction `d` (0 = u):
/// the surface itself does not change, only its net is finer, which is what gives the crown room
/// inside the loop.
fn subdivide(surface: &NurbsSurface, d: usize) -> SplineResult<NurbsSurface> {
let (degree, count) = (surface.degree()[d], surface.count()[d]);
let knots = surface.knots()[d].clone();
let mut mids = Vec::new();
for i in degree..count {
let (a, b) = (knots.get(i).copied().unwrap_or(0.0), knots.get(i + 1).copied().unwrap_or(0.0));
if b > a {
mids.push(0.5 * (a + b));
}
}
let mut out = surface.clone();
for m in mids {
out = out.insert_knot(if d == 0 { Dir::S } else { Dir::T }, m)?;
}
Ok(out)
}
/// A control point's knot interval — where its basis is non-zero — and its knot triple, whose
/// blossom is the value the point must take for the spline to reproduce a quadratic exactly.
fn support(knots: &[f64], degree: usize, i: usize) -> Option<([f64; 2], [f64; 3])> {
let interval = [*knots.get(i)?, *knots.get(i + degree + 1)?];
let triple = [*knots.get(i + 1)?, *knots.get(i + 2)?, *knots.get(i + 3)?];
(interval[1] >= interval[0]).then_some((interval, triple))
}
/// A quadratic `a + b·x + c·x²`'s blossom at a knot triple.
fn blossom(quad: (f64, f64, f64), t: [f64; 3]) -> f64 {
let (a, b, c) = quad;
let mean = (t[0] + t[1] + t[2]) / 3.0;
let pair = (t[0] * t[1] + t[1] * t[2] + t[0] * t[2]) / 3.0;
a + b * mean + c * pair
}
/// Whether the parameter rectangle `u` × `v` keeps clear of the sampled loop: its middle is
/// inside the loop and no loop segment comes within `gap` of it. A control point whose support
/// keeps clear takes the crown's bump without moving the surface over the loop.
fn clear_of_loop(u: [f64; 2], v: [f64; 2], uv: &[[f64; 2]], gap: [f64; 2]) -> bool {
if !inside_loop([(u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0], uv) {
return false;
}
let lo = [u[0] - gap[0], v[0] - gap[1]];
let hi = [u[1] + gap[0], v[1] + gap[1]];
let m = uv.len();
(0..m).all(|k| {
let (Some(a), Some(b)) = (uv.get(k).copied(), uv.get((k + 1) % m).copied()) else { return false };
!segment_meets_rect(a, b, lo, hi)
})
}
/// The largest step between neighbouring samples of the loop, per direction: a rectangle this far
/// clear of the sampled loop cannot be entered by the edges between the samples either.
fn loop_gap(uv: &[[f64; 2]]) -> [f64; 2] {
let m = uv.len();
let mut gap = [0.0f64; 2];
for k in 0..m {
let (Some(a), Some(b)) = (uv.get(k).copied(), uv.get((k + 1) % m).copied()) else { continue };
for d in 0..2 {
gap[d] = gap[d].max((b[d] - a[d]).abs());
}
}
gap
}
/// Whether `p` is inside the closed loop `uv` (ray casting).
fn inside_loop(p: [f64; 2], uv: &[[f64; 2]]) -> bool {
let m = uv.len();
let mut inside = false;
for k in 0..m {
let (Some(a), Some(b)) = (uv.get(k).copied(), uv.get((k + 1) % m).copied()) else { continue };
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
inside = !inside;
}
}
inside
}
/// Whether the segment `p`–`q` meets the rectangle `lo`–`hi` (Liang–Barsky clipping).
fn segment_meets_rect(p: [f64; 2], q: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> bool {
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for d in 0..2 {
let delta = q[d] - p[d];
if delta == 0.0 {
if p[d] < lo[d] || p[d] > hi[d] {
return false;
}
continue;
}
let (mut a, mut b) = ((lo[d] - p[d]) / delta, (hi[d] - p[d]) / delta);
if a > b {
std::mem::swap(&mut a, &mut b);
}
t0 = t0.max(a);
t1 = t1.min(b);
if t0 > t1 {
return false;
}
}
true
}
/// Refuse a loop that does not bound a height field over its plane (see [`fill_loop`]).
fn check_projectable(points: &[Vec3], uv: &[[f64; 2]]) -> SplineResult<()> {
let m = uv.len();
@@ -599,7 +819,7 @@ mod tests {
// A tilted plane over a hexagon: the smoothness energy vanishes on it, so the fill is it.
let hex = ring(6, 10.0);
let plane = |x: f64, y: f64| 0.3 * x - 0.2 * y + 1.0;
let f = fill_loop(6, &mut lifted(&hex, &plane), 1e-6).unwrap();
let f = fill_loop(6, &mut lifted(&hex, &plane), 1e-6, 0.0).unwrap();
let inside = off_inside(&f, &hex, &plane);
eprintln!("plane: deviation {:e}, inside off by {inside:e}, {} evaluations", f.deviation, f.evaluations);
assert!(f.deviation <= 1e-6 && inside < 1e-9);
@@ -608,7 +828,7 @@ mod tests {
let saddle = |x: f64, y: f64| 0.05 * x * y;
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
for (name, z, bound) in [("saddle", &saddle as &dyn Fn(f64, f64) -> f64, 0.1), ("dome", &dome, 1.0)] {
let f = fill_loop(6, &mut lifted(&hex, z), 2e-5).unwrap();
let f = fill_loop(6, &mut lifted(&hex, z), 2e-5, 0.0).unwrap();
let inside = off_inside(&f, &hex, z);
eprintln!("{name}: deviation {:e}, inside off by {inside:e}", f.deviation);
assert!(f.deviation <= 2e-5 && inside < bound, "{name}: {inside}");
@@ -638,7 +858,7 @@ mod tests {
let flat_corner = vec![[-10.0, -10.0], [0.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]];
for (name, c) in [("dart", dart), ("arrow", arrow), ("plus", plus), ("flat corner", flat_corner)] {
let n = c.len();
let f = fill_loop(n, &mut lifted(&c, &z), 1e-4).unwrap();
let f = fill_loop(n, &mut lifted(&c, &z), 1e-4, 0.0).unwrap();
let inside = off_inside(&f, &c, &z);
eprintln!("{name}: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
// The least-bending fill, not the cylinder: within a few tenths of a millimetre of it.
@@ -647,7 +867,7 @@ mod tests {
let mut back = c.clone();
back.rotate_left(2);
back.reverse();
let g = fill_loop(n, &mut lifted(&back, &z), 1e-4).unwrap();
let g = fill_loop(n, &mut lifted(&back, &z), 1e-4, 0.0).unwrap();
assert_ne!(f.counter_clockwise, g.counter_clockwise);
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0)] {
let p = Vec3::new(x, y, z(x, y));
@@ -663,7 +883,7 @@ mod tests {
// scaled and moved alike.
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
let arrow = [[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
let f = fill_loop(5, &mut lifted(&arrow, &z), 1e-4).unwrap();
let f = fill_loop(5, &mut lifted(&arrow, &z), 1e-4, 0.0).unwrap();
let axis = Vec3::new(1.0, -2.0, 0.5).normalized().unwrap();
let shift = Vec3::new(30.0, -12.0, 7.0);
for scale in [100.0, 0.01] {
@@ -674,7 +894,7 @@ mod tests {
p * c + axis.cross(p) * s + axis * (axis.dot(p) * (1.0 - c)) + shift * scale
};
let mut moved = |k: usize, t: f64| lifted(&arrow, &z)(k, t).map(place);
let g = fill_loop(5, &mut moved, 1e-4 * scale).unwrap();
let g = fill_loop(5, &mut moved, 1e-4 * scale, 0.0).unwrap();
let mut worst = 0.0f64;
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0), (9.0, -2.0), (12.0, 0.5)] {
let p = Vec3::new(x, y, z(x, y));
@@ -690,7 +910,7 @@ mod tests {
// A 200 × 1 mm slot on a cylinder of radius 300 running along it.
let z = |x: f64, _: f64| 300.0 - (90_000.0 - (x - 100.0) * (x - 100.0)).sqrt();
let slot = [[0.0, 0.0], [200.0, 0.0], [200.0, 1.0], [0.0, 1.0]];
let f = fill_loop(4, &mut lifted(&slot, &z), 2e-4).unwrap();
let f = fill_loop(4, &mut lifted(&slot, &z), 2e-4, 0.0).unwrap();
let inside = off_inside(&f, &slot, &z);
eprintln!("slot: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
assert!(f.deviation <= 2e-4 && inside < 1e-2);
@@ -702,7 +922,7 @@ mod tests {
let flat = |_: f64, _: f64| 0.0;
// A figure eight (lobes of different sizes).
let eight = [[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 6.0]];
assert!(unsupported(fill_loop(4, &mut lifted(&eight, &flat), 1e-4)));
assert!(unsupported(fill_loop(4, &mut lifted(&eight, &flat), 1e-4, 0.0)));
// A loop with a vertical spike in it (up along the normal and down again).
let spike = [
Vec3::ZERO,
@@ -713,30 +933,77 @@ mod tests {
Vec3::new(5.0, 10.0, 0.0),
Vec3::new(0.0, 10.0, 0.0),
];
assert!(unsupported(fill_loop(7, &mut polygon3(&spike), 1e-4)));
assert!(unsupported(fill_loop(7, &mut polygon3(&spike), 1e-4, 0.0)));
// A loop that runs back over itself.
let back = [[0.0, 0.0], [10.0, 0.0], [4.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
assert!(unsupported(fill_loop(5, &mut lifted(&back, &flat), 1e-4)));
assert!(unsupported(fill_loop(5, &mut lifted(&back, &flat), 1e-4, 0.0)));
// A loop too wavy for one surface.
let wavy = |x: f64, y: f64| (x * 9.0).sin() * (y * 7.0).cos();
assert!(unsupported(fill_loop(64, &mut lifted(&ring(64, 10.0), &wavy), 1e-6)));
assert!(unsupported(fill_loop(64, &mut lifted(&ring(64, 10.0), &wavy), 1e-6, 0.0)));
}
#[test]
fn hostile_loops_are_refused() {
let flat = |_: f64, _: f64| 0.0;
let five = ring(5, 10.0);
assert!(fill_loop(0, &mut lifted(&five, &flat), 1e-4).is_err());
assert!(fill_loop(5, &mut |_, _| Some(Vec3::X), 1e-4).is_err(), "no area");
assert!(fill_loop(5, &mut |_, _| None, 1e-4).is_err());
assert!(fill_loop(5, &mut |k, t| Some(Vec3::new(k as f64, t, f64::NAN)), 1e-4).is_err());
assert!(fill_loop(5, &mut lifted(&five, &flat), 0.0).is_err() && fill_loop(5, &mut lifted(&five, &flat), f64::NAN).is_err());
assert!(fill_loop(10_000, &mut lifted(&five, &flat), 1e-4).is_err());
assert!(fill_loop(0, &mut lifted(&five, &flat), 1e-4, 0.0).is_err());
assert!(fill_loop(5, &mut |_, _| Some(Vec3::X), 1e-4, 0.0).is_err(), "no area");
assert!(fill_loop(5, &mut |_, _| None, 1e-4, 0.0).is_err());
assert!(fill_loop(5, &mut |k, t| Some(Vec3::new(k as f64, t, f64::NAN)), 1e-4, 0.0).is_err());
assert!(fill_loop(5, &mut lifted(&five, &flat), 0.0, 0.0).is_err() && fill_loop(5, &mut lifted(&five, &flat), f64::NAN, 0.0).is_err());
assert!(fill_loop(10_000, &mut lifted(&five, &flat), 1e-4, 0.0).is_err());
let huge = |x: f64, y: f64| (x * y) * 1e300;
let _ = fill_loop(5, &mut lifted(&five, &huge), 1e-4);
let _ = fill_loop(5, &mut lifted(&five, &huge), 1e-4, 0.0);
let tiny: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e-300, p[1] * 1e-300]).collect();
let _ = fill_loop(5, &mut lifted(&tiny, &flat), 1e-4);
let _ = fill_loop(5, &mut lifted(&tiny, &flat), 1e-4, 0.0);
let big: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e300, p[1] * 1e300]).collect();
let _ = fill_loop(5, &mut lifted(&big, &flat), 1e-4);
let _ = fill_loop(5, &mut lifted(&big, &flat), 1e-4, 0.0);
}
/// A patch whose rim is a dome: the crown fills its middle by `crown` times the loop's size
/// and leaves the loop — the body's own edges — exactly where it was.
#[test]
fn a_crown_fills_the_middle_of_a_patch_and_leaves_its_loop_alone() {
let hex = ring(6, 10.0);
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
let plain = fill_loop(6, &mut lifted(&hex, &dome), 2e-5, 0.0).unwrap();
// Crown 0 is the fit as it was: no refinement, no bump, nothing moved.
assert_eq!((plain.crown, plain.crowned, plain.crown_drift), (0.0, 0, 0.0));
assert_eq!(plain.surface.count(), [19, 17], "crown 0 keeps the fitted net (16 × 14 spans)");
let crown = 0.25;
let full = fill_loop(6, &mut lifted(&hex, &dome), 2e-5, crown).unwrap();
assert!(full.crowned > 0, "the crown needs room inside the loop");
assert_eq!(full.crown_drift, 0.0, "the bump must not move the loop at all");
assert_eq!(full.deviation, plain.deviation, "the crown must not touch the fit");
// The middle of the box rises by `crown` times the loop's size: the bubble is 1 there.
let [du, dv] = plain.surface.domain();
let (uc, vc) = ((du[0] + du[1]) / 2.0, (dv[0] + dv[1]) / 2.0);
let size = (du[1] - du[0]).hypot(dv[1] - dv[0]);
let mid = |f: &LoopFill| f.surface.eval(uc, vc).unwrap_or(Vec3::ZERO);
let bump = (mid(&full) - mid(&plain)).dot(plain.normal);
eprintln!("crown {crown}: {} points took the bump, the middle rose by {bump} of {}", full.crowned, crown * size);
assert!((bump - crown * size).abs() < 1e-9 * size, "the middle rose by {bump}, wanted {}", crown * size);
// A flatter crown lowers the middle by the same.
let flat = fill_loop(6, &mut lifted(&hex, &dome), 2e-5, -crown).unwrap();
let dip = (mid(&flat) - mid(&plain)).dot(plain.normal);
assert!((dip + crown * size).abs() < 1e-9 * size, "the middle fell by {dip}");
// The loop's own points are still on the surface, as closely as they were.
let off = |f: &LoopFill| {
hex.iter()
.map(|p| f.point_over(Vec3::new(p[0], p[1], dome(p[0], p[1]))).map_or(f64::INFINITY, |q| (q.z - dome(p[0], p[1])).abs()))
.fold(0.0f64, f64::max)
};
assert!(off(&full) <= off(&plain) + 1e-9, "the crowned fill left the loop: {:e} against {:e}", off(&full), off(&plain));
}
#[test]
fn hostile_crowns_are_refused() {
let hex = ring(6, 10.0);
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
for crown in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 3.01, -0.91, 1e300] {
assert!(fill_loop(6, &mut lifted(&hex, &dome), 2e-5, crown).is_err(), "crown {crown}");
}
assert!(fill_loop(6, &mut lifted(&hex, &dome), 2e-5, MAX_CROWN).is_ok());
assert!(fill_loop(6, &mut lifted(&hex, &dome), 2e-5, MIN_CROWN).is_ok());
}
}
+1
View File
@@ -11,6 +11,7 @@
mod arcfit;
pub mod brep;
pub mod bspline;
pub mod compare;
pub mod env;
pub mod fill;
mod mesh;
+12 -4
View File
@@ -640,7 +640,8 @@ mod tests {
fn seekable_preview_defers_large_source_blob_until_document_read() {
let mut doc = Document::new("Deferred source");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new() }, None).unwrap();
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
@@ -699,7 +700,8 @@ mod tests {
fn native_cache_round_trip_and_preview_defer_original_sources() {
let mut doc = Document::new("Native cache");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new() }, None).unwrap();
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let bodies = native_geometry();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap();
let entries = parts(&bytes);
@@ -811,7 +813,12 @@ mod tests {
let mut doc = Document::new("Payloads");
for _ in 0..2 {
doc.add_feature(
FeatureKind::Import { file: "example.step".into(), step: "ISO-10303-21;\nEND-ISO-10303-21;".into(), components: Vec::new() },
FeatureKind::Import {
file: "example.step".into(),
step: "ISO-10303-21;\nEND-ISO-10303-21;".into(),
components: Vec::new(),
skip: Vec::new(),
},
None,
)
.unwrap();
@@ -1167,7 +1174,8 @@ mod tests {
.collect();
let step = solidcraft_kernel::step_export(&bodies.iter().collect::<Vec<_>>(), "SolidCraft storage benchmark").unwrap();
let mut doc = Document::new("Generated STEP benchmark");
doc.add_feature(FeatureKind::Import { file: "generated.step".into(), step: step.into(), components: Vec::new() }, None).unwrap();
doc.add_feature(FeatureKind::Import { file: "generated.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
measure_storage(&doc, "step");
}
+1 -1
View File
@@ -182,7 +182,7 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
Ok(StepFeature {
name,
body_names,
kind: solidcraft_doc::FeatureKind::Import { file: file_name, step: text.into(), components: imp.tree.clone() },
kind: solidcraft_doc::FeatureKind::Import { file: file_name, step: text.into(), components: imp.tree.clone(), skip: Vec::new() },
warnings: iges_warnings.into_iter().chain(imp.warnings.iter().cloned()).collect(),
import: imp,
})
+15
View File
@@ -2122,6 +2122,21 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
Err(_) => {}
}
}
// A curved blend follows the whole tangent chain. Give it all picks together so
// picks on that chain are consumed once, rather than chamfering its new rails again.
if tools.is_err() && edges.len() > 1 {
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, edges)?;
let all = Body::unique_edges(&solid);
let same_chain = ids
.first()
.and_then(|id| all.iter().find(|e| e.id() == *id))
.and_then(|e| smooth_chain(&solid, e))
.is_some_and(|chain| ids.len() > 1 && ids.iter().all(|id| chain.contains(id)));
if same_chain && let Ok(result) = blend(&cur, edges, distance, Shape::Flat, "chamfer") {
return Ok(result);
}
}
let start = cur.clone();
let one_by_one = || -> Result<Body> {
let mut cur = start.clone();
+72 -1
View File
@@ -389,7 +389,78 @@ pub(crate) fn accept(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Res
}
let sel = select(input, edges)?;
check_in(&sel, size)?;
volume_in(&sel, input, out, size)
let out = volume_in(&sel, input, out, size)?;
right_way(input, out, edges, size)
}
/// Whether the edge of `body` nearest `p` is convex (`Some(true)`: a chamfer takes material
/// away) or concave (`Some(false)`: it fills), from the two faces' outward normals and the
/// directions into each face across the edge at `p`. `None` unless that is clear-cut: faces
/// meeting at a few degrees of tangent or of a knife edge, an edge not between two faces.
fn convex_at(body: &Body, p: Vec3) -> Option<bool> {
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
let tol = (body.size() * 1e-4).max(1e-4);
let (i, _) = body.nearest_edge(p, tol).ok()??;
let edge = Body::unique_edges(&body.solid).into_iter().nth(i)?;
let mut sides = Vec::new();
for f in body.solid.face_iter() {
for e in f.boundary_iters().into_iter().flatten().filter(|e| e.id() == edge.id()) {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
let t = c.search_nearest_parameter(crate::body::p3(p), None, 100)?.clamp(t0.min(t1), t0.max(t1));
let d = c.der(t);
let dir = Vec3::new(d.x, d.y, d.z).normalized()?;
let (_, n, _) = crate::curveblend::project(f, p, None)?;
// A face's boundary runs counter-clockwise about its outward normal: its inside is
// to the left of the edge.
sides.push((n, n.cross(dir).normalized()?));
}
}
let [(n1, t1), (n2, t2)] = sides[..] else { return None };
// About 3°: the faces neither nearly tangent nor folded onto each other.
const CLEAR: f64 = 0.05;
if t1.dot(t2) > 1.0 - CLEAR || t1.dot(t2) < -1.0 + CLEAR {
return None;
}
let (a, b) = (n1.dot(t2), n2.dot(t1));
if a < -CLEAR && b < -CLEAR {
Some(true)
} else if a > CLEAR && b > CLEAR {
Some(false)
} else {
None
}
}
/// A chamfer of convex edges takes material away and one of concave edges fills it: a result
/// that went the other way (a chamfer on a curved edge running off a face it skims, which the
/// wedges can't bound) is refused rather than kept. Rounds are left to the wedges: a round
/// rolled along a smooth chain may run on round convex and concave stretches alike.
fn right_way(input: &Body, out: Body, edges: &[Vec3], size: Size) -> Result<Body> {
if matches!(size, Size::Round(_)) {
return Ok(out);
}
let ways: Option<Vec<bool>> = edges.iter().map(|p| convex_at(input, *p)).collect();
let Some(ways) = ways else { return Ok(out) };
let (convex, concave) = (ways.iter().all(|w| *w), ways.iter().all(|w| !*w));
if !(convex || concave) {
return Ok(out);
}
let (Ok(a), Ok(b)) = (crate::measure(input), crate::measure(&out)) else { return Ok(out) };
let slack = a.volume.abs() * 1e-5 + 1e-6;
let grew = b.volume - a.volume;
if (convex && grew > slack) || (concave && grew < -slack) {
return Err(KernelError::TooLarge(format!(
"{} mm is too large for this {}: on {} edge it would {} the part by {} mm³ instead (it runs off a face next to the edge)",
show(size.value()),
size.what(),
if convex { "a convex" } else { "a concave" },
if convex { "grow" } else { "shrink" },
// A fraction of a mm³ is typical here: to the hundredth.
show((grew.abs() * 100.0).round() / 100.0)
)));
}
Ok(out)
}
/// After a blend of `edges` at `size` turned `input` into `out`: refused when the volume taken
@@ -0,0 +1,53 @@
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::*;
/// Robustness seed 57's part: a block with an arc side, a ball cut from the edge of its back
/// face, just touching its bottom face.
fn block_with_ball_cut() -> Body {
let p = Vec2::new;
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
let (c, r) = (p(24.0625, 11.25), 26.5625);
let start = (p(0.0, 22.5) - c).angle();
let sweep = ((p(0.0, 0.0) - c).angle() - start).rem_euclid(std::f64::consts::TAU);
let segs = vec![
line(p(0.0, 0.0), p(47.5, 0.0)),
line(p(47.5, 0.0), p(47.5, 22.5)),
line(p(47.5, 22.5), p(27.5, 22.5)),
line(p(27.5, 22.5), p(27.5, 12.5)),
line(p(27.5, 12.5), p(15.0, 12.5)),
line(p(15.0, 12.5), p(15.0, 22.5)),
line(p(15.0, 22.5), p(0.0, 22.5)),
Seg2::Arc { center: c, radius: r, start, sweep },
];
let block = crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 17.5).unwrap().remove(0);
let ball = crate::sphere(Vec3::new(2.5, 22.5, 5.0), 5.0).unwrap();
boolean(&block, &ball, BoolOp::Cut).unwrap().unwrap()
}
/// The ball's rim on the back face dips to 0.04 mm above the bottom: a 0.5 mm chamfer of it
/// would run off the back face, and what was built grew the part (by 0.65 mm³, more with other
/// edge orders). On a convex edge a chamfer takes material away, so it is refused.
#[test]
fn a_chamfer_that_would_grow_the_part_on_a_convex_edge_is_refused() {
let body = block_with_ball_cut();
let rim = Vec3::new(3.1526309873924174, 22.5, 0.04277569594852188);
let e = chamfer(&body, &[rim], 0.5).expect_err("refused");
assert!(matches!(e, KernelError::TooLarge(_)) && e.to_string().contains("convex"), "{e}");
}
/// Curved convex edges that a chamfer does take material from still build: a hole's rim in a
/// plate.
#[test]
fn a_chamfer_of_a_hole_rim_still_builds() {
let plate = crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 30.0, 10.0)).unwrap();
let hole = crate::cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let body = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
let v0 = crate::measure(&body).unwrap().volume;
let out = chamfer(&body, &[Vec3::new(25.0, 15.0, 10.0)], 1.0).unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
// A ring of a 1 mm chamfer round a 5 mm hole: (1/2)·2π·(5 + 1/3).
let taken = v0 - crate::measure(&out).unwrap().volume;
let want = std::f64::consts::PI * (5.0 + 1.0 / 3.0);
assert!((taken - want).abs() < 0.02 * want, "{taken} vs {want}");
}
+25 -1
View File
@@ -116,9 +116,30 @@ pub(crate) fn shapeops(h: &truck_shapeops::History, tool: &Solid, tool_edges: Op
Some((faces, edges))
}
/// Samples along each edge when checking that its curve evaluates.
const EVALUATION_SAMPLES: usize = 32;
/// Whether every edge of `solid` evaluates along its whole range. An intersection curve projects
/// its leader's point onto both surfaces, which fails to converge where a boolean made the edge
/// from an approximation that strayed (#1252). Such a result is no solid to keep: the mesher,
/// healing and measuring all sample the edge and stumble over the gap.
pub(crate) fn edges_evaluate(solid: &Solid) -> bool {
use truck_meshalgo::prelude::TrySubs;
use truck_modeling::BoundedCurve;
solid.boundaries().iter().flat_map(|shell| shell.edge_iter()).all(|edge| {
let curve = edge.curve();
let (t0, t1) = curve.range_tuple();
(0..=EVALUATION_SAMPLES).all(|k| {
let t = t0 + (t1 - t0) * k as f64 / EVALUATION_SAMPLES as f64;
curve.try_subs(t).is_some_and(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())
})
})
}
/// The body made of `solid` healed (keeping faces apart along `keep`), with the history whose
/// faces and edges (in `solid`'s orders) are given before healing, carried through it.
/// Operands with `counts` faces and edges.
/// Operands with `counts` faces and edges. A result with an edge that does not evaluate is
/// refused (see [`edges_evaluate`]).
pub(crate) fn heal_build(
solid: Solid,
size: f64,
@@ -128,6 +149,9 @@ pub(crate) fn heal_build(
counts: &[(usize, usize)],
) -> Result<Body> {
let (healed, map) = crate::heal::heal_traced(solid, size, keep);
if !edges_evaluate(&healed) {
return Err(crate::KernelError::Failed("an edge of the result does not evaluate".into()));
}
let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e));
let (faces, edges) = carried.unzip();
record::build(healed, counts, faces, edges.as_ref())
+653
View File
@@ -0,0 +1,653 @@
//! Continuity-driven bridging surfaces: one B-spline face that leaves one surface and arrives at
//! another with a requested continuity (G0 up to G3). This is the core the Blend, Boundary and
//! Match surfaces are built on.
//!
//! A bridge is a tensor product. Across it (v) every station's cross-section is a Bézier whose
//! leading control points reproduce the support surface's derivatives — the Hermite rows — so the
//! finished face carries the requested continuity by construction: one control row per condition
//! (position, tangent, curvature, change of curvature). Along it (u) those cross-sections are
//! interpolated by a cubic, so the face passes through every station exactly. Every condition is
//! stated against the support surface's own derivatives measured along the direction the bridge
//! leaves in, which makes G2 the curvature vector and G3 the way that curvature vector changes
//! along that direction (its normal part: what a surface-to-surface match can hold).
//!
//! What the support surface does along the leave direction is read off from samples on its own
//! parameter line (the line through the station that leaves in the leave direction at unit
//! speed) by finite differences: the second derivative of a surface curve with a unit tangent is
//! its curvature vector, and the third is the arclength derivative of that curvature vector.
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::{CurveN, HPoint, NurbsSurface};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::surfaces::surface_body;
use crate::{KernelError, Result, guard};
/// How much of a support surface a bridge repeats along its boundary.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Continuity {
/// The boundary curve alone: the surfaces meet, nothing else.
G0,
/// Position and tangent plane.
#[default]
G1,
/// Position, tangent plane and curvature vector.
G2,
/// Position, tangent plane, curvature vector and its change along the leave direction.
G3,
}
impl Continuity {
/// Conditions a side carries: 1 (G0) up to 4 (G3).
pub fn conditions(self) -> usize {
match self {
Continuity::G0 => 1,
Continuity::G1 => 2,
Continuity::G2 => 3,
Continuity::G3 => 4,
}
}
/// The word a command parameter uses.
pub fn as_str(self) -> &'static str {
match self {
Continuity::G0 => "g0",
Continuity::G1 => "g1",
Continuity::G2 => "g2",
Continuity::G3 => "g3",
}
}
/// Parse `g0`…`g3` (upper case accepted), `None` for anything else.
pub fn parse(s: &str) -> Option<Continuity> {
match s.to_ascii_lowercase().as_str() {
"g0" => Some(Continuity::G0),
"g1" => Some(Continuity::G1),
"g2" => Some(Continuity::G2),
"g3" => Some(Continuity::G3),
_ => None,
}
}
}
/// One station of a boundary: where the bridge starts, the direction it leaves in, and what the
/// support surface does there along that direction.
#[derive(Clone, Copy, Debug)]
pub struct Frame {
/// The point on the boundary.
pub p: Vec3,
/// Unit direction the bridge leaves *towards the other boundary*, in the tangent plane of
/// the support surface at `p`.
pub leave: Vec3,
/// The support surface's curvature vector along `leave` (zero on a plane).
pub bend: Vec3,
/// The arclength derivative of `bend` along `leave` (zero on a plane).
pub bend_change: Vec3,
}
/// The edges of `body` picked by points on them, chained: each runs on from the last, and the
/// chain may close. Used with [`frames`], whose chain must be a chain of one face's own edges.
pub fn chain(body: &Body, at: &[Vec3]) -> Result<Vec<mt::Edge>> {
body.require_brep("blend")?;
if at.is_empty() || at.len() > 10_000 || at.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid("pick 1 to 10000 points on the edges of a chain".into()));
}
let mut pool: Vec<mt::Edge> = Vec::new();
for p in at {
let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid("no edge near a picked point".to_string()))?;
if !pool.iter().any(|x| x.id() == e.id()) {
pool.push(e);
}
}
let first = pool.remove(0);
let mut chain = vec![first];
// The picks need not be given in order: run forward from the first edge, then backwards.
let mut end = match chain.first() {
Some(e) => e.back().clone(),
None => return Err(KernelError::Invalid("pick at least one edge".into())),
};
while let Some(k) = pool.iter().position(|e| e.front().id() == end.id() || e.back().id() == end.id()) {
let e = pool.remove(k);
let e = if e.front().id() == end.id() { e } else { e.inverse() };
end = e.back().clone();
chain.push(e);
}
let mut start = match chain.first() {
Some(e) => e.front().clone(),
None => return Err(KernelError::Invalid("pick at least one edge".into())),
};
while let Some(k) = pool.iter().position(|e| e.front().id() == start.id() || e.back().id() == start.id()) {
let e = pool.remove(k);
let e = if e.back().id() == start.id() { e } else { e.inverse() };
start = e.front().clone();
chain.insert(0, e);
}
if !pool.is_empty() {
return Err(KernelError::Invalid("the picked edges do not run on from one another".into()));
}
Ok(chain)
}
/// Whether a chain of edges closes back on itself.
pub fn chain_closes(chain: &[mt::Edge]) -> bool {
let (Some(first), Some(last)) = (chain.first(), chain.last()) else { return false };
first.front().id() == last.back().id()
}
/// The stations of a chain of edges, `count` of them evenly along its length, and the length
/// itself. A chain that closes is sampled with its last station back on its first.
fn chain_samples(edges: &[mt::Edge], count: usize) -> Result<(Vec<Vec3>, f64)> {
/// Samples per edge used for the length table.
const PER_EDGE: usize = 64;
let mut poly: Vec<Vec3> = Vec::with_capacity(edges.len() * (PER_EDGE + 1));
for e in edges {
let c = e.curve();
let (t0, t1) = c.range_tuple();
if !(t1 > t0) || ![t0, t1].into_iter().all(f64::is_finite) {
return Err(KernelError::Invalid("an edge has no parameter range".into()));
}
for k in 0..=PER_EDGE {
poly.push(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / PER_EDGE as f64)));
}
}
if chain_closes(edges)
&& let Some(first) = poly.first().copied()
{
poly.push(first);
}
let mut cum = Vec::with_capacity(poly.len());
cum.push(0.0);
for w in poly.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
let Some(last) = cum.last().copied() else { break };
cum.push(last + a.dist(*b));
}
let total = cum.last().copied().unwrap_or(0.0);
if !(total > 1e-9) || !total.is_finite() {
return Err(KernelError::Invalid("the edge chain has no length".into()));
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
let s = total * i as f64 / (count.max(2) - 1) as f64;
let hi = cum.partition_point(|x| *x < s).clamp(1, poly.len().saturating_sub(1));
let (Some(a), Some(b)) = (poly.get(hi - 1), poly.get(hi)) else { return Err(KernelError::Failed("bridge: sample".into())) };
let (Some(&lo), Some(&hi_s)) = (cum.get(hi - 1), cum.get(hi)) else { return Err(KernelError::Failed("bridge: sample".into())) };
let span = hi_s - lo;
let t = if span > 1e-12 { (s - lo) / span } else { 0.0 };
out.push(*a + (*b - *a) * t);
}
Ok((out, total))
}
/// The support surface's frames along `edges` (a chain of its own edges), for a bridge that
/// leaves in direction `leave` (projected into the tangent plane at each station).
pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) -> Result<Vec<Frame>> {
if !(2..=4097).contains(&count) {
return Err(KernelError::Invalid("a bridge needs 2 to 4097 stations".into()));
}
let leave =
leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?;
let (pts, length) = chain_samples(edges, count)?;
let surf = face.surface();
let (Some(range_u), Some(range_v)) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the support surface is unbounded".into()));
};
// A face's own edges may sit a fit tolerance off its surface (a cylinder's wall as a NURBS
// next to its exact ring), so the projection is only asked to be close, not exact.
let tol = (length * 1e-4).max(1e-6);
// Finite-difference step: a thousandth of the chain, so curvature over the chain is read
// locally but round-off stays far below the geometry.
let mut step = (length * 1e-3).clamp(1e-6, 0.5);
let mut out = Vec::with_capacity(pts.len());
// The station before this one, as the hint for the projection: it keeps the search on the
// same side of a periodic or split surface.
let mut hint: Option<(f64, f64)> = None;
for p in &pts {
let project = |hint: Option<(f64, f64)>| surf.search_nearest_parameter(p3(*p), hint, 100);
let close = |uv: (f64, f64)| from_p3(surf.subs(uv.0, uv.1)).dist(*p) <= tol;
// The station before this one is the hint where it lands close; a chain that wraps the
// surface (or a periodic one) falls back to a free search.
let (u, v) = match project(hint).filter(|uv| close(*uv)).or_else(|| project(None).filter(|uv| close(*uv))) {
Some(uv) => uv,
None => return Err(KernelError::Invalid("a picked edge is not on the support surface".into())),
};
hint = Some((u, v));
let at = from_p3(surf.subs(u, v));
if at.dist(*p) > tol {
return Err(KernelError::Invalid("a picked edge is not on the support surface".into()));
}
let normal = surf.normal(u, v);
let n = Vec3::new(normal.x, normal.y, normal.z)
.normalized()
.ok_or_else(|| KernelError::Failed("bridge: the support surface has no normal".into()))?;
let w = (leave - n * leave.dot(n))
.normalized()
.ok_or_else(|| KernelError::Invalid("the leave direction is normal to the support surface: it has no tangent plane there".to_string()))?;
// Parameter-space direction that leaves in `w` at unit 3D speed: S_u·du + S_v·dv = w.
let su = tangent(&surf, u, v, range_u, true);
let sv = tangent(&surf, u, v, range_v, false);
let (a, b, c) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = a * c - b * b;
let scale = a.max(c).max(1e-30);
if !(det.abs() > 1e-12 * scale * scale) {
return Err(KernelError::Failed("bridge: the support surface is singular at the boundary".into()));
}
let du = (c * w.dot(su) - b * w.dot(sv)) / det;
let dv = (a * w.dot(sv) - b * w.dot(su)) / det;
// The samples of the parameter line. A station sits on the support surface's own
// boundary, so the line usually leaves the surface: the shape is then read from the
// samples of the one side that is still on it, which is the surface's own extension past
// that boundary (exact for a plane and a cylinder, what a blend continues from).
let slack = 1e-9;
let on = |t: f64, h: f64| {
let (x, y) = (u + t * h * du, v + t * h * dv);
x >= range_u.0 - slack && x <= range_u.1 + slack && y >= range_v.0 - slack && y <= range_v.1 + slack
};
let central = [-2.0, -1.0, 0.0, 1.0, 2.0];
let forward = [0.0, 1.0, 2.0, 3.0, 4.0];
let backward = [0.0, -1.0, -2.0, -3.0, -4.0];
let pick = |h: f64| -> Option<[f64; 5]> {
if central.iter().all(|t| on(*t, h)) {
Some(central)
} else if forward.iter().all(|t| on(*t, h)) {
Some(forward)
} else if backward.iter().all(|t| on(*t, h)) {
Some(backward)
} else {
None
}
};
let mut h = step;
let mut chosen = pick(h);
for _ in 0..8 {
if chosen.is_some() {
break;
}
h *= 0.25;
chosen = pick(h);
}
let Some(offsets) = chosen else {
return Err(KernelError::Invalid("the support surface is too small around a picked edge to read its shape".into()));
};
let q: Vec<Vec3> = offsets.iter().map(|t| from_p3(surf.subs(u + t * h * du, v + t * h * dv))).collect();
let (Some(&f0), Some(&f1), Some(&f2), Some(&f3), Some(&f4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else {
return Err(KernelError::Failed("bridge: samples".into()));
};
// Central samples read the line both ways; one-sided samples read it one way only from the
// station, backwards down it in the second case (the odd derivatives then change sign).
let (d1, d2, d3) = if offsets[0] == 0.0 && offsets[1] < 0.0 {
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (-1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (-1.0 / (2.0 * h * h * h));
(d1, d2, d3)
} else if offsets[0] == 0.0 {
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (1.0 / (2.0 * h * h * h));
(d1, d2, d3)
} else {
let d1 = (f0 * -1.0 + f1 * 8.0 + f3 * -8.0 + f4) * (1.0 / (12.0 * h));
let d2 = (f0 + f1 * -16.0 + f2 * 30.0 + f3 * -16.0 + f4) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -1.0 + f1 * 2.0 + f3 * -2.0 + f4) * (1.0 / (2.0 * h * h * h));
(d1, d2, d3)
};
let (t, bend, bend_change) = shape(d1, d2, d3)?;
// Read the same curve the other way when the surface's own parameter direction runs
// against the leave direction: the curvature vector is the same either way, the change of
// curvature turns round with the direction of travel.
let bend_change = if t.dot(w) < 0.0 { -bend_change } else { bend_change };
out.push(Frame { p: *p, leave: w, bend, bend_change });
step = h;
}
Ok(out)
}
/// A first derivative of the surface in one parameter, by central differences (one-sided where
/// the parameter is at the edge of the domain).
fn tangent(surf: &mt::Surface, u: f64, v: f64, range: (f64, f64), along_u: bool) -> Vec3 {
let span = (range.1 - range.0).max(1e-12);
let h = (span * 1e-5).max(1e-12);
let at = |x: f64| if along_u { from_p3(surf.subs(x, v)) } else { from_p3(surf.subs(u, x)) };
let x = if along_u { u } else { v };
if x - h >= range.0 && x + h <= range.1 {
(at(x + h) - at(x - h)) * (0.5 / h)
} else if x + h <= range.1 {
(at(x + h) - at(x)) * (1.0 / h)
} else {
(at(x.min(range.1)) - at((x - h).max(range.0))) * (1.0 / h)
}
}
/// The geometric shape of a surface curve at a point from its first three derivatives along the
/// curve: the unit tangent, the curvature vector and the arclength derivative of that curvature
/// vector. All three are what a surface-to-surface continuity condition is stated with.
fn shape(d1: Vec3, d2: Vec3, d3: Vec3) -> Result<(Vec3, Vec3, Vec3)> {
let sigma = d1.len();
if !(sigma > 1e-12) || !sigma.is_finite() {
return Err(KernelError::Failed("bridge: the support surface's parameter line does not move".into()));
}
let t = d1 * (1.0 / sigma);
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
let nn = d2 - t * d2.dot(t);
let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp);
let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2));
let bend_change = kappa * (1.0 / (sigma * sigma * sigma * sigma * sigma));
if ![t, nn, bend_change].into_iter().all(Vec3::is_finite) {
return Err(KernelError::Failed("bridge: the support surface's shape is not finite at the boundary".into()));
}
Ok((t, nn * (1.0 / (sigma * sigma)), bend_change))
}
/// The third derivative a curve needs at a boundary so that the arclength derivative of its
/// curvature vector is `target`, from its first two derivatives there. Only the normal-plane
/// part of the third derivative can hold that condition; the rest is a reparameterisation of the
/// cross-section and is left at zero.
fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result<Vec3> {
let sigma = d1.len();
if !(sigma > 1e-12) || !sigma.is_finite() {
return Err(KernelError::Failed("bridge: no room for the curvature condition".into()));
}
let t = d1 * (1.0 / sigma);
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
let nn = d2 - t * d2.dot(t);
let r = tp * (-d2.dot(t)) - t * d2.dot(tp);
let x = (target * (sigma.powi(5)) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2))) * (1.0 / (sigma * sigma));
if !x.is_finite() {
return Err(KernelError::Failed("bridge: the curvature condition is not finite".into()));
}
Ok(x - t * x.dot(t))
}
/// One station's cross-section: a Bézier whose leading control points hold the conditions of
/// each side. `speed` is how fast the cross-section leaves each boundary (any positive value; it
/// shapes the middle without touching the conditions).
fn section(a: &Frame, b: &Frame, orders: [Continuity; 2], n: usize, speed: [f64; 2]) -> Result<Vec<Vec3>> {
let (Some(speed_a), Some(speed_b)) = (speed.first().copied(), speed.get(1).copied()) else {
return Err(KernelError::Failed("bridge: speed".into()));
};
let (ka, kb) = (orders[0].conditions(), orders[1].conditions());
if ka + kb - 1 != n {
return Err(KernelError::Failed("bridge: degree".into()));
}
let mut c = vec![Vec3::ZERO; n + 1];
if let Some(slot) = c.get_mut(0) {
*slot = a.p;
}
if let Some(slot) = c.get_mut(n) {
*slot = b.p;
}
let k = n as f64;
// The first end: one control point per condition.
if ka >= 2 {
let p1 = a.p + a.leave * (speed_a / k);
if let Some(slot) = c.get_mut(1) {
*slot = p1;
}
if ka >= 3 {
if n < 2 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let p2 = a.bend * (speed_a * speed_a / (k * (k - 1.0))) + p1 * 2.0 - a.p;
if let Some(slot) = c.get_mut(2) {
*slot = p2;
}
if ka >= 4 {
if n < 3 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let d1 = (p1 - a.p) * k;
let d2 = (a.p - p1 * 2.0 + p2) * (k * (k - 1.0));
let a3 = third_derivative(d1, d2, a.bend_change)?;
if let Some(slot) = c.get_mut(3) {
*slot = a3 * (1.0 / (k * (k - 1.0) * (k - 2.0))) + p2 * 3.0 - p1 * 3.0 + a.p;
}
}
}
}
// The second end, from its own conditions read backwards.
if kb >= 2 {
let q1 = b.p + b.leave * (speed_b / k);
if let Some(slot) = c.get_mut(n - 1) {
*slot = q1;
}
if kb >= 3 {
if n < 2 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let q2 = b.bend * (speed_b * speed_b / (k * (k - 1.0))) + q1 * 2.0 - b.p;
if let Some(slot) = c.get_mut(n - 2) {
*slot = q2;
}
if kb >= 4 {
if n < 3 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let d1 = (q1 - b.p) * k;
let d2 = (b.p - q1 * 2.0 + q2) * (k * (k - 1.0));
let q3 = third_derivative(d1, d2, b.bend_change)?;
if let Some(slot) = c.get_mut(n - 3) {
*slot = q3 * (1.0 / (k * (k - 1.0) * (k - 2.0))) + q2 * 3.0 - q1 * 3.0 + b.p;
}
}
}
}
if c.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Failed("bridge: the cross-section is not finite".into()));
}
Ok(c)
}
/// The bridge surface between two boundaries of `stations` frames each: one B-spline face
/// leaving `a` and arriving at `b` with the requested continuity. `crown` scales how fast the
/// cross-sections leave each boundary (0: the plain bridge, up to 3: fuller, down to -0.9:
/// flatter), which shapes the middle without touching the conditions at either end.
pub fn bridge(a: &[Frame], b: &[Frame], orders: [Continuity; 2], crown: [f64; 2]) -> Result<Body> {
let (Some(&crown_a), Some(&crown_b)) = (crown.first(), crown.get(1)) else { return Err(KernelError::Failed("bridge: crown".into())) };
if a.len() != b.len() || a.len() < 2 || a.len() > 4097 {
return Err(KernelError::Invalid("a bridge needs 2 to 4097 stations, the same on both sides".into()));
}
for c in [crown_a, crown_b] {
if !c.is_finite() || !(-0.9..=3.0).contains(&c) {
return Err(KernelError::Invalid("a bridge's crown must be between -0.9 and 3".into()));
}
}
let stations = a.len();
let n = orders[0].conditions() + orders[1].conditions() - 1;
if n == 0 || n > solidcraft_geom::bspline::MAX_SPLINE_DEGREE {
return Err(KernelError::Failed("bridge: degree".into()));
}
let mut layers: Vec<Vec<Vec3>> = vec![Vec::with_capacity(stations); n + 1];
for (fa, fb) in a.iter().zip(b) {
if !fa.p.is_finite() || !fb.p.is_finite() || !fa.leave.is_finite() || !fb.leave.is_finite() {
return Err(KernelError::Invalid("a bridge station must be finite".into()));
}
let chord = fa.p.dist(fb.p);
if !(chord > 1e-9) {
return Err(KernelError::Invalid("the two boundaries meet: there is no room for a bridge".into()));
}
let speed = [chord / 3.0 * (1.0 + crown_a), chord / 3.0 * (1.0 + crown_b)];
let controls = section(fa, fb, orders, n, speed)?;
for (layer, c) in layers.iter_mut().zip(controls) {
layer.push(c);
}
}
// Along the bridge: a cubic through the stations' cross-sections, so the face passes through
// each of them exactly.
let params: Vec<f64> = (0..stations).map(|i| i as f64 / (stations - 1) as f64).collect();
let degree = 3.min(stations - 1);
let mut knots_u: Option<Vec<f64>> = None;
let mut ctrl = vec![Vec3::ZERO; (n + 1) * stations];
for (j, layer) in layers.iter().enumerate() {
let points: Vec<[f64; 3]> = layer.iter().map(|p| [p.x, p.y, p.z]).collect();
let curve = CurveN::<3>::interpolate(&params, &points, degree).map_err(|e| KernelError::Failed(format!("bridge: {e}")))?;
if knots_u.is_none() {
knots_u = Some(curve.knots.clone());
}
for (i, c) in curve.ctrl.iter().enumerate() {
if let Some(slot) = ctrl.get_mut(j * stations + i) {
*slot = Vec3::new(c[0], c[1], c[2]);
}
}
}
let knots_u = knots_u.ok_or_else(|| KernelError::Failed("bridge: knots".into()))?;
let mut knots_v = vec![0.0; n + 1];
knots_v.extend(std::iter::repeat_n(1.0, n + 1));
let points: Vec<HPoint> = ctrl.iter().map(|p| HPoint::weighted(*p, 1.0)).collect();
let nurbs = NurbsSurface::from_homogeneous([degree, n], [knots_u, knots_v], points).map_err(|e| KernelError::Failed(format!("bridge: {e}")))?;
guard("bridge surface", || crate::spline_conv::tensor_face(&nurbs).map(|f| surface_body(vec![f])))
}
/// The surface of a bridge face as a grid of points, for callers that measure the result.
pub fn sample_grid(body: &Body, along: usize, across: usize) -> Result<Vec<Vec<Vec3>>> {
if !(2..=1025).contains(&along) || !(2..=1025).contains(&across) {
return Err(KernelError::Invalid("a grid needs 2 to 1025 samples each way".into()));
}
let face = body.solid.face_iter().next().ok_or_else(|| KernelError::Failed("bridge: no face".into()))?;
let surf = face.surface();
let (Some((u0, u1)), Some((v0, v1))) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the surface is unbounded".into()));
};
let mut out = Vec::with_capacity(along);
for i in 0..along {
let u = u0 + (u1 - u0) * i as f64 / (along - 1) as f64;
let mut row = Vec::with_capacity(across);
for j in 0..across {
let v = v0 + (v1 - v0) * j as f64 / (across - 1) as f64;
row.push(from_p3(surf.subs(u, v)));
}
out.push(row);
}
Ok(out)
}
/// The most any point of a bridge's boundary row is from the chain it was built on — the row at
/// `v0` for the first boundary, at `v1` for the second: what a stitch would have to close up.
fn boundary_gap(made: &Body, chain: &[mt::Edge], at_end: bool) -> Result<f64> {
let face = made.solid.face_iter().next().ok_or_else(|| KernelError::Failed("bridge: no face".into()))?;
let surf = face.surface();
let (Some((u0, u1)), Some((v0, v1))) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the surface is unbounded".into()));
};
let v = if at_end { v1 } else { v0 };
let (poly, _) = chain_samples(chain, 257)?;
let mut worst: f64 = 0.0;
for i in 0..=256 {
let u = u0 + (u1 - u0) * i as f64 / 256.0;
let p = from_p3(surf.subs(u, v));
let mut best = f64::INFINITY;
for w in poly.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
let t = if b.dist(*a) > 1e-12 { ((p - *a).dot(*b - *a) / b.dist(*a).powi(2)).clamp(0.0, 1.0) } else { 0.0 };
best = best.min(p.dist(*a + (*b - *a) * t));
}
worst = worst.max(best);
}
Ok(worst)
}
/// The midpoint of a chain of edges.
fn chain_mid(edges: &[mt::Edge]) -> Result<Vec3> {
let (pts, _) = chain_samples(edges, 3)?;
pts.get(1).copied().ok_or_else(|| KernelError::Failed("bridge: no chain".into()))
}
/// Which face of `body` the blend should continue from a chain, and the direction it leaves in:
/// the face whose tangent plane holds as much of `leave` as possible.
fn support_face(body: &Body, chain: &[mt::Edge], leave: Vec3) -> Result<(mt::Face, Vec3)> {
let mut best: Option<(f64, mt::Face)> = None;
for face in body.solid.face_iter() {
if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) {
continue;
}
let mid = chain_mid(chain)?;
let surf = face.surface();
let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) else { continue };
if from_p3(surf.subs(u, v)).dist(mid) > body.size() * 1e-6 + 1e-9 {
continue;
}
let normal = surf.normal(u, v);
let n = Vec3::new(normal.x, normal.y, normal.z).normalized().unwrap_or(Vec3::ZERO);
let in_plane = leave - n * leave.dot(n);
let score = in_plane.len();
if best.as_ref().is_none_or(|(b, _)| score > *b) {
best = Some((score, face.clone()));
}
}
let (score, face) = best.ok_or_else(|| KernelError::Invalid("no face of the body holds the picked edges".into()))?;
if score < 0.2 {
return Err(KernelError::Invalid(
"the direction towards the other boundary lies in no face at the picked edges: give `align` points to leave by".into(),
));
}
Ok((face, leave))
}
/// A blend surface between two picked edge chains of two bodies: the surfaces the blend leaves
/// and arrives at are the faces the chains bound (the best-facing one at each side), and each
/// side repeats that surface up to its requested continuity. `align` overrides the direction
/// each side leaves in (points in world space); `crown` how full each side is; `stitch` sews the
/// blend to its two bodies where their boundaries meet. The reply's `gap`, if any, is how far the
/// blend's own boundary lay from the chains it was built on.
pub fn blend_between(
a: &Body,
picks_a: &[Vec3],
b: &Body,
picks_b: &[Vec3],
orders: [Continuity; 2],
crown: [f64; 2],
align: Option<[Vec3; 2]>,
samples: usize,
stitch: bool,
) -> Result<(Body, f64)> {
a.require_brep("blend")?;
b.require_brep("blend")?;
if !(5..=401).contains(&samples) {
return Err(KernelError::Invalid("a blend needs 5 to 401 stations".into()));
}
let chain_a = chain(a, picks_a)?;
let chain_b = chain(b, picks_b)?;
let (mid_a, mid_b) = (chain_mid(&chain_a)?, chain_mid(&chain_b)?);
let (leave_a, leave_b) = match align {
Some([p, q]) => (
(p - mid_a).normalized().ok_or_else(|| KernelError::Invalid("`align` must not sit on the boundary".into()))?,
(q - mid_b).normalized().ok_or_else(|| KernelError::Invalid("`align` must not sit on the boundary".into()))?,
),
None => (
(mid_b - mid_a).normalized().ok_or_else(|| KernelError::Invalid("the two boundaries coincide: there is nothing to blend".into()))?,
(mid_a - mid_b).normalized().ok_or_else(|| KernelError::Invalid("the two boundaries coincide: there is nothing to blend".into()))?,
),
};
let (face_a, leave_a) = support_face(a, &chain_a, leave_a)?;
let (face_b, leave_b) = support_face(b, &chain_b, leave_b)?;
let frames_a = frames(&face_a, &chain_a, leave_a, samples)?;
let mut frames_b = frames(&face_b, &chain_b, leave_b, samples)?;
// The two chains are sampled from their own starts: turn one round when that pairs the
// stations better (the blend is built station to station).
let direct: f64 = frames_a.iter().zip(&frames_b).map(|(x, y)| x.p.dist(y.p)).sum();
let reversed: f64 = frames_a.iter().zip(frames_b.iter().rev()).map(|(x, y)| x.p.dist(y.p)).sum();
if reversed < direct {
frames_b.reverse();
}
let made = bridge(&frames_a, &frames_b, orders, crown)?;
let gap = boundary_gap(&made, &chain_a, false)?.max(boundary_gap(&made, &chain_b, true)?);
let faces: Vec<mt::Face> = made.solid.face_iter().cloned().collect();
let mut rec = crate::record::Rebuild::default();
for f in &faces {
rec.made(f, Vec::new());
}
let blend = crate::record::rebuilt_body(surface_body(faces), &[&a.solid, &b.solid], &rec);
if !stitch {
return Ok((blend, gap));
}
// Sewing up a boundary the blend only approximates: the stitch is given what the boundary
// actually strays, so that a real gap between the bodies is still refused.
let out = crate::surfaces::stitch(&[a, b, &blend], gap * 1.05 + 1e-9)?;
Ok((out, gap))
}
#[cfg(test)]
#[path = "bridge_tests.rs"]
mod tests;
+244
View File
@@ -0,0 +1,244 @@
//! Tests of the bridge core: the support surface's shape is read off a real body, and a bridge's
//! continuity is measured back off the surface it built.
use super::*;
use crate::{box_solid, cylinder};
/// The shape of a bridge's cross-section at the parameter `u`, measured off the finished face (not
/// from the construction): position, unit tangent, curvature vector and the arclength derivative
/// of that curvature vector at the start of the cross-section. The samples run inwards from the
/// boundary, so nothing is asked of the surface outside its own domain.
fn section_at(body: &Body, u: f64) -> (Vec3, Vec3, Vec3, Vec3) {
let face = body.solid.face_iter().next().unwrap();
let surf = face.surface();
let (Some(_), Some((v0, _))) = surf.try_range_tuple() else { panic!("unbounded") };
let h = 1e-3;
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(u, v0 + k as f64 * h))).collect();
let (f0, f1, f2, f3, f4) = (q[0], q[1], q[2], q[3], q[4]);
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (1.0 / (2.0 * h * h * h));
let (t, bend, bend_change) = shape(d1, d2, d3).unwrap();
(f0, t, bend, bend_change)
}
/// The same at the end of the cross-section (`v = v1`), with the samples running back inwards and
/// the odd derivatives turned round.
fn section_end_at(body: &Body, u: f64) -> (Vec3, Vec3, Vec3, Vec3) {
let face = body.solid.face_iter().next().unwrap();
let surf = face.surface();
let (Some(_), Some((_, v1))) = surf.try_range_tuple() else { panic!("unbounded") };
let h = 1e-3;
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(u, v1 - k as f64 * h))).collect();
let (f0, f1, f2, f3, f4) = (q[0], q[1], q[2], q[3], q[4]);
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (-1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (-1.0 / (2.0 * h * h * h));
let (t, bend, bend_change) = shape(d1, d2, d3).unwrap();
(f0, t, bend, bend_change)
}
/// The angle between two vectors, in degrees.
fn angle_deg(a: Vec3, b: Vec3) -> f64 {
(a.normalized().unwrap().dot(b.normalized().unwrap())).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Two planes 10 mm apart: the lower one leaves along +y, the upper one has no curvature, and the
/// direction between them is +z — an S-shaped bridge.
fn two_planes(count: usize) -> (Vec<Frame>, Vec<Frame>) {
let mut a = Vec::new();
let mut b = Vec::new();
for i in 0..count {
let y = 10.0 * i as f64 / (count - 1) as f64;
a.push(Frame { p: Vec3::new(0.0, y, 0.0), leave: Vec3::new(0.0, 1.0, 0.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO });
b.push(Frame { p: Vec3::new(0.0, y, 10.0), leave: Vec3::new(0.0, -1.0, 0.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO });
}
(a, b)
}
/// Two parallel circular cylinders of radius `r`, axes along x at z = r, 20 mm apart in y: their
/// lowest lines are the two boundaries. Each leaves towards the other along ∓y, bends up towards
/// its own axis (curvature 1/r), and its curvature does not change as it goes round — what does
/// change is purely tangential.
fn valley_between_cylinders(count: usize, r: f64) -> (Vec<Frame>, Vec<Frame>) {
let mut a = Vec::new();
let mut b = Vec::new();
for i in 0..count {
let y = 10.0 * i as f64 / (count - 1) as f64;
a.push(Frame {
p: Vec3::new(0.0, y, 0.0),
leave: Vec3::new(0.0, 1.0, 0.0),
bend: Vec3::new(0.0, 0.0, 1.0 / r),
bend_change: Vec3::new(0.0, -1.0 / (r * r), 0.0),
});
b.push(Frame {
p: Vec3::new(0.0, y + 20.0, 0.0),
leave: Vec3::new(0.0, -1.0, 0.0),
bend: Vec3::new(0.0, 0.0, 1.0 / r),
bend_change: Vec3::new(0.0, 1.0 / (r * r), 0.0),
});
}
(a, b)
}
/// The edges of `face` chained into one loop.
fn loop_of(face: &mt::Face) -> Vec<mt::Edge> {
let mut pool: Vec<mt::Edge> = face.edge_iter().collect();
let mut out = vec![pool.remove(0)];
let mut end = out[0].back().clone();
while !pool.is_empty() {
let k = pool.iter().position(|e| e.front().id() == end.id() || e.back().id() == end.id()).unwrap();
let e = pool.remove(k);
let e = if e.front().id() == end.id() { e } else { e.inverse() };
end = e.back().clone();
out.push(e);
}
out
}
/// The face of `body` that contains `at`.
fn face_at(body: &Body, at: Vec3) -> mt::Face {
body.solid
.face_iter()
.find(|f| f.surface().search_nearest_parameter(p3(at), None, 100).is_some_and(|(u, v)| from_p3(f.surface().subs(u, v)).dist(at) < 1e-6))
.unwrap()
.clone()
}
#[test]
fn frames_read_a_plane_and_a_cylinder_wall() {
// A 40 x 30 x 20 box: the top face's rim leaves along +x, a plane with no curvature.
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let top = face_at(&b, Vec3::new(20.0, 15.0, 20.0));
let rim = loop_of(&top);
assert!(chain_closes(&rim));
let f = frames(&top, &rim, Vec3::new(1.0, 0.0, 0.0), 9).unwrap();
assert_eq!(f.len(), 9);
for s in &f {
assert!((s.p.z - 20.0).abs() < 1e-9, "{s:?}");
assert!(angle_deg(s.leave, Vec3::new(1.0, 0.0, 0.0)) < 1e-6, "{s:?}");
assert!(s.bend.len() < 1e-6 && s.bend_change.len() < 1e-6, "{s:?}");
}
// The chain's picks work the same way: the rim's own points pick the rim's own edges.
let picked = chain(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0)]).unwrap();
assert_eq!(picked.len(), 2);
// A cylinder's wall: along the axis it is straight, and a leave direction that is normal to it
// has no tangent plane to work in.
let c = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), 10.0, 30.0).unwrap();
let wall = face_at(&c, Vec3::new(10.0, 0.0, 15.0));
let ring = loop_of(&wall);
let f = frames(&wall, &ring, Vec3::new(0.0, 0.0, 1.0), 5).unwrap();
for s in &f {
assert!(s.bend.len() < 1e-6, "the axis direction is straight: {s:?}");
}
// A leave direction that is normal to the support surface has no tangent plane to work in.
assert!(frames(&top, &rim, Vec3::new(0.0, 0.0, 1.0), 9).is_err(), "a face's own normal is no direction to leave in");
// On the cylinder's wall only a direction with a tangential part is usable.
assert!(frames(&wall, &ring, Vec3::new(1.0, 0.0, 0.0), 5).is_ok());
}
#[test]
fn a_g1_bridge_meets_both_boundaries_tangentially() {
let (a, b) = two_planes(9);
let body = bridge(&a, &b, [Continuity::G1, Continuity::G1], [0.0, 0.0]).unwrap();
for u in [0.0, 0.5, 1.0] {
let (_, t, _, _) = section_at(&body, u);
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "the bridge leaves along the tangent: {u}");
}
// The boundary itself is on each support: the first and last rows of the grid.
let grid = sample_grid(&body, 9, 7).unwrap();
for (row, frame) in grid.iter().zip(&a) {
assert!(row[0].dist(frame.p) < 1e-9, "{:?} vs {:?}", row[0], frame.p);
}
for (row, frame) in grid.iter().zip(&b) {
assert!(row[6].dist(frame.p) < 1e-9, "{:?} vs {:?}", row[6], frame.p);
}
}
#[test]
fn g2_and_g3_bridges_repeat_the_support_shape() {
let (a, b) = valley_between_cylinders(9, 10.0);
// A G2 bridge to two cylinders: each end holds the cylinder's own curvature (1/10 towards its
// axis) and leaves along its tangent.
let g2 = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
for (i, u) in [(0usize, 0.0), (4, 0.5), (8, 1.0)] {
let (p, t, bend, _) = section_at(&g2, u);
assert!(p.dist(a[i].p) < 1e-9, "the boundary is on the cylinder: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "leaves along the tangent: {u}");
assert!((bend.dist(a[i].bend) / a[i].bend.len()) < 1e-3, "curvature {bend:?} vs {:?}", a[i].bend);
}
// The far end, read back to front.
let (p, t, bend, _) = section_end_at(&g2, 1.0);
assert!(p.dist(b[8].p) < 1e-9, "the far boundary is on its cylinder: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "arrives along the far tangent");
assert!((bend.dist(b[8].bend) / b[8].bend.len()) < 1e-3, "curvature {bend:?} vs {:?}", b[8].bend);
// A G2 bridge leaves the change of curvature free; asking for G3 pins its normal part to the
// cylinder's own (zero: a cylinder's curvature does not change as you go round it).
let (_, _, _, free) = section_at(&g2, 0.0);
let g3 = bridge(&a, &b, [Continuity::G3, Continuity::G3], [0.0, 0.0]).unwrap();
let (_, _, bend3, held) = section_at(&g3, 0.0);
assert!((bend3.dist(a[0].bend) / a[0].bend.len()) < 1e-3, "G3 still holds the curvature: {bend3:?}");
let along_normal = |v: Vec3| v.dot(Vec3::new(0.0, 0.0, 1.0));
assert!(along_normal(held).abs() < 1e-3, "a G3 bridge holds the change of curvature: {held:?}");
assert!(along_normal(free).abs() > 1e-3, "a G2 bridge is free to change it: {free:?}");
}
#[test]
fn crown_shapes_the_middle_without_touching_the_ends() {
let (a, b) = valley_between_cylinders(11, 10.0);
let base = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
let (_, t0, bend0, _) = section_at(&base, 0.0);
let mut heights = Vec::new();
for crown in [-0.5, 0.0, 1.0, 2.0] {
let body = bridge(&a, &b, [Continuity::G2, Continuity::G2], [crown, crown]).unwrap();
let grid = sample_grid(&body, 11, 9).unwrap();
heights.push(grid[5][4].z);
// The ends hold in every case: the same boundary points, the same tangent, and the same
// seam curvature as the uncrowned bridge.
let (_, t, bend, _) = section_at(&body, 0.0);
assert!(angle_deg(t, t0) < 0.01, "crown {crown} left the tangent alone");
assert!(bend.dist(bend0) < 1e-9, "crown {crown} left the curvature alone: {bend:?} vs {bend0:?}");
assert!(grid[5][0].dist(a[5].p) < 1e-9 && grid[5][8].dist(b[5].p) < 1e-9, "crown {crown} left the boundary alone");
}
let steps: Vec<f64> = heights.windows(2).map(|w| w[1] - w[0]).collect();
assert!(steps.iter().all(|d| d.abs() > 1e-6 && d.signum() == steps[0].signum()), "a fuller crown moves the middle one way: {heights:?}");
}
#[test]
fn the_bridge_refuses_what_it_cannot_hold() {
let (a, b) = two_planes(9);
// Different station counts.
assert!(bridge(&a[..6], &b, [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// A crown outside its range, and a crown that is not a number.
for crown in [f64::NAN, 5.0, -1.0, f64::INFINITY] {
assert!(bridge(&a, &b, [Continuity::G1, Continuity::G1], [crown, crown]).is_err(), "{crown}");
}
// Boundaries that meet: no room for a bridge.
let same: Vec<Frame> = a.iter().map(|f| Frame { p: f.p, ..*f }).collect();
assert!(bridge(&a, &same, [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// A station that is not a number.
let mut bad = a.clone();
bad[3].leave = Vec3::new(f64::NAN, 0.0, 0.0);
assert!(bridge(&bad, &b, [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// Fewer than two stations.
assert!(bridge(&a[..1], &b[..1], [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// Every order from G0 to G3 builds.
for (x, y) in [(Continuity::G0, Continuity::G0), (Continuity::G1, Continuity::G0), (Continuity::G3, Continuity::G1)] {
let body = bridge(&a, &b, [x, y], [0.0, 0.0]).unwrap();
let (_, t, _, _) = section_at(&body, 0.0);
if x != Continuity::G0 {
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "{x:?}");
}
}
}
#[test]
fn continuity_parses_its_words() {
assert_eq!(Continuity::parse("g0"), Some(Continuity::G0));
assert_eq!(Continuity::parse("G3"), Some(Continuity::G3));
assert_eq!(Continuity::parse("g9"), None);
assert_eq!(Continuity::parse(""), None);
assert_eq!([Continuity::G0, Continuity::G1, Continuity::G2, Continuity::G3].map(Continuity::conditions), [1, 2, 3, 4]);
}
+119 -5
View File
@@ -1059,6 +1059,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut seams: Vec<Vec<(usize, mt::Edge)>> = Vec::with_capacity(nv);
// At an open chain's ends: the end face (planar, square to the edge there).
let mut end_faces: Vec<(usize, usize)> = Vec::new(); // (vertex index, face index)
// A flat strip on planar sides can end across two planar faces (an earlier chamfer).
// Their shared seam is shortened to the crossing of the new chamfer plane.
let mut split_ends: Vec<(usize, usize, usize, mt::Edge)> = Vec::new();
// Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent,
// the side (1 or 2) the end face is tangent to when it lies along the edge there).
let mut slanted: Vec<(usize, usize, Vec3, Vec3, Option<u8>)> = Vec::new();
@@ -1075,7 +1078,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
let on_side = side1.contains(&fi) || side2.contains(&fi);
if !on_side {
if !at_end || end_faces.iter().any(|(kk, _)| *kk == k) {
if !at_end || end_faces.iter().filter(|(kk, _)| *kk == k).count() >= 2 {
return Err(unsupported("a vertex of the edge chain where other faces meet"));
}
end_faces.push((k, fi));
@@ -1092,6 +1095,40 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if at_end && !end_faces.iter().any(|(kk, _)| *kk == k) {
return Err(unsupported("an open chain of curved edges must end at a face"));
}
let ends: Vec<usize> = end_faces.iter().filter(|(kk, _)| *kk == k).map(|(_, fi)| *fi).collect();
if let [ga, gb] = ends[..] {
let terminal = chain.get(if k == 0 { 0 } else { n - 1 }).ok_or_else(|| unsupported("chain end"))?;
let planar = [ga, gb, k1, k2].iter().all(|i| faces.get(*i).and_then(crate::blend::plane_normal).is_some());
if round || !planar || !straight(terminal, tol * 10.0) {
return Err(unsupported("a vertex of the edge chain where other faces meet"));
}
let shares = |g: usize, side: usize| {
faces
.get(g)
.zip(faces.get(side))
.is_some_and(|(g, s)| g.edge_iter().any(|e| (e.front() == v || e.back() == v) && s.edge_iter().any(|x| x.id() == e.id())))
};
let (ga, gb) = if shares(ga, k1) && shares(gb, k2) {
(ga, gb)
} else if shares(gb, k1) && shares(ga, k2) {
(gb, ga)
} else {
return Err(unsupported("the end faces do not meet both sides of the chain"));
};
let seam = faces
.get(ga)
.and_then(|a| {
faces.get(gb).and_then(|b| a.edge_iter().find(|e| (e.front() == v || e.back() == v) && b.edge_iter().any(|x| x.id() == e.id())))
})
.ok_or_else(|| unsupported("the end faces have no shared seam"))?;
if !straight(&seam, tol * 10.0) {
return Err(unsupported("a curved seam between the end faces"));
}
// The first face is the one reached by side 1's contact.
end_faces.retain(|(kk, _)| *kk != k);
end_faces.extend([(k, ga), (k, gb)]);
split_ends.push((k, ga, gb, seam.absolute_clone()));
}
// At most one seam per side.
if found.iter().filter(|x| x.0 == 1).count() > 1 || found.iter().filter(|x| x.0 == 2).count() > 1 {
return Err(unsupported("a vertex of the edge chain where several seams meet"));
@@ -1144,7 +1181,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
_ => false,
};
if !square {
if !square || split_ends.iter().any(|e| e.0 == k) {
slanted.push((k, fi, p, t, None));
}
}
@@ -1193,7 +1230,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let (_, ng, _) = project(g, *p, None).ok_or_else(|| unsupported("the blend's end on its end face"))?;
let cos = ng.dot(*t).abs();
let cos = split_ends
.iter()
.find(|e| e.0 == *k)
.and_then(|(_, _, gb, _)| faces.get(*gb))
.and_then(crate::blend::plane_normal)
.map_or(ng.dot(*t).abs(), |nb| ng.dot(*t).abs().min(nb.dot(*t).abs()));
// An end face along the edge and tangent to one side there (a wall that runs out into
// the face): the blend meets it along a curve that touches that side's contact at the
// vertex. Any other end face that close to the edge is refused.
@@ -1417,6 +1459,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// Where each extended blend meets its slanted end face: (vertex, edge, cut parameters of
// the two contacts, the end curve from contact 1 to contact 2).
let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new();
let mut split_caps: Vec<(usize, mt::Edge, Vec3)> = Vec::new();
for (k, fi, _, _, tangent) in &slanted {
let ke = if *k == 0 { 0 } else { n - 1 };
let fit = fits.get(ke).and_then(Option::as_ref).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
@@ -1424,6 +1467,43 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let seam = |s: u8| seams.get(*k).and_then(|l| l.iter().find(|(x, _)| *x as u8 == s)).map(|(_, e)| e);
let both = [seam(1), seam(2)];
if let Some((_, ga, gb, middle)) = split_ends.iter().find(|e| e.0 == *k) {
use mt::{BSplineCurve, KnotVec, ParametricCurve, SearchNearestParameter};
let fail = || unsupported("the chamfer does not cross both end faces");
let p = from_p3(verts.get(*k).ok_or_else(fail)?.point());
let (_, t) = tangent_at(chain.get(ke).ok_or_else(fail)?, if *k == 0 { 0.0 } else { 1.0 }).ok_or_else(fail)?;
let hit = |q: Vec3, fi: usize| -> Option<Vec3> {
let normal = faces.get(fi).and_then(crate::blend::plane_normal)?;
let den = normal.dot(t);
(den.abs() > 1e-6).then(|| q - t * (normal.dot(q - p) / den))
};
let a = hit(at.q1, *ga).ok_or_else(fail)?;
let b = hit(at.q2, *gb).ok_or_else(fail)?;
let m = (at.q2 - at.q1).cross(t).normalized().ok_or_else(fail)?;
let far = if middle.front() == verts.get(*k).ok_or_else(fail)? { middle.back() } else { middle.front() };
let u = from_p3(far.point()) - p;
let den = m.dot(u);
if den.abs() < tol {
return Err(fail());
}
let fraction = m.dot(at.q1 - p) / den;
let c = p + u * fraction;
let across = (c - at.q1).dot(at.q2 - at.q1) / (at.q2 - at.q1).len2();
if !(fraction > 1e-6 && fraction < 1.0 - 1e-6 && across > 1e-6 && across < 1.0 - 1e-6) {
return Err(fail());
}
let parameter = |row: usize, q: Vec3| -> Option<f64> {
let curve = fit.contacts.get(row)?;
let v = curve.search_nearest_parameter(p3(q), None, 100)?;
(v > fit.range.0 + 1e-6 && v < fit.range.1 - 1e-6 && from_p3(curve.subs(v)).dist(q) <= fit_tolerance).then_some(v)
};
let (ta, tb) = (parameter(0, a).ok_or_else(fail)?, parameter(1, b).ok_or_else(fail)?);
let curve = BSplineCurve::try_new(KnotVec::from(vec![0.0, 0.0, 0.5, 1.0, 1.0]), vec![p3(a), p3(c), p3(b)])
.map_err(|e| KernelError::Failed(format!("chamfer end: {e}")))?;
caps.push((*k, ke, ta, tb, mt::Curve::BSplineCurve(curve)));
split_caps.push((*k, middle.clone(), c));
continue;
}
let cap = crate::guard("curve blend end", || match tangent {
Some(side) => tangent_cap(fit, g, *side, both, fit_tolerance),
None => end_cap(fit, g, &at, both, round, fit_tolerance),
@@ -1478,6 +1558,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
}
let mut arcs = Vec::with_capacity(nv);
let mut split_arcs: HashMap<usize, Vec<mt::Edge>> = HashMap::new();
for (k, s) in vsec.iter().enumerate() {
let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
let e = if round {
@@ -1490,6 +1571,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?,
None => e,
};
if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) {
let v = builder::vertex(p3(*c));
split_arcs.insert(k, vec![builder::line(a, &v), builder::line(&v, b)]);
}
arcs.push(e);
}
// Outward: toward the ball centre on a concave edge, away on a convex one.
@@ -1575,6 +1660,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
}
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (k, seam, _) in &split_caps {
let old = verts.get(*k).ok_or_else(|| fail("end vertex"))?;
let v = split_arcs.get(k).and_then(|es| es.first()).map(|e| e.back()).ok_or_else(|| fail("end seam"))?;
seam_subst.insert(seam.id(), crate::blend::retarget(seam, old, v, fit_tolerance)?);
}
for (id, (seam, a, b)) in ends {
if consumed.contains_key(&id) {
continue;
@@ -1630,6 +1720,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
for (fi, f) in faces.iter().enumerate() {
let corners: Vec<usize> = end_faces.iter().filter(|(_, g)| *g == fi).map(|(k, _)| *k).collect();
if !corners.is_empty() {
if corners.iter().any(|k| split_arcs.contains_key(k)) {
let connectors: Vec<mt::Edge> = corners
.iter()
.flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().into_iter().collect()))
.collect();
let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?;
rec.face(&rebuilt, f);
out.push(rebuilt);
continue;
}
// The end face (one or both ends of the chain): its two edges at a chain end now
// stop at the contacts, with the blend's end arc between them.
let mut wires = Vec::new();
@@ -1725,14 +1825,28 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
.ok_or_else(|| fail("edge use"))?;
// Side 1 runs the contact forward (as the chain) when it ran the edge forward.
// A pivot's planar contact is a point: three sides.
let wire: mt::Wire = if used_fwd {
let edges = if used_fwd {
[e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())]
} else {
[e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())]
}
.into_iter()
.flatten()
.collect();
.collect::<Vec<_>>();
let wire: mt::Wire = edges
.into_iter()
.flat_map(|e| {
let split = [(k, ak), ((k + 1) % nv, ak1)]
.into_iter()
.find(|(_, arc)| arc.id() == e.id())
.and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts)));
match split {
Some((arc, parts)) if e.orientation() == arc.orientation() => parts.clone(),
Some((_, parts)) => parts.iter().rev().map(|p| p.inverse()).collect(),
None => vec![e],
}
})
.collect();
let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?;
rec.blend(&face, [chain_e.id()]);
out.push(face);
+48
View File
@@ -8,6 +8,54 @@ use crate::blend::plane_normal;
use crate::issue_851_tests::planned_volume;
use crate::{Body, BoolOp, boolean, cylinder, fillet};
#[test]
fn issue_1303_chamfer_open_rim_after_end_chamfer() {
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
let rim = Region2 {
outer: Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(31.0, -15.0), b: Vec2::new(31.0, -11.75) },
Seg2::Line { a: Vec2::new(31.0, -11.75), b: Vec2::new(0.0, -11.75) },
Seg2::Arc { center: Vec2::ZERO, radius: 11.75, start: -std::f64::consts::FRAC_PI_2, sweep: -std::f64::consts::PI },
Seg2::Line { a: Vec2::new(0.0, 11.75), b: Vec2::new(31.0, 11.75) },
Seg2::Line { a: Vec2::new(31.0, 11.75), b: Vec2::new(31.0, 15.0) },
Seg2::Line { a: Vec2::new(31.0, 15.0), b: Vec2::new(0.0, 15.0) },
Seg2::Arc { center: Vec2::ZERO, radius: 15.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: Vec2::new(0.0, -15.0), b: Vec2::new(31.0, -15.0) },
],
},
holes: vec![],
};
let mut body = crate::extrude(&Plane::XY, &[rim], 0.0, 13.0).unwrap().pop().unwrap();
// Exact local end bevels, meeting at the four-face vertices (30, +/-11.75, 13).
body = crate::blend_corner::chamfer(
&body,
&[Vec3::new(31.0, 13.375, 13.0), Vec3::new(31.0, -13.375, 13.0), Vec3::new(31.0, 11.75, 8.0), Vec3::new(31.0, -11.75, 8.0)],
1.0,
)
.unwrap();
let picks = [Vec3::new(-11.75, 0.0, 13.0), Vec3::new(15.0, 11.75, 13.0), Vec3::new(15.0, -11.75, 13.0)];
let result = crate::chamfer(&body, &picks, 1.0).unwrap();
assert!(result.validity().is_empty(), "{:?}", result.validity());
assert_eq!(result.crossed_face_loops().unwrap(), 0);
// At height 12 + u, the removed half-ring area is pi/2 * (2*r*u + u^2).
// The straight strips end at min(31-u, 30+v), 0 <= v <= u. Their integral
// over 0 <= u <= 1 is 30 + 1/6; this includes both split end sections.
let expected = 30.0 + 1.0 / 6.0 + std::f64::consts::PI * (11.75 / 2.0 + 1.0 / 6.0);
let volume = |b: &Body| b.tessellate_with(0.0001, true).unwrap().measure().volume;
let removed = volume(&body) - volume(&result);
assert!((removed - expected).abs() < 0.02, "removed {removed}, expected {expected}");
for p in [Vec3::new(30.5, 12.25, 12.5), Vec3::new(30.5, -12.25, 12.5)] {
assert!(result.solid.vertex_iter().any(|v| from_p3(v.point()).dist(p) < 1e-6));
}
for selection in [vec![picks[0]], picks.into_iter().rev().collect()] {
let other = crate::chamfer(&body, &selection, 1.0).unwrap();
assert!(other.validity().is_empty(), "{:?}", other.validity());
assert!((volume(&body) - volume(&other) - expected).abs() < 0.02);
}
assert!(crate::chamfer(&body, &picks, 4.0).is_err());
}
fn tee(scale: f64, origin: Vec3) -> Body {
let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff. Show more