diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 135e5bd5..3c2b1607 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index dd5e3ea9..aaeb9c24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`** 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`. diff --git a/CLAUDE.md b/CLAUDE.md index 005b8219..60b87023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`** 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ff450ce..8f15211f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index ed8150d9..0e4b61c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 73c703a8..a35256e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index b204f4c1..1cc2dc12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index b67312db..f9063778 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolidCraft -SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 477 crates below. +SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 508 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -14,6 +14,7 @@ accesskit_unix 0.21.1 (MIT OR Apache-2.0) accesskit_windows 0.32.1 (MIT OR Apache-2.0) accesskit_winit 0.32.2 (Apache-2.0) adler2 2.0.1 (0BSD OR MIT OR Apache-2.0) +aes 0.8.4 (MIT OR Apache-2.0) ahash 0.8.12 (MIT OR Apache-2.0) allocator-api2 0.2.21 (MIT OR Apache-2.0) android-activity 0.6.1 (MIT OR Apache-2.0) @@ -29,6 +30,7 @@ ash 0.38.0+1.3.281 (MIT OR Apache-2.0) async-broadcast 0.7.2 (MIT OR Apache-2.0) async-channel 2.5.0 (Apache-2.0 OR MIT) async-executor 1.14.0 (Apache-2.0 OR MIT) +async-fs 2.2.0 (Apache-2.0 OR MIT) async-io 2.6.0 (Apache-2.0 OR MIT) async-lock 3.4.2 (Apache-2.0 OR MIT) async-process 2.5.0 (Apache-2.0 OR MIT) @@ -46,6 +48,7 @@ bit-vec 0.9.1 (Apache-2.0 OR MIT) bitflags 1.3.2 (MIT/Apache-2.0) bitflags 2.13.2 (MIT OR Apache-2.0) block-buffer 0.10.4 (MIT OR Apache-2.0) +block-padding 0.3.3 (MIT OR Apache-2.0) block2 0.5.1 (MIT) block2 0.6.2 (MIT) blocking 1.7.0 (Apache-2.0 OR MIT) @@ -53,16 +56,19 @@ branches 0.3.0 (MIT) bumpalo 3.20.3 (MIT OR Apache-2.0) bytemuck 1.25.2 (Zlib OR Apache-2.0 OR MIT) bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT) +byteorder 1.5.0 (Unlicense OR MIT) byteorder-lite 0.1.0 (Unlicense OR MIT) bytes 1.12.1 (MIT) calloop 0.13.0 (MIT) calloop 0.14.5 (MIT) calloop-wayland-source 0.3.0 (MIT) calloop-wayland-source 0.4.1 (MIT) +cbc 0.1.2 (MIT OR Apache-2.0) cfg-if 1.0.5 (MIT OR Apache-2.0) cgl 0.3.2 (MIT / Apache-2.0) cgmath 0.18.0 (Apache-2.0) chrono 0.4.45 (MIT OR Apache-2.0) +cipher 0.4.4 (MIT OR Apache-2.0) clipboard-win 5.4.1 (BSL-1.0) codespan-reporting 0.13.1 (Apache-2.0) color 0.3.3 (Apache-2.0 OR MIT) @@ -82,6 +88,8 @@ crossbeam-utils 0.8.23 (MIT OR Apache-2.0) crunchy 0.2.4 (MIT) crypto-common 0.1.7 (MIT OR Apache-2.0) cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib) +dbus 0.9.12 (Apache-2.0/MIT) +dbus-secret-service 4.1.0 (MIT OR Apache-2.0) derive_arbitrary 1.5.0 (MIT OR Apache-2.0) derive_more 0.99.20 (MIT) digest 0.10.7 (MIT OR Apache-2.0) @@ -127,6 +135,7 @@ futures-core 0.3.34 (MIT OR Apache-2.0) futures-io 0.3.34 (MIT OR Apache-2.0) futures-lite 2.6.1 (Apache-2.0 OR MIT) futures-macro 0.3.34 (MIT OR Apache-2.0) +futures-sink 0.3.34 (MIT OR Apache-2.0) futures-task 0.3.34 (MIT OR Apache-2.0) futures-util 0.3.34 (MIT OR Apache-2.0) generic-array 0.14.7 (MIT) @@ -150,6 +159,8 @@ hashbrown 0.17.1 (MIT OR Apache-2.0) hermit-abi 0.5.3 (MIT OR Apache-2.0) hex 0.4.3 (MIT OR Apache-2.0) hidapi 2.6.7 (MIT) +hkdf 0.12.4 (MIT OR Apache-2.0) +hmac 0.12.1 (MIT OR Apache-2.0) http 1.5.0 (MIT OR Apache-2.0) httparse 1.10.1 (MIT OR Apache-2.0) iana-time-zone 0.1.65 (MIT OR Apache-2.0) @@ -165,6 +176,7 @@ idna 1.1.0 (MIT OR Apache-2.0) idna_adapter 1.2.2 (Apache-2.0 OR MIT) image 0.25.10 (MIT OR Apache-2.0) indexmap 2.14.2 (Apache-2.0 OR MIT) +inout 0.1.4 (MIT OR Apache-2.0) itertools 0.13.0 (MIT OR Apache-2.0) itertools 0.15.0 (MIT OR Apache-2.0) itoa 1.0.18 (MIT OR Apache-2.0) @@ -175,9 +187,11 @@ jni-sys 0.4.1 (MIT OR Apache-2.0) jni-sys-macros 0.4.1 (MIT OR Apache-2.0) js-sys 0.3.106 (MIT OR Apache-2.0) katexit 0.1.5 (MIT OR Apache-2.0) +keyring 3.6.3 (MIT OR Apache-2.0) khronos-egl 6.0.0 (MIT/Apache-2.0) kurbo 0.13.1 (Apache-2.0 OR MIT) libc 0.2.190 (MIT OR Apache-2.0) +libdbus-sys 0.2.7 (Apache-2.0/MIT) libloading 0.8.9 (ISC) libm 0.2.16 (MIT) libredox 0.1.25 (MIT) @@ -201,8 +215,14 @@ naga-types 30.0.1 (MIT OR Apache-2.0) ndk 0.9.0 (MIT OR Apache-2.0) ndk-context 0.1.1 (MIT OR Apache-2.0) ndk-sys 0.6.0+11769913 (MIT OR Apache-2.0) +nix 0.29.0 (MIT) nohash-hasher 0.2.0 (Apache-2.0 OR MIT) +num 0.4.3 (MIT OR Apache-2.0) +num-bigint 0.4.8 (MIT OR Apache-2.0) num-complex 0.4.6 (MIT OR Apache-2.0) +num-integer 0.1.47 (MIT OR Apache-2.0) +num-iter 0.1.46 (MIT OR Apache-2.0) +num-rational 0.4.2 (MIT OR Apache-2.0) num-traits 0.2.19 (MIT OR Apache-2.0) num_enum 0.7.6 (BSD-3-Clause OR MIT OR Apache-2.0) num_enum_derive 0.7.6 (BSD-3-Clause OR MIT OR Apache-2.0) @@ -304,6 +324,8 @@ rustversion 1.0.23 (MIT OR Apache-2.0) schannel 0.1.29 (MIT) scoped-tls 1.0.1 (MIT/Apache-2.0) scopeguard 1.2.0 (MIT OR Apache-2.0) +secret-service 4.0.0 (MIT OR Apache-2.0) +security-framework 2.11.1 (MIT OR Apache-2.0) security-framework 3.7.0 (MIT OR Apache-2.0) security-framework-sys 2.17.0 (MIT OR Apache-2.0) self_cell 1.3.0 (Apache-2.0 OR GPL-2.0-only) @@ -313,6 +335,7 @@ serde_core 1.0.229 (MIT OR Apache-2.0) serde_derive 1.0.229 (MIT OR Apache-2.0) serde_json 1.0.151 (MIT OR Apache-2.0) serde_repr 0.1.21 (MIT OR Apache-2.0) +sha1 0.10.7 (MIT OR Apache-2.0) sha2 0.10.9 (MIT OR Apache-2.0) signal-hook-registry 1.4.8 (MIT OR Apache-2.0) simd-adler32 0.3.10 (MIT) @@ -453,14 +476,18 @@ x11-dl 2.21.0 (MIT) x11rb 0.13.2 (MIT OR Apache-2.0) x11rb-protocol 0.13.2 (MIT OR Apache-2.0) xcursor 0.3.11 (MIT) +xdg-home 1.3.0 (MIT) xkbcommon-dl 0.4.2 (MIT) xkeysym 0.2.1 (MIT OR Apache-2.0 OR Zlib) yoke 0.8.3 (Unicode-3.0) yoke-derive 0.8.2 (Unicode-3.0) +zbus 4.4.0 (MIT) zbus 5.19.0 (MIT) zbus-lockstep 0.5.2 (MIT) zbus-lockstep-macros 0.5.2 (MIT) +zbus_macros 4.4.0 (MIT) zbus_macros 5.19.0 (MIT) +zbus_names 3.0.0 (MIT) zbus_names 4.3.4 (MIT) zbus_xml 5.2.1 (MIT) zcheapstr 1.1.0 (MIT) @@ -469,6 +496,7 @@ zerocopy-derive 0.8.61 (BSD-2-Clause OR Apache-2.0 OR MIT) zerofrom 0.1.8 (Unicode-3.0) zerofrom-derive 0.1.7 (Unicode-3.0) zeroize 1.9.1 (Apache-2.0 OR MIT) +zeroize_derive 1.5.0 (Apache-2.0 OR MIT) zerotrie 0.2.5 (Unicode-3.0) zerovec 0.11.8 (Unicode-3.0) zerovec-derive 0.11.6 (Unicode-3.0) @@ -478,8 +506,11 @@ zmij 1.0.23 (MIT) zopfli 0.8.3 (Apache-2.0) zune-core 0.5.3 (MIT OR Apache-2.0 OR Zlib) zune-jpeg 0.5.15 (MIT OR Apache-2.0 OR Zlib) +zvariant 4.2.0 (MIT) zvariant 5.15.0 (MIT) +zvariant_derive 4.2.0 (MIT) zvariant_derive 5.15.0 (MIT) +zvariant_utils 2.1.0 (MIT) zvariant_utils 4.2.0 (MIT) ================================================================================ @@ -500,12 +531,12 @@ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ -adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-io 2.6.0, -async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task 4.7.1, -async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, displaydoc 0.2.7, -endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, -futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0, linux-raw-sys -0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, +adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-fs 2.2.0, +async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, +async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0, +displaydoc 0.2.7, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4, +fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0, +linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic 1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47, rustc-hash 1.1.0, rustc-hash @@ -513,8 +544,9 @@ pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic 1.15.0, po 1.0.229, serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typeid 1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi -0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, -zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zmij 1.0.23, zvariant_utils 4.2.0 +0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home +1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, +zvariant_utils 2.1.0, zvariant_utils 4.2.0 ================================================================================ Permission is hereby granted, free of charge, to any @@ -747,6 +779,243 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +================================================================================ +aes 0.8.4 +================================================================================ + +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +================================================================================ +aes 0.8.4, block-buffer 0.10.4, block-padding 0.3.3, cbc 0.1.2, cipher 0.4.4, cpufeatures 0.2.17, +crypto-common 0.1.7, digest 0.10.7, hkdf 0.12.4, hmac 0.12.1, inout 0.1.4, sha1 0.10.7, sha2 0.10.9 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ================================================================================ ahash 0.8.12 ================================================================================ @@ -778,28 +1047,30 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-io -2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task -4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo -3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation -0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque -0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, derive_arbitrary 1.5.0, displaydoc 0.2.7, -either 1.19.0, equivalent 1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, -event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, form_urlencoded 1.2.2, futures-lite -2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, -httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools -0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api -0.4.14, log 0.4.34, num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, -ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding -2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon -1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, rustc-hash 1.1.0, rustix -0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard -1.2.0, security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, -simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile -3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, -wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, -wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, -wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1 +ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-fs +2.2.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal +0.2.14, async-task 4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, +blocking 1.7.0, bumpalo 3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation +0.10.1, core-foundation 0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types +0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service +4.1.0, derive_arbitrary 1.5.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14, +euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, +form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, +hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap +2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, +linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, +num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, +openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core +0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster +0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron +0.12.2, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, +scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0, security-framework 2.11.1, +security-framework 3.7.0, security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 +1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, +ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi +0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures +0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, +web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1 ================================================================================ Apache License @@ -1504,7 +1775,7 @@ approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, at cgmath 0.18.0, codespan-reporting 0.13.1, kurbo 0.13.1, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions 1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, -x11rb-protocol 0.13.2, zeroize 1.9.1, zune-core 0.5.3, zune-jpeg 0.5.15 +x11rb-protocol 0.13.2, zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15 ================================================================================ Apache License @@ -2517,7 +2788,8 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -bitflags 1.3.2, bitflags 2.13.2, log 0.4.34, num-complex 0.4.6, num-traits 0.2.19 +bitflags 1.3.2, bitflags 2.13.2, log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, +num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19 ================================================================================ Copyright (c) 2014 The Rust Project Developers @@ -2547,7 +2819,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -block-buffer 0.10.4 +block-buffer 0.10.4, block-padding 0.3.3 ================================================================================ Copyright (c) 2018-2019 The RustCrypto Project Developers @@ -2576,212 +2848,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -================================================================================ -block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9 -================================================================================ - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - ================================================================================ branches 0.3.0, rclite 0.2.8 ================================================================================ @@ -2935,7 +3001,7 @@ The above copyright notice and this permission notice (including the next paragr THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1 +byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1 ================================================================================ The MIT License (MIT) @@ -2961,7 +3027,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 +byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 ================================================================================ This is free and unencumbered software released into the public domain. @@ -2989,6 +3055,14 @@ OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to +================================================================================ +byteorder 1.5.0, memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 +================================================================================ + +This project is dual-licensed under the Unlicense and MIT licenses. + +You may use this code under the terms of either license. + ================================================================================ bytes 1.12.1 ================================================================================ @@ -3067,6 +3141,37 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +cbc 0.1.2 +================================================================================ + +Copyright (c) 2018-2022 RustCrypto Developers +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ cfg-if 1.0.5, js-sys 0.3.106, openssl-probe 0.2.1, scoped-tls 1.0.1, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, @@ -3384,6 +3489,36 @@ See the License for the specific language governing permissions and limitations under the License. ~~~~ +================================================================================ +cipher 0.4.4 +================================================================================ + +Copyright (c) 2016-2020 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ combine 4.6.8 ================================================================================ @@ -4008,6 +4143,266 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +dbus 0.9.12, libdbus-sys 0.2.7 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2014-2018 David Henningsson and other contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ +dbus 0.9.12, libdbus-sys 0.2.7 +================================================================================ + +Copyright (c) 2014-2018 David Henningsson and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +dbus-secret-service 4.1.0, secret-service 4.0.0 +================================================================================ + +Copyright (c) 2016 secret-service Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ derive_more 0.99.20 ================================================================================ @@ -4035,7 +4430,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -digest 0.10.7 +digest 0.10.7, hmac 0.12.1 ================================================================================ Copyright (c) 2017 Artyom Pavlov @@ -4913,8 +5308,8 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-task 0.3.34, futures-util -0.3.34 +futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-sink 0.3.34, futures-task +0.3.34, futures-util 0.3.34 ================================================================================ Apache License @@ -5121,8 +5516,8 @@ See the License for the specific language governing permissions and limitations under the License. ================================================================================ -futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-task 0.3.34, futures-util -0.3.34 +futures-core 0.3.34, futures-io 0.3.34, futures-macro 0.3.34, futures-sink 0.3.34, futures-task +0.3.34, futures-util 0.3.34 ================================================================================ Copyright (c) 2016 Alex Crichton @@ -6121,6 +6516,37 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FO OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +hkdf 0.12.4 +================================================================================ + +Copyright (c) 2015-2018 Vlad Filippov +Copyright (c) 2018-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ http 1.5.0 ================================================================================ @@ -6761,6 +7187,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +inout 0.1.4 +================================================================================ + +Copyright (c) 2022 The RustCrypto Project Developers +Copyright (c) 2022 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ jni-sys 0.3.1, jni-sys 0.4.1 ================================================================================ @@ -6785,6 +7242,242 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +keyring 3.6.3 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2017] [keyring developers] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ +keyring 3.6.3 +================================================================================ + +Copyright (c) 2016 keyring Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ libc 0.2.190 ================================================================================ @@ -7645,14 +8338,6 @@ Apache License See the License for the specific language governing permissions and limitations under the License. -================================================================================ -memchr 2.8.3, termcolor 1.4.1, winapi-util 0.1.11 -================================================================================ - -This project is dual-licensed under the Unlicense and MIT licenses. - -You may use this code under the terms of either license. - ================================================================================ memmap2 0.9.11 ================================================================================ @@ -8321,6 +9006,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +nix 0.29.0 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Carl Lerche + nix-rust Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ nohash-hasher 0.2.0 ================================================================================ @@ -10549,7 +11260,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -security-framework 3.7.0, security-framework-sys 2.17.0 +security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0 ================================================================================ The MIT License (MIT) @@ -10918,7 +11629,7 @@ library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ================================================================================ -sha2 0.10.9 +sha1 0.10.7, sha2 0.10.9 ================================================================================ Copyright (c) 2006-2009 Graydon Hoare @@ -13864,8 +14575,8 @@ Permission is granted to anyone to use this software for any purpose, including 3. This notice may not be removed or altered from any source distribution. ================================================================================ -zbus 5.19.0, zbus_macros 5.19.0, zbus_names 4.3.4, zbus_xml 5.2.1, zvariant 5.15.0, zvariant_derive -5.15.0 +zbus 4.4.0, zbus 5.19.0, zbus_macros 4.4.0, zbus_macros 5.19.0, zbus_names 4.3.4, zbus_xml 5.2.1, +zvariant 4.2.0, zvariant 5.15.0, zvariant_derive 4.2.0, zvariant_derive 5.15.0 ================================================================================ Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors @@ -14219,6 +14930,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +zeroize_derive 1.5.0 +================================================================================ + +Copyright (c) 2019-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ zip 3.0.0 ================================================================================ diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index db51d3dc..db27ddaf 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -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", ] } diff --git a/apps/solidcraft-web/index.html b/apps/solidcraft-web/index.html index 1a671690..aa66df9a 100644 --- a/apps/solidcraft-web/index.html +++ b/apps/solidcraft-web/index.html @@ -23,6 +23,15 @@ + + + + + + + + + @@ -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; }); diff --git a/apps/solidcraft-web/manifest.webmanifest b/apps/solidcraft-web/manifest.webmanifest new file mode 100644 index 00000000..54b56d59 --- /dev/null +++ b/apps/solidcraft-web/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/solidcraft-web/src/install.rs b/apps/solidcraft-web/src/install.rs new file mode 100644 index 00000000..e8ae0bd9 --- /dev/null +++ b/apps/solidcraft-web/src/install.rs @@ -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 { + 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::().map_err(JsValue::from)) + .and_then(|f| f.call0(&offer)) + .and_then(|p| p.dyn_into::().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::::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) } +} diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index ac6fd240..f3a788de 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -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, 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); diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index ab7835c1..20e0fa7f 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -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] diff --git a/apps/solidcraft/src/control_server.rs b/apps/solidcraft/src/control_server.rs index 0d79ca6b..2e566afc 100644 --- a/apps/solidcraft/src/control_server.rs +++ b/apps/solidcraft/src/control_server.rs @@ -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::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> = std::sync::Mutex::new(Vec::new()); /// A new random token: 32 bytes from the operating system, as hex. fn new_token() -> Option { @@ -30,30 +31,55 @@ fn new_token() -> Option { /// 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 { let (tx, rx) = channel::(); // 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, ctx: egui::Context) -> Option { + 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, ctx: egui::Context, patient: bool) -> Option { 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 { 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) { let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink()); } -fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { +fn serve(stream: TcpStream, token: &str, tx: Sender, 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, 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]| { diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index a687dade..75f05b7f 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -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), 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::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, 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, 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); diff --git a/apps/solidcraft/src/native_frame.rs b/apps/solidcraft/src/native_frame.rs new file mode 100644 index 00000000..9bac6d4e --- /dev/null +++ b/apps/solidcraft/src/native_frame.rs @@ -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:: 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 { + 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 = [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]); + } +} diff --git a/assets/README.md b/assets/README.md index 8d81e20e..2bfafd71 100644 --- a/assets/README.md +++ b/assets/README.md @@ -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 diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 1d05483e..4f887905 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -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); diff --git a/assets/locales/de.json b/assets/locales/de.json index 5fd806e1..35e5f8af 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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" } diff --git a/assets/locales/en.json b/assets/locales/en.json index a04d8c93..2b8345bc 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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" } diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 90b9a726..0b185008 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 901c485d..5f718d41 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "最大" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 15f3f1cc..a2b1c812 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "면적" } diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 9736c97f..15477724 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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": "Начать рендеринг", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 8e799cf0..a5ea3674 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -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}.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index bb32bcc7..2168aa33 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "最大" } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index ab98a7b6..cc449d70 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -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) { diff --git a/crates/doc/src/display_quality.rs b/crates/doc/src/display_quality.rs index d6795b86..041c3497 100644 --- a/crates/doc/src/display_quality.rs +++ b/crates/doc/src/display_quality.rs @@ -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); + } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2cf094af..89394a89 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, + /// 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, + /// 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, }, /// 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, +} + +/// 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, 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, kind: Kind, what: &str| -> Result> { + 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 diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 73c42203..263c8233 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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, Arc)> { + 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 { let mut names: Vec = 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, p: &crate::PointExpr) -> Result { 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, e: &str, k: Kind) -> Result { +pub(crate) fn val(vals: &BTreeMap, e: &str, k: Kind) -> Result { 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 { 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> = 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) -> 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> = - 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 { + 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::>(); + 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::>(); 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 ®ions { 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::>(); 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 = 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::>())]; @@ -3995,9 +4122,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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, 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, 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> { 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> { + let skip: std::collections::HashSet = 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, diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index e3fbcf30..41d0aa7b 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -6,9 +6,9 @@ use super::*; use crate::{RuledCorners, RuledMode}; use solidcraft_geom::Seg2; -pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { +pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> 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, 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, 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 diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index 0d54eb10..8ea891a9 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -237,6 +237,8 @@ fn lex_spans(s: &str) -> Result, 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, 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, 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, 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); diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index c643343b..1b388a61 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -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"})), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index 7d169e87..ace4b70d 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -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) diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index c9db3a6d..0f50600f 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -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] diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index 70110cc7..edc123b4 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -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 diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index a8bcddc4..091bb4e3 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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 { /// 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 diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index 8bc4703f..5cf20897 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -624,6 +624,16 @@ fn unknowns(tree: &Tree, around: &BTreeSet, 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, 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 = + 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 = vars.iter().map(|(id, i)| near.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect(); + let mut x: Vec = 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> { + 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) { 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 = 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> { - 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 = 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 { + let mut work = at.clone(); + // The loops' gaps, then three points of the occurrence `scale` apart. + let mut f = |x: &[f64]| -> Option> { + 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 = 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 = BTreeSet::new(); let mut queue: VecDeque = 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> = 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; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1aa47845..4d8b705b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 0115397c..c9753be4 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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 = 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); diff --git a/crates/doc/src/physical_material.rs b/crates/doc/src/physical_material.rs index 26aee591..ad3b1644 100644 --- a/crates/doc/src/physical_material.rs +++ b/crates/doc/src/physical_material.rs @@ -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, pub isotropic_moduli: Option, pub unspecified_conditions: Vec, + /// 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>, pub source_review_required: Vec, 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, diff --git a/crates/doc/src/physical_material_tests.rs b/crates/doc/src/physical_material_tests.rs index 70dc3588..8b93a007 100644 --- a/crates/doc/src/physical_material_tests.rs +++ b/crates/doc/src/physical_material_tests.rs @@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() { assert_eq!(serde_json::from_value::(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::(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()); diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 45f7d71d..b2aed274 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -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`). diff --git a/crates/doc/src/render_settings.rs b/crates/doc/src/render_settings.rs index bff01ff8..f7aa4ce0 100644 --- a/crates/doc/src/render_settings.rs +++ b/crates/doc/src/render_settings.rs @@ -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 [ diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 34499951..dc32c177 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -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 Each for Vec { } } +impl Each 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 Each 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 Each for Option> { fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) { for t in self.iter().flatten() { diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index d1d5b954..9041841f 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 00df4c06..4b37b39a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -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> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect(); + let at: Vec> = 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)")); } } diff --git a/crates/engine/src/cmd/compare.rs b/crates/engine/src/cmd/compare.rs new file mode 100644 index 00000000..be888746 --- /dev/null +++ b/crates/engine/src/cmd/compare.rs @@ -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) -> Result { + 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> { + 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 = + s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); + let all: Vec = 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> { + 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 { + 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, y: Option| 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 { + 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 { + 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 = (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 = 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::>(), + "faces_b": r.faces_b.iter().map(|x| x.0).collect::>(), + "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::() + 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 { + 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, 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; diff --git a/crates/engine/src/cmd/compare_tests.rs b/crates/engine/src/cmd/compare_tests.rs new file mode 100644 index 00000000..f0066db8 --- /dev/null +++ b/crates/engine/src/cmd/compare_tests.rs @@ -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 { + 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"]); +} diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index a4869631..ffe5510f 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -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 { Ok(json!({"deleted": gone, "features": feats})) } +fn unlock(s: &mut Session, p: &Value) -> Result { + 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 { transfer::move_bodies(s, p) } @@ -376,6 +397,17 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { 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") { diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 26ca9b7a..bf03c14d 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -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) { diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index dafa9e98..8dd29104 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -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]", ), diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs index 75f850a3..1415d8db 100644 --- a/crates/engine/src/cmd/config_certificate_tests.rs +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -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()]; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 7bef27db..facd8bee 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index a437efdb..413c0478 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -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)); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f873b27b..1fdf684a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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 Result { - 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 { 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 = 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 { + 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 { 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 { @@ -193,10 +188,21 @@ fn open(s: &mut Session, p: &Value) -> Result { } 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) { diff --git a/crates/engine/src/cmd/file_import.rs b/crates/engine/src/cmd/file_import.rs new file mode 100644 index 00000000..ee016fa9 --- /dev/null +++ b/crates/engine/src/cmd/file_import.rs @@ -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, + transform: Mat, +} + +/// How a STEP file's tree becomes components. +#[derive(Default)] +struct Plan { + slots: Vec, + places: Vec, + /// 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, +} + +/// 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 = 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, parent_world: &Mat, seen: &mut HashMap, 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, 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, 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, + 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, +} + +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)> { + 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, + 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 = Vec::with_capacity(plan.slots.len()); + let mut first_occ: Vec> = 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 = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect(); + let forecast: Vec = names.iter().flatten().cloned().collect(); + if built != forecast { + let mut it = built.into_iter(); + let actual: Vec> = 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], 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> { + 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 = 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 { + 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 { + 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() + } +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index b2949845..410c6c43 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -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"), diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 313f681f..a6c8e890 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -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 { +fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result { 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 { (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 = 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 { 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 { 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 { 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 { "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 = - 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 = 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 = 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); diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 8764b8b6..7d7a3290 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -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 = 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 = 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}"); +} diff --git a/crates/engine/src/cmd/material_properties.rs b/crates/engine/src/cmd/material_properties.rs index 7a34c47d..1fc4e7b5 100644 --- a/crates/engine/src/cmd/material_properties.rs +++ b/crates/engine/src/cmd/material_properties.rs @@ -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()); } diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 4ad82745..18da943c 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -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, Value)> { +/// A described item: its geometry, direction (planar face normal, straight edge direction), +/// description and exact shape. +struct Item { + geo: Geo, + dir: Option, + json: Value, + shape: Shape, + /// A slot this item is one end of (or the outline of). + slot: Option, +} + +/// 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 { + 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 = + 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 { + 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, + 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 { + 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 { + use solidcraft_geom::{Seg2, Vec2}; + let pr = ss.profiles.get(index)?; + let outline: Vec = 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 { + 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![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)> = + 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 { 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, Value)> { let pieces: Vec<&Vec> = 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::>(); + 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::>(); let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum(); let normals: Vec = 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 = 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, + }; + // ":" names a point of any sketch. + let (given, id) = match id.split_once(':').and_then(|(k, p)| Some((k.parse::().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(v: &[T], max: usize) -> Vec { @@ -195,22 +451,89 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result { } 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::>()}); - 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::>()}); + 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::(); + 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 { + match g { + Geo::Points(p) => sample(p, 2000), + Geo::Tris(t) => sample(&t.iter().flat_map(|t| t.iter().copied()).collect::>(), 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)) +} diff --git a/crates/engine/src/cmd/measure_shape.rs b/crates/engine/src/cmd/measure_shape.rs new file mode 100644 index 00000000..507d57a3 --- /dev/null +++ b/crates/engine/src/cmd/measure_shape.rs @@ -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 { + let axis = axis.normalized()?; + if pts.is_empty() || !(radius.is_finite() && radius > 0.0) { + return None; + } + let ts: Vec = 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 = 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 { + 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, + pub max: Option, +} + +/// 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 { + 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 { + 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)]) -> Option { + 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 { + (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()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 07f2c088..bb6ed59d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index ee9e7e38..94083c1d 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 181d69d3..f1e5d9b3 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -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 { 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 { 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 { // 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 { )); } let v = s.doc.param(¶m).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. diff --git a/crates/engine/src/cmd/sketch_clipboard.rs b/crates/engine/src/cmd/sketch_clipboard.rs new file mode 100644 index 00000000..5818063c --- /dev/null +++ b/crates/engine/src/cmd/sketch_clipboard.rs @@ -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, +} + +/// 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, + pub curves: Vec, + pub constraints: Vec, + /// 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)>, + /// Lone points (not used by a copied curve), as indices into `points`. + pub lone: Vec, + /// 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 { + 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 { + 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, Vec, Vec) = (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 = BTreeMap::new(); + let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap| -> usize { + *pmap.entry(q).or_insert_with(|| { + clip.points.push(sk.point(q).unwrap_or_default()); + clip.points.len() - 1 + }) + }; + let mut cmap: BTreeMap = 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 = 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::(), + "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 { + 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 { + 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 = 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 = 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; diff --git a/crates/engine/src/cmd/sketch_clipboard_tests.rs b/crates/engine/src/cmd/sketch_clipboard_tests.rs new file mode 100644 index 00000000..86ec2e24 --- /dev/null +++ b/crates/engine/src/cmd/sketch_clipboard_tests.rs @@ -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, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn strs(v: &Value) -> Vec { + 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 { + 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) { + 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::>()); +} + +#[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 = [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); +} diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index f363ae49..e6d428d4 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -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 { } /// 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> { - 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)> { + 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, TextStyle)>) -> Result { - 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, 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) -> Result { + 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 { + 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 { @@ -197,7 +254,7 @@ fn text(s: &mut Session, p: &Value) -> Result { 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 { 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> { - 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 { 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 { fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option)> { 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 { +fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result { 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 { _ => 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. diff --git a/crates/engine/src/cmd/sketch_face_tests.rs b/crates/engine/src/cmd/sketch_face_tests.rs index 3d5f226b..42a0e129 100644 --- a/crates/engine/src/cmd/sketch_face_tests.rs +++ b/crates/engine/src/cmd/sketch_face_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index e28ad785..785c9ec8 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -141,7 +141,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result { 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() { diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 008856a9..f92adc81 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -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), diff --git a/crates/engine/src/cmd/sketch_text_expr_tests.rs b/crates/engine/src/cmd/sketch_text_expr_tests.rs new file mode 100644 index 00000000..334e7320 --- /dev/null +++ b/crates/engine/src/cmd/sketch_text_expr_tests.rs @@ -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 = 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) { + 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); +} diff --git a/crates/engine/src/cmd/sketch_text_move_tests.rs b/crates/engine/src/cmd/sketch_text_move_tests.rs index c8f3aa7c..7f1a34b5 100644 --- a/crates/engine/src/cmd/sketch_text_move_tests.rs +++ b/crates/engine/src/cmd/sketch_text_move_tests.rs @@ -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), diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 8656cfa3..0e1c1830 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 22b0d6f8..33c5aa2d 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -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 { let cmd = "surface.patch"; + let crown = crown_param(p, cmd)?; let edges: Vec = 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 { + 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 { + 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 = + 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 { + 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 { + 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 { + 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 { + 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 { @@ -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) -> (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::>()); + 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::>()); + 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() + ); + } } diff --git a/crates/engine/src/crash_tests.rs b/crates/engine/src/crash_tests.rs index eca51d20..b7a9b5f0 100644 --- a/crates/engine/src/crash_tests.rs +++ b/crates/engine/src/crash_tests.rs @@ -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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32b2a266..8fc7f217 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -222,6 +222,8 @@ pub struct Session { removed_later_cache: std::sync::Mutex>, /// Features copied with `timeline.copy` (pasted by `timeline.paste`). pub clipboard: Vec, + /// Sketch geometry copied with `sketch.copy` (pasted by `sketch.paste`). + pub sketch_clipboard: Option, /// 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; diff --git a/crates/engine/src/measure_centre_tests.rs b/crates/engine/src/measure_centre_tests.rs new file mode 100644 index 00000000..74791889 --- /dev/null +++ b/crates/engine/src/measure_centre_tests.rs @@ -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 { + 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); +} diff --git a/crates/engine/src/product_scene.rs b/crates/engine/src/product_scene.rs index e389d50f..b90d0300 100644 --- a/crates/engine/src/product_scene.rs +++ b/crates/engine/src/product_scene.rs @@ -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(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e1b4b481..b990ace9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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() { diff --git a/crates/engine/src/tests/import_component.rs b/crates/engine/src/tests/import_component.rs new file mode 100644 index 00000000..061f40d9 --- /dev/null +++ b/crates/engine/src/tests/import_component.rs @@ -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 { + 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::>(), ["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, 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 = 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 = 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); +} diff --git a/crates/engine/src/tests/step_import_options.rs b/crates/engine/src/tests/step_import_options.rs index bdf7ebb7..435c4d72 100644 --- a/crates/engine/src/tests/step_import_options.rs +++ b/crates/engine/src/tests/step_import_options.rs @@ -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"]); } diff --git a/crates/engine/src/view_display_tests.rs b/crates/engine/src/view_display_tests.rs index 0db14675..06f9faf7 100644 --- a/crates/engine/src/view_display_tests.rs +++ b/crates/engine/src/view_display_tests.rs @@ -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(); diff --git a/crates/engine/src/view_palette_tests.rs b/crates/engine/src/view_palette_tests.rs index 52c9dce8..0d65b9b0 100644 --- a/crates/engine/src/view_palette_tests.rs +++ b/crates/engine/src/view_palette_tests.rs @@ -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()]; diff --git a/crates/geom/src/compare.rs b/crates/geom/src/compare.rs new file mode 100644 index 00000000..e7999579 --- /dev/null +++ b/crates/geom/src/compare.rs @@ -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, + /// 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; 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, + nodes: Vec, +} + +#[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 = tris.iter().map(|t| (t[0] + t[1] + t[2]) / 3.0).collect(); + let mut order: Vec = (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 = 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 { + let mut out = Vec::new(); + let mut stack: Vec = 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::(); + 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, + face_area: HashMap, +} + +/// Runs `job` for every index below `n` through `each` and returns the results in order. +fn gather(n: usize, each: Each, job: impl Fn(usize) -> T + Sync) -> Vec { + let slots: Vec> = (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, other: &Mesh, from_a: bool, each: Each) -> Vec { + 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::>() + }); + 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> { + 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 = (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 { 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 = 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> = HashMap::new(); + for (i, g) in groups_of(&mut parent).into_iter().enumerate() { + by.entry(g).or_default().push(i); + } + let mut out: Vec> = 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 = 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; 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 = 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; diff --git a/crates/geom/src/compare_tests.rs b/crates/geom/src/compare_tests.rs new file mode 100644 index 00000000..43f65c27 --- /dev/null +++ b/crates/geom/src/compare_tests.rs @@ -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 = 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()); +} diff --git a/crates/geom/src/fill.rs b/crates/geom/src/fill.rs index bfde4ac1..3122982b 100644 --- a/crates/geom/src/fill.rs +++ b/crates/geom/src/fill.rs @@ -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, tol: f64) -> SplineResult { +/// - `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, tol: f64, crown: f64) -> SplineResult { 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 { evaluations += 1; @@ -116,7 +137,7 @@ pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option } // The loop in order: fitted and checked points alternating. let all: Vec = 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 { +fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64, crown: f64) -> SplineResult { 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 { 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 { 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> { + 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 { + 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()); } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8a6e3f13..21e44948 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -11,6 +11,7 @@ mod arcfit; pub mod brep; pub mod bspline; +pub mod compare; pub mod env; pub mod fill; mod mesh; diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index 8dc7630b..0d321760 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -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::>(), "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"); } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6ef6f575..5b28962e 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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, }) diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 59b27aa3..f0f003a8 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -2122,6 +2122,21 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result { 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 { let mut cur = start.clone(); diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs index 5cf57293..f2e55301 100644 --- a/crates/kernel/src/blend_fit.rs +++ b/crates/kernel/src/blend_fit.rs @@ -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 { + 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 { + if matches!(size, Size::Round(_)) { + return Ok(out); + } + let ways: Option> = 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 diff --git a/crates/kernel/src/blend_wrong_way_tests.rs b/crates/kernel/src/blend_wrong_way_tests.rs new file mode 100644 index 00000000..9d927c16 --- /dev/null +++ b/crates/kernel/src/blend_wrong_way_tests.rs @@ -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}"); +} diff --git a/crates/kernel/src/boolean_record.rs b/crates/kernel/src/boolean_record.rs index e24d31f0..e52383bf 100644 --- a/crates/kernel/src/boolean_record.rs +++ b/crates/kernel/src/boolean_record.rs @@ -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 { 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()) diff --git a/crates/kernel/src/bridge.rs b/crates/kernel/src/bridge.rs new file mode 100644 index 00000000..1b55c40a --- /dev/null +++ b/crates/kernel/src/bridge.rs @@ -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 { + 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> { + 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 = 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, f64)> { + /// Samples per edge used for the length table. + const PER_EDGE: usize = 64; + let mut poly: Vec = 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> { + 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 = 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 { + 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> { + 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 { + 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![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 = (0..stations).map(|i| i as f64 / (stations - 1) as f64).collect(); + let degree = 3.min(stations - 1); + let mut knots_u: Option> = 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(¶ms, &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 = 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>> { + 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 { + 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 { + 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 = 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; diff --git a/crates/kernel/src/bridge_tests.rs b/crates/kernel/src/bridge_tests.rs new file mode 100644 index 00000000..0b6c9c5a --- /dev/null +++ b/crates/kernel/src/bridge_tests.rs @@ -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 = (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 = (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, Vec) { + 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, Vec) { + 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 { + let mut pool: Vec = 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 = 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 = 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]); +} diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 0f09d99d..6de2a465 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -1059,6 +1059,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let mut seams: Vec> = 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)> = 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 = 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 { + 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 { + 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> = 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 = 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 = 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 = 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::>(); + 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); diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index bf160624..cf88c611 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -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(); diff --git a/crates/kernel/src/fill.rs b/crates/kernel/src/fill.rs index 829d84c8..90e80d96 100644 --- a/crates/kernel/src/fill.rs +++ b/crates/kernel/src/fill.rs @@ -16,6 +16,13 @@ use crate::{KernelError, Result}; /// every edge. Its history is over `[body]`: the face is new, its edges the body's own. Call it /// under [`crate::guard`]. pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result { + fill_loop_crowned(body, chain, 0.0) +} + +/// [`fill_loop`] with a crown: positive makes the fitted patch's middle fuller, negative flatter, +/// by `crown` times the loop's size (see `solidcraft_geom::fill::fill_loop`). The bump vanishes on +/// the loop, so the face still meets the body's own edges exactly as the plain fill does. +pub(crate) fn fill_loop_crowned(body: &Body, chain: &[mt::Edge], crown: f64) -> Result { let tol = face_tolerance(body); let mut sides: Vec<(EdgeSampler, Vec3)> = Vec::with_capacity(chain.len()); for e in chain { @@ -27,7 +34,7 @@ pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result { // The loop's corners are its vertices (an imported edge's curve may end a little off). if t <= 0.0 { Some(*start) } else { sampler.at(t) } }; - let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol).map_err(|e| match e { + let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol, crown).map_err(|e| match e { SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")), other => KernelError::Invalid(format!("cannot fill the loop: {other}")), })?; @@ -40,6 +47,37 @@ pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result { Ok(rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec)) } +/// [`crate::patch_edges`] with a crown on the fitted face: positive makes the patch's middle +/// fuller, negative flatter, by `crown` times the loop's size (its projected box's diagonal), the +/// bump vanishing along the loop, so the face still meets the body's own edges. `crown` 0 is +/// exactly [`crate::patch_edges`], and a loop that is certainly planar ([`crate::plane_fit`]) +/// makes the same exact planar face whatever the crown. Its history is over `[body]`: the face is +/// new, its edges the body's own. +pub fn patch_edges_crowned(body: &Body, at: &[Vec3], crown: f64) -> Result { + if crown == 0.0 { + return crate::surfaces::patch_edges(body, at); + } + body.require_brep("patch")?; + if at.is_empty() || at.len() > 10_000 { + return Err(KernelError::Invalid("pick the edges of a closed loop".into())); + } + // A range test also throws out NaN. + if !(solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(&crown) { + return Err(KernelError::Invalid("a patch's crown must be between -0.9 and 3".into())); + } + crate::guard("patch", || { + let chain = crate::surfaces::picked_loop(body, at)?; + let Some(face) = crate::plane_fit::planar_face(&chain, planarity_tolerance(body))? else { + // Not certainly planar: a fitted surface, crowned. + return fill_loop_crowned(body, &chain, crown); + }; + // A new face on the body's own edges (a plane has no middle to fill). + let mut rec = Rebuild::default(); + rec.made(&face, Vec::new()); + Ok(rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec)) + }) +} + /// How far a loop's edges may be from a plane for its patch to be that plane: `10⁻⁷` of the /// body's size, at least the linear tolerance (`10⁻⁶` mm) — the body's own "same point" /// tolerance, since a planar face is exact and other operations treat it as containing its @@ -71,7 +109,7 @@ mod tests { use crate::body::{Body, from_p3, p3}; use crate::boolean_record::tests::{Reach, checked}; use crate::history::{Change, EdgeOrigin}; - use crate::{BoolOp, boolean, copy_faces, cylinder, extrude, measure, patch_edges, stitch}; + use crate::{BoolOp, boolean, copy_faces, cylinder, extrude, measure, patch_edges, patch_edges_crowned, stitch}; /// The cutting cylinder: axis along y at x = 0, z = 50, radius 40, so the top of a prism /// within 20 of the axis and 20 high is cut away everywhere (to between 10 and 15.4 mm). @@ -524,4 +562,52 @@ mod tests { assert!(patch_edges(&open, &[Vec3::new(1e300, 0.0, 0.0)]).is_err()); assert!(patch_edges(&open, &vec![rim[0]; 20_000]).is_err()); } + + /// A crowned patch: the middle is fuller by `crown` times the loop's size, the loop's own + /// edges stay exactly where they were (the face still meets them within [`face_tolerance`]), + /// and a planar loop is the same exact plane whatever the crown. + #[test] + fn a_crowned_patch_fills_its_middle_and_keeps_its_edges() { + let (_, open, rim) = cup(®ular(5, 0.3), Vec2::ZERO); + let plain = patch_edges(&open, &rim).unwrap(); + let same = patch_edges_crowned(&open, &rim, 0.0).unwrap(); + // Crown 0 is the plain patch: the same surface, to the last bit. + assert_eq!(measure(&plain).unwrap().area, measure(&same).unwrap().area, "crown 0 is patch_edges"); + for crown in [0.25, -0.25] { + let full = patch_edges_crowned(&open, &rim, crown).unwrap(); + assert_eq!(full.face_count(), 1, "one face"); + let face = full.solid.face_iter().next().unwrap().clone(); + assert!(!matches!(face.surface(), mt::Surface::Plane(_))); + let own: Vec = open.solid.edge_iter().map(|e| e.id()).collect(); + assert!(full.solid.edge_iter().all(|e| own.contains(&e.id())), "an edge that is not the body's"); + // The loop's edges are within the fill's tolerance of the face, as for the plain fill. + let off = edges_off_surface(&face); + assert!(off <= super::face_tolerance(&open), "an edge is {off} off the crowned face"); + mesh_is_clean(&full); + // The middle of the loop's box moved by crown × its diagonal (the bump is 1 there). + let a = plain.solid.face_iter().next().unwrap().surface(); + let b = face.surface(); + let (Some((bu0, bu1)), Some((bv0, bv1))) = a.try_range_tuple() else { panic!("a bounded surface") }; + let (uc, vc) = ((bu0 + bu1) / 2.0, (bv0 + bv1) / 2.0); + let size = (bu1 - bu0).hypot(bv1 - bv0); + let bump = a.subs(uc, vc).distance(b.subs(uc, vc)); + let want = (crown * size).abs(); + eprintln!("crown {crown}: the middle moved {bump:.6}, wanted {want:.6}"); + assert!((bump - want).abs() < 1e-6 * size, "middle {bump} against {want}"); + } + // A planar loop: the exact plane, crowned or not (the crown has no middle to fill). + let boxed = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let open = copy_faces( + &boxed, + &[Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 0.0, 5.0), Vec3::new(10.0, 5.0, 5.0), Vec3::new(5.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)], + ) + .unwrap(); + let rim = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)]; + let lid = patch_edges_crowned(&open, &rim, 1.0).unwrap(); + assert!(matches!(lid.solid.face_iter().next().unwrap().surface(), mt::Surface::Plane(_)), "a planar loop is a plane"); + // Hostile crowns are refused before anything is built. + for crown in [f64::NAN, f64::INFINITY, 3.01, -0.91, 1e300] { + assert!(patch_edges_crowned(&open, &rim, crown).is_err(), "crown {crown}"); + } + } } diff --git a/crates/kernel/src/intersection_curve_tests.rs b/crates/kernel/src/intersection_curve_tests.rs index c1d8bef1..c4eee490 100644 --- a/crates/kernel/src/intersection_curve_tests.rs +++ b/crates/kernel/src/intersection_curve_tests.rs @@ -58,3 +58,59 @@ fn open_intersection_keeps_endpoints_on_both_planes() { assert_eq!(curve.subs(hi), end); assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0)); } + +/// A triangular face whose first edge is an intersection curve of two planes: the parallel planes +/// `z = 0` and `z = 1` when `support_gap` is `1.0` (the projection onto both never converges), the +/// same plane twice when it is `0.0` (the leader lies on both and always evaluates). +fn triangle_with_intersection_edge(support_gap: f64) -> truck_modeling::Shell { + use truck_modeling::{Curve, Edge, Face, IntersectionCurve, Leader, Line, Shell, Surface, Vertex, Wire}; + let plane = |z: f64| Surface::Plane(Plane::new(Point3::new(0.0, 0.0, z), Point3::new(1.0, 0.0, z), Point3::new(0.0, 1.0, z))); + let (v0, v1, v2) = (Vertex::new(Point3::new(0.0, 0.0, 0.5)), Vertex::new(Point3::new(1.0, 0.0, 0.5)), Vertex::new(Point3::new(0.5, 1.0, 0.5))); + let leader = Leader::Polyline(PolylineCurve(vec![Point3::new(0.0, 0.0, 0.5), Point3::new(1.0, 0.0, 0.5)])); + let bad = IntersectionCurve::new_unchecked(Box::new(plane(0.5 - support_gap / 2.0)), Box::new(plane(0.5 + support_gap / 2.0)), leader, 0.01); + let line = |a: &Vertex, b: &Vertex| Edge::new(a, b, Curve::Line(Line(a.point(), b.point()))); + let wire = Wire::from(vec![Edge::new(&v0, &v1, Curve::IntersectionCurve(bad)), line(&v1, &v2), line(&v2, &v0)]); + Shell::from(vec![Face::new(vec![wire], plane(0.5))]) +} + +/// Meshing an edge whose intersection curve does not evaluate (#1252): `IntersectionCurve::subs` +/// unwraps a failed projection, and the mesher sampled the edge through it. The two supports here +/// are parallel planes, so the projection never converges. Nothing may panic (wasm aborts on one, +/// and the native guard prints the message first) and the mesh stays finite. +#[test] +fn meshing_an_edge_that_does_not_evaluate_neither_panics_nor_goes_non_finite() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use truck_meshalgo::prelude::RobustMeshableShape; + let shell = triangle_with_intersection_edge(1.0); + + let me = std::thread::current().id(); + let count = Arc::new(AtomicUsize::new(0)); + let c2 = count.clone(); + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |_| { + if std::thread::current().id() == me { + c2.fetch_add(1, Ordering::SeqCst); + } + })); + let meshed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shell.robust_triangulation(0.01))); + std::panic::set_hook(prev); + assert_eq!(count.load(Ordering::SeqCst), 0, "panics while meshing"); + let meshed = meshed.unwrap(); + for face in meshed.face_iter() { + if let Some(mesh) = face.surface() { + assert!(mesh.positions().iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())); + } + } +} + +/// A boolean's result with an edge that does not evaluate is refused with an error (#1252). The +/// mesher's panic on such an edge used to reject the attempt; now that it no longer panics, the +/// result must be refused explicitly or a plausible-looking mesh would let it through. +#[test] +fn a_result_with_an_edge_that_does_not_evaluate_is_refused() { + use truck_modeling::Solid; + let solid = |gap: f64| Solid::new_unchecked(vec![triangle_with_intersection_edge(gap)]); + assert!(!crate::boolean_record::edges_evaluate(&solid(1.0))); + assert!(crate::boolean_record::edges_evaluate(&solid(0.0))); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 0eaf370c..46c4a087 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -17,11 +17,14 @@ mod blend_corner; mod blend_fit; mod blend_variable; mod blend_vertex; +#[cfg(test)] +mod blend_wrong_way_tests; mod body; mod boolean_diagnostics; mod boolean_guard; mod boolean_record; mod bore_guard; +mod bridge; mod build; mod clearance; mod coplanar; @@ -117,12 +120,14 @@ pub use blend_corner::fillet_radii; pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics}; +pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, sample_grid}; pub use build::{ PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, sweep_with, torus, }; pub use delete_face::delete_faces; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; +pub use fill::patch_edges_crowned; pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; @@ -140,10 +145,12 @@ pub use prism_measure::certified_prism_equivalent; pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching}; pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map}; pub use step::step_export; -pub use step_in::{ImportedBody, StepImport, StepNode, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate}; +pub use step_in::{ + ImportedBody, StepImport, StepNode, StepReading, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate, +}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_extend::{ExtendLimit, ExtendMode, ExtendOptions, extend_surface}; -pub use surface_kind::{FaceSurface, face_surface}; +pub use surface_kind::{EdgeCircle, FaceSurface, edge_circle, face_surface}; pub use surface_offset::offset_surface; pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface}; pub use surface_untrim::{UntrimHoles, untrim}; diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index 8e5113a5..ae15a750 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -1120,9 +1120,10 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul /// Draft walls parallel to `pull` (planes, and cylinders about axes along `pull`) by `angle` /// about their line on the neutral plane: at height h along `pull` a wall moves along its -/// outward normal by −h·tan(angle), so planes tilt and cylinders become cones. The other -/// faces must be caps square to `pull`; the topology is kept, and the result's history is over -/// `[b]` (each face and edge the one it was moved from). +/// outward normal by −h·tan(angle), so planes tilt and cylinders become cones. The faces that +/// share a corner with a drafted wall must be caps square to `pull` or walls; the rest of the +/// body (a rounded edge elsewhere, say) is left as it is. The topology is kept, and the +/// result's history is over `[b]` (each face and edge the one it was moved from). pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom::Plane, pull: Vec3, angle: f64) -> Result { let faces: Vec = b.solid.face_iter().cloned().collect(); let size = b.size(); @@ -1134,37 +1135,6 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom: } let height = |p: Vec3| (p - neutral.origin).dot(nn) / den; let tan = angle.tan(); - let mut old = Vec::with_capacity(faces.len()); - for (i, f) in faces.iter().enumerate() { - let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("draft: only bodies with planar and cylindrical faces"))?; - let wall = match sf { - Surf::Plane { n, .. } => n.dot(pull).abs() < 1e-9, - Surf::Cylinder { a, .. } => a.dot(pull).abs() > 1.0 - 1e-9, - Surf::Sphere { .. } | Surf::Cone { .. } | Surf::Torus { .. } => false, - }; - let cap = matches!(sf, Surf::Plane { n, .. } if n.dot(pull).abs() > 1.0 - 1e-9); - if chosen.contains(&i) && !wall { - return Err(fail("draft: a chosen face isn't parallel to the pull direction")); - } - if !chosen.contains(&i) && !cap && !wall { - return Err(fail("draft: faces next to the drafted walls must be square to the pull direction")); - } - old.push(sf); - } - // Shifts and moved surfaces at a height. - let at = |ids: &[usize], h: f64| -> Option<(Vec, Vec, Vec)> { - let mut o = Vec::new(); - let mut s = Vec::new(); - let mut n = Vec::new(); - for i in ids { - let sf = *old.get(*i)?; - let sh = if chosen.contains(i) { -h * tan } else { 0.0 }; - o.push(sf); - s.push(sh); - n.push(sf.moved(sh)?); - } - Some((o, s, n)) - }; let mut vfaces: HashMap> = HashMap::new(); let mut efaces: HashMap> = HashMap::new(); for (i, f) in faces.iter().enumerate() { @@ -1181,9 +1151,53 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom: } } } + // A corner moves when one of its faces is drafted; the faces round a moving corner must be + // walls or caps for the corner to follow them. Faces away from the drafted walls may be + // anything: they keep their surface and their edges. + let moves = |vid: &mt::VertexID| vfaces.get(vid).is_some_and(|fs| fs.iter().any(|i| chosen.contains(i))); + let touched: Vec = faces.iter().enumerate().map(|(i, f)| chosen.contains(&i) || f.vertex_iter().any(|v| moves(&v.id()))).collect(); + let mut old: Vec> = Vec::with_capacity(faces.len()); + for (i, f) in faces.iter().enumerate() { + if !touched.get(i).copied().unwrap_or(false) { + old.push(None); + continue; + } + let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("draft: the faces next to the drafted walls must be planes or cylinders"))?; + let wall = match sf { + Surf::Plane { n, .. } => n.dot(pull).abs() < 1e-9, + Surf::Cylinder { a, .. } => a.dot(pull).abs() > 1.0 - 1e-9, + Surf::Sphere { .. } | Surf::Cone { .. } | Surf::Torus { .. } => false, + }; + let cap = matches!(sf, Surf::Plane { n, .. } if n.dot(pull).abs() > 1.0 - 1e-9); + if chosen.contains(&i) && !wall { + return Err(fail("draft: a chosen face isn't parallel to the pull direction")); + } + if !chosen.contains(&i) && !cap && !wall { + return Err(fail("draft: faces next to the drafted walls must be square to the pull direction")); + } + old.push(Some(sf)); + } + // Shifts and moved surfaces at a height. + let at = |ids: &[usize], h: f64| -> Option<(Vec, Vec, Vec)> { + let mut o = Vec::new(); + let mut s = Vec::new(); + let mut n = Vec::new(); + for i in ids { + let sf = (*old.get(*i)?)?; + let sh = if chosen.contains(i) { -h * tan } else { 0.0 }; + o.push(sf); + s.push(sh); + n.push(sf.moved(sh)?); + } + Some((o, s, n)) + }; let mut newpos: HashMap = HashMap::new(); for v in b.solid.vertex_iter() { let p = from_p3(v.point()); + if !moves(&v.id()) { + newpos.insert(v.id(), p); + continue; + } let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?; let (o, s, n) = at(fs, height(p)).ok_or_else(|| fail("draft: a wall shrinks to nothing"))?; let q = move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("draft: a corner whose faces don't meet the same way after the draft"))?; @@ -1192,10 +1206,8 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom: // New surfaces for the drafted walls: planes through their hinge line, cylinders → cones. let mut surfaces: Vec> = vec![None; faces.len()]; for (i, sf) in old.iter().enumerate() { - if !chosen.contains(&i) { - continue; - } - let surface = match *sf { + let (true, Some(sf)) = (chosen.contains(&i), *sf) else { continue }; + let surface = match sf { Surf::Plane { n, d } => { let n2 = (n * angle.cos() + pull * angle.sin()).normalized().ok_or_else(|| fail("draft"))?; // A point of the old plane on the neutral plane: the hinge. @@ -1248,7 +1260,14 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom: continue; } let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) }; - let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("draft: edges must be lines or arcs"))?; + // An edge whose ends stay keeps its curve (it may be anything: a blend's spline). + if !moves(&e.absolute_front().id()) && !moves(&e.absolute_back().id()) { + let ne = mt::Edge::try_new(a, c, e.curve().clone()).map_err(|e| fail(&e.to_string()))?; + rec.piece(&ne, &e); + edges.insert(e.id(), ne); + continue; + } + let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("draft: edges next to the drafted walls must be lines or arcs"))?; let ne = match kind { None => builder::line(a, c), Some(m) => { @@ -1277,7 +1296,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom: Some(mut surface) => { // Outward like the old face (checked at a boundary point). let probe = f.boundaries().first().and_then(|w| w.vertex_iter().next()).map(|v| from_p3(v.point())); - let want = probe.and_then(|p| old.get(i).and_then(|s| s.normal(p))); + let want = probe.and_then(|p| old.get(i).copied().flatten().and_then(|s| s.normal(p))); if let (Some(p), Some(want)) = (probe, want) { use mt::{ParametricSurface3D, SearchNearestParameter}; if let Some((u, v)) = surface.search_nearest_parameter(p3(p), None, 100) { diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 8a5fdca3..a78e73e9 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -500,15 +500,11 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // A tool that misses its target, or covers it whole, leaves it as it is, and the // sampled shortcut below answers that by reusing the operand itself: the very same body. // The 2D route rebuilds it instead - the same shape on new faces - which would turn an - // untouched body in a timeline into a different solid. Those pairs go back to the path - // below, which decides them, and the identity they keep, exactly as it always has. - let unchanged = 1e-4 * (va0 + vb0); - let rebuilt_from_an_operand = match op { - BoolOp::Cut => v0 >= va0 - unchanged, - BoolOp::Intersect => (v0 - va0).abs() <= unchanged || (v0 - vb0).abs() <= unchanged, - BoolOp::Union => false, - }; - if !rebuilt_from_an_operand && plausible(v0, op, va0, vb0, slack0) { + // untouched body in a timeline into a different solid. The route declines those pairs + // itself, from its exact sections (a volume measured off meshes could not tell an + // engraving of thin strokes from no change), and they take the path below, which + // decides them, and the identity they keep, exactly as it always has. + if plausible(v0, op, va0, vb0, slack0) { return Ok(Some(body.clone())); } } @@ -733,6 +729,19 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { None }; for (attempt, j) in JITTER.iter().enumerate() { + // The operands as they lie failed. A planar tool beside the target's curved faces (a tooth + // or a pocket cut next to a round edge) is decided exactly in the slab it lies in, where + // the target is planar too, before the shifted tries: that is where they end anyway, as a + // retry, after costing several times as much (on every copy of a pattern, #1197). + if attempt == 1 && matches!(op, BoolOp::Cut | BoolOp::Intersect) { + let (sliced, diagnostics) = capture_boolean_diagnostics(|| crate::polybool::sliced_planar_boolean(a, b, op)); + if let Some(Ok(Some(body))) = sliced + && check(&body).0 + { + merge(diagnostics); + return Ok(Some(body)); + } + } let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); for k in [2e-3, 5e-4] { let tol = (size * k).max(1e-5); diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index e9c376f8..970205dc 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -431,7 +431,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { /// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round /// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the /// pieces outside are joined back on. `None` when no such slab exists. -fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { +pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { let pb = planar_polys(b, 1).ok()?; let size = a.size().max(b.size()); let tol = (a.size() * 1e-3).max(1e-3); @@ -447,6 +447,10 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option Option Option Option<(Vec, Traced)> { + let parts = crate::splitface::split_body(&piece.0, &crate::splitface::SplitTool::Plane(*plane)).ok()?; + if parts.len() < 2 { + return None; + } + let tol = piece.0.size() * 1e-6 + 1e-9; + let (mut away, mut kept) = (Vec::new(), Vec::new()); + for part in parts { + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for v in part.solid.vertex_iter() { + let t = from_p3(v.point()).dot(axis) - x; + lo = lo.min(t); + hi = hi.max(t); + } + let h = match (part.history(), piece.1.as_ref()) { + (Some(hp), Some(h)) => recorded::composed(hp.clone().substituted(0, h)), + _ => None, + }; + let positive = if lo >= -tol { + true + } else if hi <= tol { + false + } else { + return None; + }; + if positive == keep_positive { kept.push((part, h)) } else { away.push((part, h)) } + } + let kept = kept.pop().filter(|_| kept.is_empty())?; + (!away.is_empty()).then_some((away, kept)) +} + /// `h` over `[a, b]`: the operands after them (the boxes a piece was cut with) dropped, which /// fails where something of them is left. fn dropped(h: Option) -> Option { diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 17fe0539..7e5642c7 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -17,6 +17,10 @@ use crate::{BoolOp, KernelError, Result}; #[path = "prism_lineage.rs"] mod lineage; +#[cfg(test)] +#[path = "prism_spline_tests.rs"] +mod spline_tests; + const MAX_PLANAR_SEGMENTS: usize = 8192; const MAX_PLANAR_EVENTS: usize = 131_072; const MAX_PLANAR_PAIRS: usize = 16_000_000; @@ -216,10 +220,13 @@ pub(crate) struct Tag { pub far: Option, pub start: Option, pub end: Option, + /// Inside [`boolean2d_tagged`] only: the free-form segment (by number) the arc this tags + /// was fitted to, so the result's pieces of it get the exact curve back. + pub free: Option, } /// A segment nothing is known of. -const BLANK: Tag = Tag { operand: 0, wall: None, edge: None, far: None, start: None, end: None }; +const BLANK: Tag = Tag { operand: 0, wall: None, edge: None, far: None, start: None, end: None, free: None }; impl Tag { /// The segment run the other way. @@ -475,6 +482,7 @@ impl Index { far: None, start: self.vertices.get(&e.front().id()).copied(), end: self.vertices.get(&e.back().id()).copied(), + free: None, } } @@ -1055,8 +1063,333 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option Option
{ + let line_arc = |s: &Section| s.regions.iter().all(Region2::is_line_arc); + if line_arc(a) && line_arc(b) { + return boolean2d_arcs(a, b, op, tol); + } + let mut sources = Vec::new(); + let (fa, fb) = (fitted(a, &mut sources, PLANAR_FIT_TOL)?, fitted(b, &mut sources, PLANAR_FIT_TOL)?); + let out = boolean2d_arcs(&fa, &fb, op, tol)?; + Some(exact_again(out, &sources, tol)) +} + +/// `s` with each free-form segment replaced by lines and arcs within `fit`, tagged with its +/// number in `sources` (where it is put). +fn fitted(s: &Section, sources: &mut Vec, fit: f64) -> Option
{ + let mut out = Section::default(); + let mut pieces = 0usize; + for (r, reg) in s.regions.iter().enumerate() { + let mut loops = Vec::new(); + let mut tags = Vec::new(); + for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + let (mut segs, mut ts) = (Vec::new(), Vec::new()); + for (i, seg) in lp.segs.iter().enumerate() { + let tag = s.tag(r, l, i); + if seg.is_line_or_arc() { + segs.push(*seg); + ts.push(tag); + continue; + } + let arcs = seg.fit_arcs(fit)?; + pieces = pieces.checked_add(arcs.len()).filter(|n| *n <= MAX_PLANAR_SEGMENTS)?; + let n = arcs.len(); + ts.extend((0..n).map(|k| Tag { free: Some(sources.len()), ..tag.piece(k, n) })); + segs.extend(arcs); + sources.push(*seg); + } + loops.push(Loop2 { segs }); + tags.push(ts); + } + let mut loops = loops.into_iter(); + out.regions.push(Region2 { outer: loops.next()?, holes: loops.collect() }); + out.tags.push(tags); + } + Some(out) +} + +/// A loop's piece in [`exact_again`]: a run of arcs fitted to free-form segment `src`, standing +/// for its exact curve from parameter `t0` to `t1` (`t1 < t0`: run backwards), from `a` to `b`; +/// or a line or arc as it is. +#[derive(Clone, Copy)] +enum Piece { + Exact { src: usize, t0: f64, t1: f64, a: Vec2, b: Vec2, tag: Tag }, + Plain(Seg2, Tag), +} + +impl Piece { + fn end(&self) -> Vec2 { + match self { + Piece::Exact { b, .. } => *b, + Piece::Plain(s, _) => s.end(), + } + } + + /// The piece ending at `at` (an exact curve: at its parameter `t`, when given). + fn set_end(&mut self, t: Option, at: Vec2) { + match self { + Piece::Exact { t1, b, .. } => { + if let Some(t) = t { + *t1 = t; + } + *b = at; + } + Piece::Plain(s, _) => *s = seg_with_ends(s, s.start(), at), + } + } + + /// The piece starting at `at` (an exact curve: at its parameter `t`, when given). + fn set_start(&mut self, t: Option, at: Vec2) { + match self { + Piece::Exact { t0, a, .. } => { + if let Some(t) = t { + *t0 = t; + } + *a = at; + } + Piece::Plain(s, _) => *s = seg_with_ends(s, at, s.end()), + } + } +} + +/// `s` running from `a` to `b` instead (points on it or a hair off it): a line between them, an +/// arc of the same circle, a free-form segment with those end control points. +fn seg_with_ends(s: &Seg2, a: Vec2, b: Vec2) -> Seg2 { + match *s { + Seg2::Line { .. } => Seg2::Line { a, b }, + Seg2::Arc { center, radius, start, sweep } => { + let new_start = (a - center).angle(); + let end = start + sweep; + // The same way round, through the same part of the circle. + let shift = |x: f64, near: f64| x + ((near - x) / std::f64::consts::TAU).round() * std::f64::consts::TAU; + let s0 = shift(new_start, start); + let s1 = shift((b - center).angle(), end); + Seg2::Arc { center, radius, start: s0, sweep: s1 - s0 } + } + Seg2::Cubic { p1, p2, .. } => Seg2::Cubic { p0: a, p1, p2, p3: b }, + Seg2::Conic { apex, w, .. } => Seg2::Conic { a, apex, b, w }, + Seg2::Bezier { n, mut p } => { + if let Some(x) = p.first_mut() { + *x = a; + } + if let Some(x) = p.get_mut(n as usize) { + *x = b; + } + Seg2::Bezier { n, p } + } + } +} + +/// The exact piece of `src` from parameter `t0` to `t1` (either way round), from `a` to `b`. +fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 { + let (lo, hi) = (t0.min(t1), t0.max(t1)); + let mut s = *src; + if lo > 1e-12 { + s = s.split_at(lo).1; + } + if hi < 1.0 - 1e-12 { + // Splitting a conic renormalises its weights, which moves its parameters: the second + // split is found on the piece. + let at = s.closest_param(src.point_at(hi)); + if at < 1.0 - 1e-12 { + s = s.split_at(at).0; + } + } + if t0 <= t1 { seg_with_ends(&s, a, b) } else { seg_with_ends(&s.reversed(), a, b) } +} + +/// Where curve `p` near its parameter `t` meets `q` near its parameter `u`, by Newton's method: +/// their parameters there, or `None` when the crossing doesn't settle within `near` of `p(t)`. +fn meet(p: &Seg2, t: f64, q: &Seg2, u: f64, near: f64) -> Option<(f64, f64)> { + let (mut t, mut u) = (t, u); + let start = p.point_at(t); + let scale = 1.0 + start.x.abs().max(start.y.abs()); + for _ in 0..50 { + let f = p.point_at(t) - q.point_at(u); + if f.len() < 1e-14 * scale { + break; + } + // P(t) - Q(u) = f; the step solves [P'(t), -Q'(u)] (dt, du) = -f. + let (dp, dq) = (p.derivative(t), q.derivative(u)); + let det = -dp.cross(dq); + if !(det.abs() > 1e-300 && det.is_finite()) { + return None; + } + t = (t + f.cross(dq) / det).clamp(0.0, 1.0); + u = (u + f.cross(dp) / det).clamp(0.0, 1.0); + } + let (x, y) = (p.point_at(t), q.point_at(u)); + (x.dist(y) < 1e-9 * scale && x.dist(start) < near).then_some((t, u)) +} + +/// The whole line or circle a line or arc lies on (as a segment reaching past it), what an exact +/// crossing with a curve is looked for on. +fn carrier(s: &Seg2) -> Option { + match *s { + Seg2::Line { a, b } => Some(Seg2::Line { a: a - (b - a), b: b + (b - a) }), + Seg2::Arc { center, radius, start, sweep } => { + let turn = sweep.signum() * std::f64::consts::PI; + Some(Seg2::Arc { center, radius, start: start + sweep * 0.5 - turn, sweep: 2.0 * turn }) + } + _ => None, + } +} + +/// The exact curves back in the result of a boolean of [`fitted`] sections: each run of arcs +/// fitted to one free-form segment becomes that segment's exact piece, and where a run ends on +/// another segment (a cut across the curve) both end at their exact crossing. Where that +/// crossing can't be found they end where the arrangement put them, within +/// [`PLANAR_FIT_TOL`] of it. +fn exact_again(out: Section, sources: &[Seg2], tol: f64) -> Section { + let near = (PLANAR_FIT_TOL + tol) * 10.0; + let mut res = Section::default(); + for (r, reg) in out.regions.iter().enumerate() { + let tags = out.region_tags(r); + let mut loops = Vec::new(); + let mut loop_tags = Vec::new(); + for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { + let (segs, ts) = exact_loop(lp, tags.get(l).map(Vec::as_slice).unwrap_or(&[]), sources, near); + loops.push(Loop2 { segs }); + loop_tags.push(ts); + } + let mut loops = loops.into_iter(); + let Some(outer) = loops.next() else { continue }; + res.regions.push(Region2 { outer, holes: loops.collect() }); + res.tags.push(loop_tags); + } + res +} + +/// One loop of [`exact_again`]: its segments and their tags. +fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec, Vec) { + let n = lp.segs.len(); + let tag = |i: usize| Tag { free: None, ..tags.get(i).copied().unwrap_or(BLANK) }; + let free = |i: usize| tags.get(i).and_then(|t| t.free); + let plain = || (lp.segs.clone(), (0..n).map(tag).collect::>()); + // Start at the first segment of a run (a loop that is one run stays as it is). + let Some(first) = (0..n).find(|&i| free(i).is_none() || free(i) != free((i + n - 1) % n)) else { return plain() }; + let mut pieces: Vec = Vec::new(); + let mut k = 0; + while k < n { + let i = (first + k) % n; + let Some(seg) = lp.segs.get(i) else { return plain() }; + let Some((f, src)) = free(i).and_then(|f| sources.get(f).map(|s| (f, s))) else { + pieces.push(Piece::Plain(*seg, tag(i))); + k += 1; + continue; + }; + let mut j = k; + while j + 1 < n && free((first + j + 1) % n) == Some(f) { + j += 1; + } + let Some(last) = lp.segs.get((first + j) % n) else { return plain() }; + // A run reaching the curve's end is the curve to its end, exactly: the same curve the + // walls over it are read from, which a parameter found by projection (a hair short of 1) + // would cut short into a different one. + let at = |p: Vec2| { + if p.dist(src.start()) < near { + 0.0 + } else if p.dist(src.end()) < near { + 1.0 + } else { + src.closest_param(p) + } + }; + let (t0, t1) = (at(seg.start()), at(last.end())); + if (t1 - t0).abs() < 1e-9 { + // A sliver of the curve: its arcs as they are. + for m in k..=j { + let q = (first + m) % n; + pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q))); + } + } else { + let t = Tag { end: tag((first + j) % n).end, ..tag(i) }; + pieces.push(Piece::Exact { src: f, t0, t1, a: src.point_at(t0), b: src.point_at(t1), tag: t }); + } + k = j + 1; + } + // Each joint at an exact curve: both sides end at the same point, the exact crossing where + // the curve is cut, its own end point where it ends. + let m = pieces.len(); + let inner = |t: f64| t > 1e-9 && t < 1.0 - 1e-9; + for i in 0..m { + let (Some(p), Some(q)) = (pieces.get(i).copied(), pieces.get((i + 1) % m).copied()) else { continue }; + let joint = p.end(); + let src = |f: usize| sources.get(f); + let found = match (p, q) { + (Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => { + src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a)))) + } + (Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => { + src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))) + } + (Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => { + src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))) + } + _ => None, + }; + // No crossing to refine (or none found): both end where the curve does. + let found = found.or(match (p, q) { + (Piece::Exact { b, .. }, _) => Some((None, None, b)), + (_, Piece::Exact { a, .. }) => Some((None, None, a)), + _ => None, + }); + let Some((x, y, at)) = found else { continue }; + if let Some(p) = pieces.get_mut(i) { + p.set_end(x, at); + } + if let Some(q) = pieces.get_mut((i + 1) % m) { + q.set_start(y, at); + } + } + let mut segs = Vec::with_capacity(m); + let mut ts = Vec::with_capacity(m); + for p in pieces { + match p { + Piece::Exact { src, t0, t1, a, b, tag } => match sources.get(src) { + Some(s) => { + segs.push(trimmed(s, t0, t1, a, b)); + ts.push(tag); + } + None => return plain(), + }, + Piece::Plain(s, t) => { + segs.push(s); + ts.push(t); + } + } + } + // The loop closed exactly: where a segment ends a hair from where the next starts (a font's + // nearly round curve read as an arc ends a few hundredths of a micron off the curve after + // it), the one that is not an arc moves its end onto the arc's (an arc's ends stay on its + // circle). Two arcs are left as they meet, as a boolean of lines and arcs leaves them. + let m = segs.len(); + for i in 0..m { + let j = (i + 1) % m; + let (Some(x), Some(y)) = (segs.get(i).copied(), segs.get(j).copied()) else { continue }; + let gap = x.end().dist(y.start()); + if gap == 0.0 || gap > near { + continue; + } + let arc = |s: &Seg2| matches!(s, Seg2::Arc { .. }); + if !arc(&y) { + if let Some(s) = segs.get_mut(j) { + *s = seg_with_ends(&y, x.end(), y.end()); + } + } else if !arc(&x) + && let Some(s) = segs.get_mut(i) + { + *s = seg_with_ends(&x, x.start(), y.start()); + } + } + (segs, ts) +} + +/// The boolean of sections of lines and arcs alone ([`boolean2d_tagged`]). +fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option
{ let count = |rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len())); let (na, nb) = (count(&a.regions), count(&b.regions)); @@ -1347,9 +1680,6 @@ fn simple_loops(lp: Loop2, tags: Vec, near: f64) -> Vec<(Loop2, Vec)> } fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { - if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { - return None; - } let mut ts: Vec = pts .iter() .filter_map(|p| { @@ -1372,7 +1702,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { out.push(match *seg { Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) }, Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) }, - _ => return None, + _ => trimmed(seg, t0, t1, seg.point_at(t0), seg.point_at(t1)), }); t0 = t1; } @@ -1510,6 +1840,23 @@ fn wall_key(s: &Seg2) -> Option<[u64; 6]> { match s { Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]), Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]), + // Its ends and a point between them: free-form segments with the same ones are the same + // curve as far as a wall over it goes. + _ => { + let (a, m, b) = (s.start(), s.point_at(0.5), s.end()); + let h = |x: f64, y: f64| bits(x).zip(bits(y)).map(|(x, y)| x ^ y.rotate_left(17)); + Some([2, h(a.x, a.y)?, h(m.x, m.y)?, h(b.x, b.y)?, bits(m.x)?, bits(m.y)?]) + } + } +} + +/// A free-form segment as the control points of the cubic Bézier it is (a quadratic raised to +/// a cubic); `None` for those no cubic is exactly (rational conics, higher degrees). +fn cubic_of(s: &Seg2) -> Option<[Vec2; 4]> { + match *s { + Seg2::Cubic { p0, p1, p2, p3 } => Some([p0, p1, p2, p3]), + Seg2::Conic { a, apex, b, w } if (w - 1.0).abs() < 1e-12 => Some([a, a + (apex - a) * (2.0 / 3.0), b + (apex - b) * (2.0 / 3.0), b]), + Seg2::Bezier { n: 3, p } => Some([p[0], p[1], p[2], p[3]]), _ => None, } } @@ -1551,12 +1898,68 @@ fn sew_connected_sections( pts.push(sg.end()); } } - let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; - let edge = |sg: &Seg2, z: f64| -> EdgeSpec { - match sg { - Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) }, - _ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) }, + // Ends within `tol` of each other are one point. The 2D boolean closes its loops within + // `tol`, not exactly: where a font's nearly round curve was read as an arc, the arc ends a + // few hundredths of a micron from the curve next to it. Sewing joins only ends at the same + // point, so such a loop would not close. + let cell = |u: Vec2| ((u.x / tol).floor() as i64, (u.y / tol).floor() as i64); + let mut ends: Vec = Vec::new(); + let mut grid: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); + let weld = |ends: &[Vec2], grid: &std::collections::HashMap<(i64, i64), Vec>, u: Vec2| -> Option { + let (cx, cy) = cell(u); + (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|c| grid.get(&c)) + .flatten() + .filter_map(|&i| ends.get(i).copied()) + .find(|e| e.dist(u) <= tol) + }; + for &u in &pts { + if weld(&ends, &grid, u).is_none() { + grid.entry(cell(u)).or_default().push(ends.len()); + ends.push(u); } + } + // A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from + // its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs + // are left as they were: such a piece of one fails the sewing, as before.) + let short = |s: &Seg2| { + !s.is_line_or_arc() + && s.length() <= 2.0 * tol + && matches!((weld(&ends, &grid, s.start()), weld(&ends, &grid, s.end())), (Some(p), Some(q)) if p == q) + }; + // One curve between two ends, as the walls and the caps beside them each read it: their + // sections come from different booleans, which split it from different pieces, so the + // control points can differ by a hair, and sewing tells edges apart by them. The first + // reading stands for all of them. + let chains: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + let same_chain = |c: [Vec3; 4]| -> [Vec3; 4] { + let mid = |k: &[Vec3; 4]| (k[0] + (k[1] + k[2]) * 3.0 + k[3]) * 0.125; + let mut known = chains.borrow_mut(); + for k in known.iter() { + if mid(k).dist(mid(&c)) <= tol * 10.0 { + if k[0] == c[0] && k[3] == c[3] { + return *k; + } + if k[0] == c[3] && k[3] == c[0] { + return [k[3], k[2], k[1], k[0]]; + } + } + } + known.push(c); + c + }; + let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; + let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z); + let edge = |sg: &Seg2, z: f64| -> Option { + Some(match sg { + Seg2::Line { .. } => EdgeSpec::Line { a: welded(sg.start(), z), b: welded(sg.end(), z) }, + Seg2::Arc { .. } => EdgeSpec::Arc { a: welded(sg.start(), z), b: welded(sg.end(), z), mid: at(sg.point_at(0.5), z) }, + _ => { + let [p0, p1, p2, p3] = cubic_of(sg)?; + EdgeSpec::Chain { ctrl: same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]).to_vec().into() } + } + }) }; // Exact oriented generated segments alone define a run. No support fitting, // proximity matching, or old face lineage is used here. @@ -1574,6 +1977,9 @@ fn sew_connected_sections( let pieces = split_seg(sg0, &pts, tol)?; let (n, tag) = (pieces.len(), slab.tag(r, l, si)); for (k, sg) in pieces.into_iter().enumerate() { + if short(&sg) { + continue; + } remaining = remaining.checked_sub(1)?; let key = wall_key(&sg)?; let prior = if coalesce { last.get(&key).copied() } else { None }; @@ -1622,20 +2028,22 @@ fn sew_connected_sections( let (z0, z1) = (levels[first], levels[end]); let (a, b) = (sg.start(), sg.end()); let mut lp3 = Vec::with_capacity(2 + 2 * (end - first)); - lp3.push(edge(&sg, z0)); + lp3.push(edge(&sg, z0)?); // Keep station vertices at both ends: neighboring stepped walls/caps // must share complete edges rather than create T-junctions. for k in first..end { - lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) }); + lp3.push(EdgeSpec::Line { a: welded(b, levels[k]), b: welded(b, levels[k + 1]) }); } - lp3.push(edge(&sg.reversed(), z1)); + lp3.push(edge(&sg.reversed(), z1)?); for k in (first..end).rev() { - lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) }); + lp3.push(EdgeSpec::Line { a: welded(a, levels[k + 1]), b: welded(a, levels[k]) }); } let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?; let surface = match sg { + Seg2::Line { .. } => SurfSpec::Plane, Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) }, - _ => SurfSpec::Plane, + // A free-form wall: the lines along `d` joining the curve at both levels. + _ => SurfSpec::Ruled { a: edge(&sg, z0)?, b: edge(&sg, z1)? }, }; faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) }); // Axis-aligned generated lines have a common oriented support frame @@ -1668,10 +2076,10 @@ fn sew_connected_sections( for lp in std::iter::once(&r.outer).chain(&r.holes) { let mut segs: Vec = Vec::new(); for sg in &lp.segs { - segs.extend(split_seg(sg, &pts, tol)?); + segs.extend(split_seg(sg, &pts, tol)?.into_iter().filter(|s| !short(s))); } let segs: Vec = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() }; - loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>()); + loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>>()?); } faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None }); sources.push(None); @@ -1700,24 +2108,9 @@ fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option Option>> { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; - // Candidate directions: a's planar face normals (each line once). - let mut dirs: Vec = Vec::new(); - let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2; - for f in a.solid.face_iter() { - remaining_direction_faces = remaining_direction_faces.checked_sub(1)?; - if let mt::Surface::Plane(p) = f.oriented_surface() { - let n = p.normal(); - if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() - && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) - { - dirs.push(if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }); - if dirs.len() == 8 { - break; - } - } - } - } - let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { + // Candidate directions (each line once, pointing up). + let dirs: Vec = directions(a, b)?.into_iter().map(|n| if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }).collect(); + let (d, plane, pa, pb) = dirs.into_iter().find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; let pa = prism_tagged(a, d, &plane, tol, ia)?; let pb = prism_tagged(b, d, &plane, tol, ib)?; @@ -1743,10 +2136,16 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2 if regions.is_empty() { return Some(Ok(None)); } + let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions)); + let alike = |v: f64| (u - v).abs() <= tol * (1.0 + u); + // A tool that misses its target (or an intersection that is one operand whole) leaves it + // as it is: the B-rep boolean reuses that operand itself, where this would rebuild it. + if matches!(op, BoolOp::Cut | BoolOp::Intersect) && (same_area(u, x, tol) || (op == BoolOp::Intersect && same_area(u, y, tol))) { + return None; + } let (lo, hi) = if stacked { // Different sections (a boss hanging over a plate's edge): sewn slab by slab. - let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions)); - if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) { + if !alike(x) || !alike(y) { return sew_stack(&plane, d, &pa, &pb, tol, &history).map(|r| r.map(Some)); } // One outline end to end: also slab by slab, so the history follows each operand's @@ -1784,6 +2183,46 @@ fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2 Some(Ok(Some(history.attach(last.0.clone(), &[last])))) } +/// The directions to try the operands as prisms along: the first eight plane directions of the +/// target (in its face order), then those of the tool's caps (a pair of opposite plane faces) +/// that the target has further on. A target cut many times over (a line of text, a group of +/// letters at a time) has more plane directions than eight, its walls' among them: in face +/// order its caps' direction can come too late. `None` past the face budget. +fn directions(a: &Body, b: &Body) -> Option> { + if a.face_count().saturating_add(b.face_count()) > MAX_PLANAR_SEGMENTS * 2 { + return None; + } + let normals = |x: &Body| -> Vec { + x.solid + .face_iter() + .filter_map(|f| match f.oriented_surface() { + mt::Surface::Plane(p) => { + let n = p.normal(); + Vec3::new(n.x, n.y, n.z).normalized() + } + _ => None, + }) + .collect() + }; + let mut target: Vec = Vec::new(); + for n in normals(a) { + if !target.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) { + target.push(n); + } + } + let tool = normals(b); + let capped = |d: &Vec3| tool.iter().any(|n| n.dot(*d) > 1.0 - 1e-9) && tool.iter().any(|n| n.dot(*d) < -1.0 + 1e-9); + let mut dirs: Vec = target.iter().take(8).copied().collect(); + dirs.extend(target.iter().skip(8).filter(|d| capped(d)).take(8)); + Some(dirs) +} + +/// Whether a boolean of sections left one of area `v` as it was (area `u` after): the same to +/// within rounding, as the very same outline gives (a change is at least a sliver `tol` wide). +fn same_area(u: f64, v: f64, tol: f64) -> bool { + (u - v).abs() <= 1e-9 * u.abs().max(v.abs()) + tol * tol +} + /// A stepped extrusion (case floor plus walls), recovered from its cap events. /// Downward caps add a section above their level; upward caps remove one. #[cfg(test)] @@ -1886,19 +2325,9 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.section.clone()).unwrap_or_default() } let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0; - let mut dirs: Vec = Vec::new(); - for f in a.solid.face_iter() { - if let mt::Surface::Plane(p) = f.oriented_surface() { - let n = p.normal(); - if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() - && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) - { - dirs.push(n); - } - } - } + let dirs = directions(a, b)?; let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); - let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { + let (d, plane, pa, pb) = dirs.into_iter().find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; let pa = stack_tagged(a, d, &plane, tol, ia)?; let pb = stack_tagged(b, d, &plane, tol, ib)?; @@ -1919,10 +2348,15 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm let mut slabs = Vec::new(); let mut work = 0usize; let mut output_edges = 0usize; + let area = + |rs: &[Region2]| rs.iter().map(|r| r.outer.signed_area().abs() - r.holes.iter().map(|h| h.signed_area().abs()).sum::()).sum::(); + // Whether some slab's section came out other than the target's (else the tool missed it). + let mut changed = false; let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::(); for w in levels.windows(2) { let (lo, hi) = (*w.first()?, *w.get(1)?); let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol)); + let before = area(&x.regions); if op == BoolOp::Cut { let count = edges(&x.regions).saturating_add(edges(&y.regions)); work = work.saturating_add(count.saturating_mul(count)); @@ -1936,6 +2370,7 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm (_, true, _) => x, _ => boolean2d_tagged(&x, &y, op, tol)?, }; + changed |= !same_area(area(&s.regions), before, tol); if op == BoolOp::Cut { output_edges = output_edges.saturating_add(edges(&s.regions)); if output_edges > 8192 { @@ -1947,6 +2382,11 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], comm if slabs.iter().all(|s| s.regions.is_empty()) { return Some(Ok(None)); } + // A cut that takes nothing leaves the target as it is: the B-rep boolean reuses it itself, + // where this would rebuild it. + if op == BoolOp::Cut && !changed { + return None; + } let history = lineage::Context::new(op, [a, b], [ia.get(), ib.get()], plane, tol, None); let coalesce = op == BoolOp::Cut && !a.regularization_protected diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs index cada5e37..27ec3b76 100644 --- a/crates/kernel/src/prism_curve.rs +++ b/crates/kernel/src/prism_curve.rs @@ -276,6 +276,60 @@ pub(super) fn segment(e: &mt::Edge, plane: &Plane, tol: f64, work: &mut usize) - if a.dist(b) > tol && spans.iter().all(|s| line_span(s, a, b, tol, error, work).is_some()) { return Some(vec![Seg2::Line { a, b }]); } + arc(&c, &spans, plane, a, b, tol, error, work).or_else(|| free_form(&spans, a, b, tol)) +} + +/// One Bézier span that is neither a line nor an arc, as the exact free-form segment it is +/// (a font's quadratic or cubic, a conic), its ends `a` and `b`. `None` for several spans (a +/// curve that may wind about more than once), degrees past [`solidcraft_geom::MAX_BEZIER_DEGREE`], +/// rational spans other than conics, and control polygons with no area (a curve doubling back +/// along a line). +fn free_form(spans: &[Vec], a: Vec2, b: Vec2, tol: f64) -> Option> { + let [span] = spans else { return None }; + let pts: Vec = span.iter().map(|p| point(*p)).collect(); + let (first, last) = (*pts.first()?, *pts.last()?); + if first.dist(a) > tol || last.dist(b) > tol || first.dist(last) <= tol { + return None; + } + // Twice the area the control polygon spans off the chord: none is a line, maybe doubling back. + let chord = last - first; + let off = pts.iter().map(|p| chord.cross(*p - first).abs()).fold(0., f64::max) / chord.len(); + if !(off > tol) { + return None; + } + let rational = span.iter().any(|p| (p[2] - 1.).abs() > 1e-12); + let seg = match (pts.len(), rational) { + (_, false) => solidcraft_geom::bezier(&pts)?, + (3, true) => { + // Weights normalised to 1 at the ends: the middle one is the conic's. + let (w0, w1, w2) = (span[0][2], span[1][2], span[2][2]); + let w = w1 / (w0 * w2).sqrt(); + if !(w.is_finite() && w > 1e-9) { + return None; + } + Seg2::Conic { a: first, apex: pts[1], b: last, w } + } + _ => return None, + }; + // The exact ends of the edge (the span's agree within the tolerance). + let seg = match seg { + Seg2::Cubic { p1, p2, .. } => Seg2::Cubic { p0: a, p1, p2, p3: b }, + Seg2::Conic { apex, w, .. } => Seg2::Conic { a, apex, b, w }, + Seg2::Bezier { n, mut p } => { + *p.first_mut()? = a; + *p.get_mut(n as usize)? = b; + Seg2::Bezier { n, p } + } + other => other, + }; + Some(vec![seg]) +} + +/// The spline `c` (its Bézier `spans` in the plane, from `a` to `b`) as a circular arc, when +/// whole-span proofs show it is one. +#[allow(clippy::too_many_arguments)] +fn arc(c: &mt::Curve, spans: &[Vec], plane: &Plane, a: Vec2, b: Vec2, tol: f64, error: f64, work: &mut usize) -> Option> { + let (t0, t1) = c.range_tuple(); // Samples are only a candidate proposal. Whole-span implicit and oriented // angular proofs below are required before converting any spline to an arc. let q = plane.to_local(from_p3(c.subs(t0 + (t1 - t0) * 5. / 16.))) - a; @@ -290,7 +344,7 @@ pub(super) fn segment(e: &mt::Edge, plane: &Plane, tol: f64, work: &mut usize) - return None; } let mut sweep: f64 = 0.; - for span in &spans { + for span in spans { let ds = circle_span(span, center, radius, tol, error, 0, work)?; if sweep != 0. && sweep.signum() != ds.signum() { return None; diff --git a/crates/kernel/src/prism_spline_tests.rs b/crates/kernel/src/prism_spline_tests.rs new file mode 100644 index 00000000..6fe0b48a --- /dev/null +++ b/crates/kernel/src/prism_spline_tests.rs @@ -0,0 +1,87 @@ +use super::*; + +/// A rounded square of four quadratic curves (a font's kind) about (20, 15). +fn rounded() -> Region2 { + let p = Vec2::new; + let q = |a: Vec2, apex: Vec2, b: Vec2| Seg2::Conic { a, apex, b, w: 1.0 }; + let segs = vec![ + q(p(10.0, 15.0), p(10.0, 5.0), p(20.0, 5.0)), + q(p(20.0, 5.0), p(30.0, 5.0), p(30.0, 15.0)), + q(p(30.0, 15.0), p(30.0, 25.0), p(20.0, 25.0)), + q(p(20.0, 25.0), p(10.0, 25.0), p(10.0, 15.0)), + ]; + Region2 { outer: Loop2 { segs }, holes: vec![] } +} + +fn block() -> Body { + crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 30.0, 10.0)).unwrap() +} + +/// Its free-form faces (the tool's walls kept as the curves they were). +fn spline_faces(b: &Body) -> usize { + b.solid.face_iter().filter(|f| matches!(f.surface(), mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_))).count() +} + +/// A shape of a font's curves engraved into a block is decided in 2D, its walls the exact curves. +#[test] +fn an_engraving_of_quadratic_curves_is_exact_in_2d() { + let region = rounded(); + let area = region.outer.signed_area().abs(); + let tool = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 9.0, 11.0).unwrap().remove(0); + let out = prism_boolean(&block(), &tool, BoolOp::Cut).expect("the 2D route takes it").unwrap().unwrap(); + assert!(out.validity().is_empty(), "{:?}", out.validity()); + // Half the tool, measured as the result is (meshes of curved faces are off by about 1e-4). + let taken = 12000.0 - crate::measure(&out).unwrap().volume; + let half = crate::measure(&tool).unwrap().volume / 2.0; + assert!((taken - half).abs() < 1e-6 * half && (taken - area).abs() < 2e-4 * area, "{taken} vs {half} ({area})"); + assert_eq!(spline_faces(&out), 4); +} + +/// A shape running off the block's side is cut where its curves exactly cross the side. +#[test] +fn an_engraving_running_off_the_side_ends_on_its_exact_crossings() { + let region = rounded(); + // Moved 14 to the left: the block's side x = 0 cuts its two left curves. + let shift = |s: &Seg2| match *s { + Seg2::Conic { a, apex, b, w } => { + Seg2::Conic { a: a - Vec2::new(14.0, 0.0), apex: apex - Vec2::new(14.0, 0.0), b: b - Vec2::new(14.0, 0.0), w } + } + other => other, + }; + let moved = Region2 { outer: Loop2 { segs: region.outer.segs.iter().map(shift).collect() }, holes: vec![] }; + let tool = crate::extrude(&Plane::XY, std::slice::from_ref(&moved), 9.0, 11.0).unwrap().remove(0); + let out = prism_boolean(&block(), &tool, BoolOp::Cut).expect("the 2D route takes it").unwrap().unwrap(); + assert!(out.validity().is_empty(), "{:?}", out.validity()); + // What the block keeps of the shape: the shape's part at x > 0. + let inside = planar_regions_boolean( + std::slice::from_ref(&moved), + &[Region2 { + outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), + holes: vec![], + }], + BoolOp::Intersect, + 1e-6, + ) + .unwrap(); + let area: f64 = inside.iter().map(|r| r.outer.signed_area().abs()).sum(); + let taken = 12000.0 - crate::measure(&out).unwrap().volume; + assert!((taken - area).abs() < 2e-4 * area, "{taken} vs {area}"); + assert!(spline_faces(&out) >= 2, "{}", spline_faces(&out)); +} + +/// A tool that misses its target leaves it as it is: the 2D route declines it (the B-rep +/// boolean then reuses the target itself). An engraving taking far less than a ten-thousandth +/// of a large part is a change all the same, and the route keeps it: told from the exact +/// sections, not from volumes measured off meshes, which could not tell it from no change. +#[test] +fn a_tool_that_misses_is_declined_and_a_fine_engraving_is_not() { + let big = crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(200.0, 200.0, 10.0)).unwrap(); + let region = rounded(); + let tool = |lo: f64, hi: f64| crate::extrude(&Plane::XY, std::slice::from_ref(®ion), lo, hi).unwrap().remove(0); + assert!(prism_boolean(&big, &tool(10.0, 12.0), BoolOp::Cut).is_none(), "a tool above the part misses it"); + // 0.05 mm deep: 17 mm³ of a 400000 mm³ part. + let out = prism_boolean(&big, &tool(9.95, 11.0), BoolOp::Cut).expect("an engraving is a change").unwrap().unwrap(); + let taken = 400_000.0 - crate::measure(&out).unwrap().volume; + let want = region.outer.signed_area().abs() * 0.05; + assert!((taken - want).abs() < 2e-3 * want, "{taken} vs {want}"); +} diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index 441e9543..0c5c8b4a 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -80,8 +80,17 @@ impl EdgeSpec { EdgeSpec::Line { .. } => None, EdgeSpec::Arc { mid, .. } => Some(key(*mid)), EdgeSpec::Chain { ctrl } => { - let n = ctrl.len().max(1) as f64; - Some(key(ctrl.iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / n))) + // Summed in pairs from both ends, so that the chain run either way gives the very + // same point: a font's control points are simple fractions, whose mean can fall + // exactly halfway between two steps of the key, where summing in another order + // would round it to the other one. + let n = ctrl.len(); + let sum = (0..n.div_ceil(2)).fold(Vec3::ZERO, |s, i| match (ctrl.get(i), ctrl.get(n - 1 - i)) { + (Some(p), Some(q)) if i != n - 1 - i => s + (*p + *q), + (Some(p), _) => s + *p, + _ => s, + }); + Some(key(sum * (1.0 / n.max(1) as f64))) } } } @@ -952,6 +961,23 @@ mod tests { vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)] } + /// A chain's key is the same whichever way it runs. These control points (a joining stroke + /// of a script font) have their mean exactly halfway between two steps of the key, and + /// summed in order one way and the other they rounded to different steps: a wall and the cap + /// beside it, reading the curve opposite ways, got two edges and the solid did not close. + #[test] + fn a_chain_has_one_key_both_ways() { + let p = |x: f64, y: f64| Vec3::new(x, y, 40.0); + let ctrl = [ + p(34.74439583333333, 11.397236111111111), + p(35.066196759259256, 11.722652777777778), + p(35.32833796296296, 11.885361111111111), + p(35.53081944444444, 11.885361111111111), + ]; + let forward = EdgeSpec::Chain { ctrl: ctrl.to_vec().into() }; + assert_eq!(forward.mid_key(), forward.reversed().mid_key()); + } + #[test] fn disconnected_closed_faces_keep_every_material_component() { let mut faces = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)); diff --git a/crates/kernel/src/spline_conv.rs b/crates/kernel/src/spline_conv.rs index 5a681220..01b87035 100644 --- a/crates/kernel/src/spline_conv.rs +++ b/crates/kernel/src/spline_conv.rs @@ -4,6 +4,7 @@ use solidcraft_geom::bspline::{HPoint, NurbsSurface}; use truck_modeling as mt; +use crate::body::p3; use crate::{KernelError, Result}; fn fail(m: impl std::fmt::Display) -> KernelError { @@ -34,3 +35,30 @@ pub(crate) fn surface(s: &NurbsSurface) -> Result { let rows: Vec> = (0..nu).map(|i| (0..nv).map(|j| h4(s.point(i, j))).collect()).collect(); nurbs_surface((mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())), rows) } + +/// One face of a whole tensor surface: its four boundary curves as edges, sharing the corners. +/// This is what a patch built as a single control net (`bridge`, `boundary`, the T-spline sheet) +/// hands to the B-rep. +pub(crate) fn tensor_face(s: &NurbsSurface) -> Result { + let [nu, nv] = s.count(); + if nu < 2 || nv < 2 { + return Err(fail("a face needs at least 2 x 2 control points")); + } + let [ku, kv] = s.knots(); + let corners = [s.point(0, 0), s.point(nu - 1, 0), s.point(nu - 1, nv - 1), s.point(0, nv - 1)]; + let mut v: Vec = Vec::with_capacity(4); + for c in corners { + let p = c.project().ok_or_else(|| fail("a corner has no position"))?; + v.push(mt::builder::vertex(p3(p))); + } + let row = |j: usize| -> Vec { (0..nu).map(|i| s.point(i, j)).collect() }; + let column = |i: usize| -> Vec { (0..nv).map(|j| s.point(i, j)).collect() }; + let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) }; + let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail); + let bottom = edge(v0, v1, nurbs_curve(mt::KnotVec::from(ku.clone()), &row(0))?)?; + let right = edge(v1, v2, nurbs_curve(mt::KnotVec::from(kv.clone()), &column(nu - 1))?)?; + let top = edge(v3, v2, nurbs_curve(mt::KnotVec::from(ku.clone()), &row(nv - 1))?)?; + let left = edge(v0, v3, nurbs_curve(mt::KnotVec::from(kv.clone()), &column(0))?)?; + let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into(); + mt::Face::try_new(vec![wire], surface(s)?).map_err(fail) +} diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 88038c6b..6ea12585 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -124,6 +124,9 @@ fn trace_tol(face: &mt::Face, size: f64) -> f64 { /// Trace the zero set of `g` over a face: chains of exact points. fn trace(face: &mt::Face, g: &Field, size: f64) -> Result> { + if let Some(chains) = trace_flat(face, g, size) { + return Ok(chains); + } let tol = trace_tol(face, size); let shell: mt::Shell = vec![face.clone()].into(); let meshed = shell.robust_triangulation(tol); @@ -290,6 +293,53 @@ fn trace(face: &mt::Face, g: &Field, size: f64) -> Result> { Ok(chains) } +/// A plane across a flat face (a plane, or a B-spline surface that is one) with straight edges, +/// exactly: the line the two planes meet in, inside the face between pairs of its crossings with +/// the boundary, taken in order along the line. Which side a corner is on is decided once, at +/// the corner, so a plane through a corner crosses the boundary there once or not at all, as it +/// should. `None` (traced on a mesh instead) for any other face or tool, and where an edge lies +/// in the plane. +fn trace_flat(face: &mt::Face, g: &Field, size: f64) -> Option> { + let Field::Plane { o, n } = g else { return None }; + let surface = face.surface(); + let Field::Plane { n: m, .. } = surface_field(&surface) else { return None }; + let along = m.cross(*n).normalized()?; + let tol = size * 1e-7 + 1e-9; + let mut hits: Vec = Vec::new(); + for e in face.boundaries().iter().flat_map(|w| w.edge_iter()) { + let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point())); + let (ga, gb) = ((a - *o).dot(*n), (b - *o).dot(*n)); + // Straight: a line, or a curve (a B-spline of degree one) whose points all lie on its + // chord; such an edge crosses the plane once at most. + let straight = matches!(e.curve(), mt::Curve::Line(_)) || { + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + (1..8).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 8.0)).dist_to_segment(a, b) <= tol) + }; + if !straight || (ga.abs() <= tol && gb.abs() <= tol) || !ga.is_finite() || !gb.is_finite() { + return None; + } + if (ga >= 0.0) != (gb >= 0.0) { + hits.push(p3(a + (b - a) * (ga / (ga - gb)).clamp(0.0, 1.0))); + } + } + if !hits.len().is_multiple_of(2) { + return None; + } + hits.sort_by(|a, b| from_p3(*a).dot(along).total_cmp(&from_p3(*b).dot(along))); + let uv = |p: mt::Point3| surface.search_nearest_parameter(p, None, 50); + let mut chains = Vec::new(); + for pair in hits.chunks(2) { + let [a, b] = pair else { return None }; + // A corner the plane only touches: no line. + if a.distance(*b) <= tol { + continue; + } + chains.push(Chain { pts: vec![(*a, uv(*a)?), (*b, uv(*b)?)], closed: false }); + } + Some(chains) +} + /// Where the zero set crosses a boundary edge of the face (exact, by bisection on the curve). fn boundary_hits(face: &mt::Face, g: &Field) -> Vec { let mut out = Vec::new(); @@ -1084,9 +1134,22 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul }) } -/// A point well inside a face (a mesh triangle's centre where the field is largest) and the -/// field there. +/// The field at a point well inside a face (a mesh triangle's centre where the field is +/// largest; for a plane, the face's corner farthest from it). fn side_of(face: &mt::Face, g: &Field, size: f64) -> Result { + // A face the plane does not cross is on the side of whichever corner lies off it (the + // farthest, against rounding); a face with every corner on the plane is meshed. + if let Field::Plane { .. } = g { + let far = face + .boundaries() + .iter() + .flat_map(|w| w.vertex_iter()) + .map(|v| g.eval(from_p3(v.point()))) + .fold(0.0, |b: f64, x| if x.abs() > b.abs() { x } else { b }); + if far.abs() > size * 1e-6 { + return Ok(far); + } + } let shell: mt::Shell = vec![face.clone()].into(); let meshed = shell.robust_triangulation((size * 2e-3).max(1e-4)); let pm = meshed.face_iter().next().and_then(|f| f.surface()).ok_or_else(|| KernelError::Failed("a face could not be meshed".into()))?; diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 36c9e658..6bf8a91d 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -88,6 +88,21 @@ pub struct StepImport { pub system: String, } +/// A solid the walk found, converted later. +struct Planned { + item: u64, + units: Units, + batch: usize, +} + +/// Where the solids of a batch go: their placement and names. +struct Batch { + m: mt::Matrix4, + path: Vec, + product: String, + instance: Option, +} + /// Reading context: the file and the units of the representation being read. pub(crate) struct Ctx<'a> { pub ex: &'a Exchange, @@ -228,8 +243,8 @@ fn rigid_inverse(m: &mt::Matrix4) -> mt::Matrix4 { /// faces the file had, and the file face of each kernel face (band faces split into two). type SolidEntry = (mt::Solid, bool, usize, Vec); -struct Reader<'a> { - ex: &'a Exchange, +struct Reader { + ex: Arc, units: HashMap, warnings: BTreeMap, /// Representation → its items and context. @@ -263,6 +278,15 @@ struct Reader<'a> { /// Items whose meshing was checked. unmeshed_checked: HashSet, bodies: Vec, + /// Solids the walk found, in file order, to be converted into bodies afterwards (so the + /// conversion, the long part, can be done a piece at a time: [`StepReading`]). + plan: Vec, + /// What the solids of one batch (consecutive solids of one representation) share. + batches: Vec, + /// Planned solids converted so far. + converted: usize, + /// The body each converted planned solid became (`None`: it failed). + made: Vec>, workers: usize, /// Whoever waits for the import (progress, Cancel). watch: Option>, @@ -272,8 +296,8 @@ struct Reader<'a> { cancelled: bool, } -impl<'a> Reader<'a> { - fn new(ex: &'a Exchange, workers: usize) -> Self { +impl Reader { + fn new(ex: Arc, workers: usize) -> Self { Self { ex, units: HashMap::new(), @@ -293,6 +317,10 @@ impl<'a> Reader<'a> { visits: 0, unmeshed_checked: HashSet::new(), bodies: Vec::new(), + plan: Vec::new(), + batches: Vec::new(), + converted: 0, + made: Vec::new(), workers, watch: None, solid_items: 0, @@ -310,7 +338,7 @@ impl<'a> Reader<'a> { self.cancelled = true; return true; } - w.progress("converting", self.bodies.len().min(self.solid_items), self.solid_items); + w.progress("converting", self.converted.min(self.solid_items), self.solid_items); } false } @@ -320,7 +348,8 @@ impl<'a> Reader<'a> { } fn index(&mut self) { - let ex = self.ex; + let ex = self.ex.clone(); + let ex = &*ex; let mut pds: HashMap = HashMap::new(); // PDS → definition let mut sdrs: Vec<(u64, u64)> = Vec::new(); // (PDS, rep) let mut cdsrs: Vec<(u64, u64)> = Vec::new(); // (relationship, PDS) @@ -522,11 +551,11 @@ impl<'a> Reader<'a> { fn units_of(&mut self, rep: u64) -> Units { let ctx = self.reps.get(&rep).and_then(|r| r.1); - context_units(self.ex, ctx, &mut self.units) + context_units(&self.ex, ctx, &mut self.units) } fn placement_matrix(&mut self, item: u64, u: Units) -> R { - let cx = Ctx { ex: self.ex, len: u.len, ang: u.ang, tol: u.tol }; + let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol }; let e = geom::entity(&cx, item)?; if e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" { let p = e.params(); @@ -569,7 +598,7 @@ impl<'a> Reader<'a> { if let Some(r) = self.solids.get(&item) { return r.as_ref().ok().cloned(); } - let cx = Ctx { ex: self.ex, len: u.len, ang: u.ang, tol: u.tol }; + let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol }; let r = match guard("step brep", || brep::read_item(&cx, item).map_err(KernelError::Failed)) { Ok(out) => { let label = self.label(item); @@ -625,7 +654,7 @@ impl<'a> Reader<'a> { // Only batch consecutive solids: mapped items must retain traversal and // first-use unit/cache order. Bound temporary geometry independently of file size. let mut batch = vec![item]; - let capacity = MAX_BODIES.saturating_sub(self.bodies.len()).min(64); + let capacity = MAX_BODIES.saturating_sub(self.bodies_so_far()).min(64); while batch.len() < capacity && items.peek().is_some_and(|id| { self.ex.get(*id).is_some_and(|e| { @@ -637,84 +666,20 @@ impl<'a> Reader<'a> { batch.push(next); } } - let mut sources = Vec::new(); - let mut solids = Vec::new(); + let id = self.batches.len(); + self.batches.push(Batch { m: *m, path: path.to_vec(), product: product.to_string(), instance: instance.map(str::to_string) }); for item in batch { self.used_items.insert(item); if !self.node_items.insert(item) { continue; } - if self.bodies.len() >= MAX_BODIES { + if self.bodies_so_far() >= MAX_BODIES { self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out")); return; } - let Some(entry) = self.item_solid(item, u) else { continue }; - // A one-use item is the only reference to its entry, so the solid moves - // out; an item whose conversion is cached for a later instance needs its - // own copy. - let (solid, closed, file_faces, face_origin) = match Arc::try_unwrap(entry) { - Ok(entry) => entry, - Err(shared) => (*shared).clone(), - }; - sources.push((item, closed, file_faces, face_origin)); - solids.push(parallel::Job { solid, check_mesh: !self.unmeshed_checked.contains(&item) }); - } - let prepared = parallel::bodies(&solids, m, self.workers); - for ((item, closed, file_faces, face_origin), body) in sources.into_iter().zip(prepared) { - let Some(e) = self.ex.get(item) else { continue }; - match body { - Ok((body, missing)) => { - let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string(); - let name = if own.is_empty() { product.to_string() } else { own }; - // Faces the mesher gives up on (or that the budget cut short) are reported. - if self.unmeshed_checked.insert(item) && missing > 0 { - self.warn(format!("solid '{name}': {missing} face(s) could not be meshed and are not shown")); - } - out.push(self.bodies.len()); - let color = self.colors.get(&item).copied(); - // See-through bodies and faces with colours of their own. - let faces = face_origin - .iter() - .enumerate() - .filter_map(|(i, f)| { - let c = self.colors.get(f)?; - Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) }) - }) - .collect(); - let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; - let body = body.with_color(color).with_paint(Some(paint)); - let identities = face_origin - .iter() - .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) - .collect(); - let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); - // A later instance of an item shows its first instance's mesh, placed. - let body = match self.shown.get(&item) { - Some((shared, first)) => body.with_display_of(shared.clone(), to_array(&mat_mul(m, &rigid_inverse(first)))), - None => { - if !self.once.contains(&item) { - self.shown.insert(item, (Arc::new(crate::body::SharedDisplay::new(body.clone())), *m)); - } - body - } - }; - self.bodies.push(ImportedBody { - name, - body, - color, - path: path.to_vec(), - instance: instance.map(str::to_string), - closed, - file_faces, - source_item: item, - face_origin, - }); - } - Err(err) => { - let label = self.label(item); - self.warn(format!("{label}: not imported: {err}")); - } - } + // The node lists the planned solid; it becomes a body index once converted. + out.push(self.plan.len()); + self.plan.push(Planned { item, units: u, batch: id }); } } "MAPPED_ITEM" => { @@ -742,6 +707,121 @@ impl<'a> Reader<'a> { } } + /// Bodies made, and planned ones still to convert (the most a failure can leave out). + fn bodies_so_far(&self) -> usize { + self.bodies.len() + self.plan.len() - self.converted + } + + /// Convert the next batch of planned solids into bodies; false once every one is done. + /// `single`: one solid at a time (the caller wants short steps and has no worker threads). + fn convert_next(&mut self, single: bool) -> bool { + if self.converted >= self.plan.len() { + return false; + } + if self.stop() { + return false; + } + let first = self.converted; + let id = self.plan[first].batch; + let mut last = first + 1; + while !single && last < self.plan.len() && self.plan[last].batch == id { + last += 1; + } + self.converted = last; + let ex = self.ex.clone(); + let mut sources = Vec::new(); + let mut solids = Vec::new(); + for i in first..last { + let (item, u) = (self.plan[i].item, self.plan[i].units); + self.made.push(None); + let Some(entry) = self.item_solid(item, u) else { continue }; + // A one-use item is the only reference to its entry, so the solid moves + // out; an item whose conversion is cached for a later instance needs its + // own copy. + let (solid, closed, file_faces, face_origin) = match Arc::try_unwrap(entry) { + Ok(entry) => entry, + Err(shared) => (*shared).clone(), + }; + sources.push((i, item, closed, file_faces, face_origin)); + solids.push(parallel::Job { solid, check_mesh: !self.unmeshed_checked.contains(&item) }); + } + let Some(batch) = self.batches.get(id) else { return true }; + let (m, path, product, instance) = (batch.m, batch.path.clone(), batch.product.clone(), batch.instance.clone()); + let prepared = parallel::bodies(&solids, &m, self.workers); + for ((i, item, closed, file_faces, face_origin), body) in sources.into_iter().zip(prepared) { + let Some(e) = ex.get(item) else { continue }; + match body { + Ok((body, missing)) => { + let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string(); + let name = if own.is_empty() { product.clone() } else { own }; + // Faces the mesher gives up on (or that the budget cut short) are reported. + if self.unmeshed_checked.insert(item) && missing > 0 { + self.warn(format!("solid '{name}': {missing} face(s) could not be meshed and are not shown")); + } + if let Some(slot) = self.made.get_mut(i) { + *slot = Some(self.bodies.len()); + } + let color = self.colors.get(&item).copied(); + // See-through bodies and faces with colours of their own. + let faces = face_origin + .iter() + .enumerate() + .filter_map(|(i, f)| { + let c = self.colors.get(f)?; + Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) }) + }) + .collect(); + let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces }; + let body = body.with_color(color).with_paint(Some(paint)); + let identities = face_origin + .iter() + .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) + .collect(); + let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); + // A later instance of an item shows its first instance's mesh, placed. + let body = match self.shown.get(&item) { + Some((shared, first)) => body.with_display_of(shared.clone(), to_array(&mat_mul(&m, &rigid_inverse(first)))), + None => { + if !self.once.contains(&item) { + self.shown.insert(item, (Arc::new(crate::body::SharedDisplay::new(body.clone())), m)); + } + body + } + }; + self.bodies.push(ImportedBody { + name, + body, + color, + path: path.clone(), + instance: instance.clone(), + closed, + file_faces, + source_item: item, + face_origin, + }); + } + Err(err) => { + let label = self.label(item); + self.warn(format!("{label}: not imported: {err}")); + } + } + } + true + } + + /// Planned-solid indices (a node's `bodies` after the walk) as body indices, leaving out + /// the ones that failed. + fn remap(&self, planned: &[usize]) -> Vec { + planned.iter().filter_map(|i| self.made.get(*i).copied().flatten()).collect() + } + + fn remap_node(&self, n: &mut StepNode) { + n.bodies = self.remap(&n.bodies); + for c in &mut n.children { + self.remap_node(c); + } + } + /// The name of an assembly occurrence: the usage's name, else its id. fn occurrence_name(&self, nauo: u64) -> Option { let p = self.ex.get(nauo)?.params(); @@ -763,8 +843,9 @@ impl<'a> Reader<'a> { /// Placement of a child occurrence in its parent. fn occurrence_matrix(&mut self, nauo: u64, child_reps: &[u64]) -> mt::Matrix4 { let Some(&rel) = self.placements.get(&nauo) else { return mt::Matrix4::from_scale(1.0) }; + let ex = self.ex.clone(); let r = (|| -> R { - let e = self.ex.get(rel).ok_or("missing placement")?; + let e = ex.get(rel).ok_or("missing placement")?; let rr = e.record("REPRESENTATION_RELATIONSHIP").ok_or("placement without representations")?; let (r1, r2) = (rr.get(2).and_then(Param::as_ref_id).ok_or("bad placement")?, rr.get(3).and_then(Param::as_ref_id).ok_or("bad placement")?); @@ -773,7 +854,7 @@ impl<'a> Reader<'a> { .and_then(|x| x.first()) .and_then(Param::as_ref_id) .ok_or("placement without transformation")?; - let ope = self.ex.get(op).ok_or("missing transformation")?; + let ope = ex.get(op).ok_or("missing transformation")?; let (u1, u2) = (self.units_of(r1), self.units_of(r2)); if ope.name() == "ITEM_DEFINED_TRANSFORMATION" { let p = ope.params(); @@ -1019,25 +1100,34 @@ const KEEP_SMALL_BYTES: usize = 1 << 20; /// [`step_import`] through a small cache of recent results (keyed by the text). Keep the result /// while the same text is still to be read elsewhere (in the timeline): that read reuses it. pub fn step_import_shared(text: &str) -> Result> { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - text.hash(&mut h); - let key = (h.finish(), text.len()); + let key = text_key(text); if let Ok(cache) = RECENT.lock() && let Some(imp) = cache.iter().filter(|r| r.key == key).find_map(|r| r.import.upgrade()) { return Ok(imp); } let imp = std::sync::Arc::new(step_import(text)?); + remember(key, &imp, text.len() <= KEEP_SMALL_BYTES); + Ok(imp) +} + +/// The cache key of a STEP text: its hash and length. +fn text_key(text: &str) -> (u64, usize) { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + (h.finish(), text.len()) +} + +/// Keep `imp` in the recent imports (strongly only when `small`). +fn remember(key: (u64, usize), imp: &Arc, small: bool) { if let Ok(mut cache) = RECENT.lock() { cache.retain(|r| r.key != key && r.import.strong_count() > 0); - let small = (text.len() <= KEEP_SMALL_BYTES).then(|| imp.clone()); - cache.push(Recent { key, import: Arc::downgrade(&imp), _small: small }); + cache.push(Recent { key, import: Arc::downgrade(imp), _small: small.then(|| imp.clone()) }); while cache.len() > 4 { cache.remove(0); } } - Ok(imp) } /// Imports the cache still holds alive, and how many STEP bytes they came from (tests and @@ -1052,72 +1142,130 @@ pub fn step_import(text: &str) -> Result { } fn step_import_with_workers(text: &str, workers: usize) -> Result { - let watch = crate::watch::current(); - let cancelled = || watch.as_ref().is_some_and(|w| w.cancelled()); - let ex = match p21::parse(text) { - Ok(ex) => ex, - Err(_) if cancelled() => return Err(crate::watch::cancelled_error("STEP import")), - Err(e) => return Err(KernelError::Invalid(e)), - }; - let mut rd = Reader::new(&ex, workers); - rd.solid_items = ex - .entities - .values() - .filter(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")) - .count(); - rd.watch = watch.clone(); - rd.index(); - // Roots: product definitions that are nobody's component. - let child_pds: HashSet = - rd.children.values().flatten().filter_map(|n| ex.get(*n).and_then(|e| e.params().get(4)).and_then(Param::as_ref_id)).collect(); - let mut roots: Vec = - ex.entities.iter().filter(|(id, e)| e.name() == "PRODUCT_DEFINITION" && !child_pds.contains(id)).map(|(id, _)| *id).collect(); - roots.sort(); - let identity = mt::Matrix4::from_scale(1.0); - let mut tree = Vec::new(); - for pd in roots { - let n = rd.node(pd, identity, identity, &[], None, 0, &mut Vec::new()); - if !n.bodies.is_empty() || !n.children.is_empty() { - tree.push(n); - } + let mut reading = StepReading::with_workers(text, workers)?; + while reading.step() {} + reading.finish() +} + +/// A STEP file being read a piece at a time, for a caller that can't wait for the whole read +/// in one go (the web app, which has one thread and must keep drawing): [`StepReading::new`] +/// parses the file and walks its structure, each [`StepReading::step`] converts the next few +/// solids into bodies, and [`StepReading::finish_shared`] gives the import, cached as +/// [`step_import_shared`] would, so a command reading the same text next finds it read. +pub struct StepReading { + rd: Reader, + tree: Vec, + key: (u64, usize), + small: bool, + /// One solid per step (no worker threads to share a batch between). + single: bool, +} + +impl StepReading { + /// Parse `text` and walk it; the solids are converted by [`Self::step`]. + pub fn new(text: &str) -> Result { + Self::with_workers(text, parallel::workers()) } - // Solids outside any product structure (bare geometry files). - let mut loose: Vec = rd - .reps - .iter() - .flat_map(|(r, (items, _))| items.iter().map(move |i| (*r, *i))) - .filter(|(_, i)| !rd.used_items.contains(i)) - .filter(|(_, i)| { - ex.get(*i).is_some_and(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")) - }) - .map(|(r, _)| r) - .collect(); - loose.sort(); - loose.dedup(); - if !loose.is_empty() { - let mut bodies = Vec::new(); - rd.node_items = rd.used_items.clone(); - rd.visits = 0; - for r in loose { - rd.rep_bodies(r, &identity, &[], "Import", None, &mut bodies, 0); + + fn with_workers(text: &str, workers: usize) -> Result { + let key = text_key(text); + let watch = crate::watch::current(); + let cancelled = || watch.as_ref().is_some_and(|w| w.cancelled()); + let ex = match p21::parse(text) { + Ok(ex) => Arc::new(ex), + Err(_) if cancelled() => return Err(crate::watch::cancelled_error("STEP import")), + Err(e) => return Err(KernelError::Invalid(e)), + }; + let mut rd = Reader::new(ex.clone(), workers); + rd.solid_items = ex + .entities + .values() + .filter(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")) + .count(); + rd.watch = watch.clone(); + rd.index(); + // Roots: product definitions that are nobody's component. + let child_pds: HashSet = + rd.children.values().flatten().filter_map(|n| ex.get(*n).and_then(|e| e.params().get(4)).and_then(Param::as_ref_id)).collect(); + let mut roots: Vec = + ex.entities.iter().filter(|(id, e)| e.name() == "PRODUCT_DEFINITION" && !child_pds.contains(id)).map(|(id, _)| *id).collect(); + roots.sort(); + let identity = mt::Matrix4::from_scale(1.0); + let mut tree = Vec::new(); + for pd in roots { + tree.push(rd.node(pd, identity, identity, &[], None, 0, &mut Vec::new())); } - if !bodies.is_empty() { + // Solids outside any product structure (bare geometry files). + let mut loose: Vec = rd + .reps + .iter() + .flat_map(|(r, (items, _))| items.iter().map(move |i| (*r, *i))) + .filter(|(_, i)| !rd.used_items.contains(i)) + .filter(|(_, i)| { + ex.get(*i) + .is_some_and(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")) + }) + .map(|(r, _)| r) + .collect(); + loose.sort(); + loose.dedup(); + if !loose.is_empty() { + let mut bodies = Vec::new(); + rd.node_items = rd.used_items.clone(); + rd.visits = 0; + for r in loose { + rd.rep_bodies(r, &identity, &[], "Import", None, &mut bodies, 0); + } tree.push(StepNode { name: "Import".into(), transform: to_array(&identity), bodies, children: Vec::new() }); } + let single = workers <= 1; + Ok(StepReading { rd, tree, key, small: text.len() <= KEEP_SMALL_BYTES, single }) } - // Cancelled: what was read so far is dropped here, not cached. - if rd.cancelled || cancelled() { - return Err(crate::watch::cancelled_error("STEP import")); + + /// Convert the next solid (or batch of solids); false once all are converted (or the + /// watch cancelled the import). + pub fn step(&mut self) -> bool { + self.rd.convert_next(self.single) } - if let Some(first) = ex.duplicates.first() { - rd.warn(format!("{} entity ids are defined twice (first #{first}); the first definitions were used", ex.duplicates.len())); + + /// Solids converted, of the solids to convert. + pub fn progress(&self) -> (usize, usize) { + (self.rd.converted, self.rd.plan.len()) } - let warnings: Vec = rd.warnings.iter().map(|(m, n)| if *n > 1 { format!("{m} (×{n})") } else { m.clone() }).collect(); - if rd.bodies.is_empty() { - let why = warnings.first().cloned().unwrap_or_else(|| "the file has no solids".into()); - return Err(KernelError::Failed(format!("STEP import: no bodies ({why})"))); + + /// The import, once [`Self::step`] has converted every solid. + pub fn finish(mut self) -> Result { + while self.step() {} + let rd = &mut self.rd; + // Cancelled: what was read so far is dropped here, not cached. + if rd.cancelled || rd.watch.as_ref().is_some_and(|w| w.cancelled()) { + return Err(crate::watch::cancelled_error("STEP import")); + } + let mut tree = std::mem::take(&mut self.tree); + for n in &mut tree { + rd.remap_node(n); + } + tree.retain(|n| !n.bodies.is_empty() || !n.children.is_empty()); + let ex = rd.ex.clone(); + if let Some(first) = ex.duplicates.first() { + rd.warn(format!("{} entity ids are defined twice (first #{first}); the first definitions were used", ex.duplicates.len())); + } + let warnings: Vec = rd.warnings.iter().map(|(m, n)| if *n > 1 { format!("{m} (×{n})") } else { m.clone() }).collect(); + if rd.bodies.is_empty() { + let why = warnings.first().cloned().unwrap_or_else(|| "the file has no solids".into()); + return Err(KernelError::Failed(format!("STEP import: no bodies ({why})"))); + } + let bodies = std::mem::take(&mut rd.bodies); + Ok(StepImport { bodies, tree, warnings, schema: ex.schemas.join(", "), system: ex.originating_system.clone() }) + } + + /// [`Self::finish`], and the import is cached for the next read of the same text. + pub fn finish_shared(self) -> Result> { + let (key, small) = (self.key, self.small); + let imp = Arc::new(self.finish()?); + remember(key, &imp, small); + Ok(imp) } - Ok(StepImport { bodies: rd.bodies, tree, warnings, schema: ex.schemas.join(", "), system: ex.originating_system.clone() }) } #[cfg(test)] diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 1ff99f93..6012461c 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -175,16 +175,19 @@ fn parallel_import_does_not_prepare_past_body_budget_with_duplicates_or_mapped_i "{HEAD}{}#2000=MANIFOLD_SOLID_BREP('Duplicate',#100);\n#2001=MANIFOLD_SOLID_BREP('First',#100);\n#2002=MANIFOLD_SOLID_BREP('Second',#100);\n#2003=MANIFOLD_SOLID_BREP('Beyond budget',#999999);\n#3000=MAPPED_ITEM('',#999998,#999997);\n#60=SHAPE_REPRESENTATION('',(#2000,#2000,#2001,#3000,#2002,#2003),$);\n{TAIL}", cube_entities() ); - let ex = p21::parse(&text).unwrap(); + let ex = std::sync::Arc::new(p21::parse(&text).unwrap()); let seed = step_import_with_workers(&format!("{HEAD}{}#60=SHAPE_REPRESENTATION('',(#101),$);\n{TAIL}", cube_entities()), 1).unwrap().bodies.remove(0); for workers in [1, 8] { - let mut reader = Reader::new(&ex, workers); + let mut reader = Reader::new(ex.clone(), workers); reader.index(); reader.bodies = vec![seed.clone(); MAX_BODIES - 2]; reader.node_items.insert(2000); let mut out = Vec::new(); reader.rep_bodies(60, &mt::Matrix4::from_scale(1.0), &[], "Budget", None, &mut out, 0); + assert!(reader.solids.is_empty(), "the walk only plans the solids"); + while reader.convert_next(workers == 1) {} + let out = reader.remap(&out); assert_eq!(reader.bodies.len(), MAX_BODIES); assert_eq!(out, vec![MAX_BODIES - 2, MAX_BODIES - 1]); assert_eq!(reader.bodies.last().unwrap().name, "Second"); @@ -598,8 +601,8 @@ fn repeated_items_are_shared_and_one_use_items_are_not() { let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap(); let flat = step_export(&[&a, &b], "test").unwrap(); - let ex = p21::parse(&flat).unwrap(); - let mut rd = Reader::new(&ex, 1); + let ex = std::sync::Arc::new(p21::parse(&flat).unwrap()); + let mut rd = Reader::new(ex.clone(), 1); rd.index(); assert_eq!(rd.once.len(), 2, "each solid of a flat file is converted once"); let imp = step_import(&flat).unwrap(); @@ -609,8 +612,8 @@ fn repeated_items_are_shared_and_one_use_items_are_not() { assert!(rel(vol, 2000.0) < 1e-6, "{vol}"); let assembly = two_instance_assembly(); - let ex = p21::parse(&assembly).unwrap(); - let mut rd = Reader::new(&ex, 1); + let ex = std::sync::Arc::new(p21::parse(&assembly).unwrap()); + let mut rd = Reader::new(ex.clone(), 1); rd.index(); assert!(rd.once.is_empty(), "an assembly may convert an item again for the second instance"); let imp = step_import(&assembly).unwrap(); @@ -967,6 +970,48 @@ fn rounded_slot_exports_cylinders() { assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, want) < 1e-3); } +/// A slot plate rounded along its top and bottom outlines, with a ramp cut across one end +/// (issue #1198). The other end's half turn is still written as a cylinder (its corners alone +/// point both ways: the seam comes from points along its edges). The faces the ramp crosses keep +/// their B-spline sheets, cut down to the faces: the rounds' sheets reached 35 mm past both ends, +/// and readers bounding the part by its surfaces saw a 60 mm part as 130 mm long. +#[test] +fn ramp_cut_slot_exports_sheets_bounded_by_their_faces() { + use solidcraft_geom::Seg2; + use std::f64::consts::{FRAC_PI_2, PI}; + let outer = Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(10.0, -10.0), b: Vec2::new(50.0, -10.0) }, + Seg2::Arc { center: Vec2::new(50.0, 0.0), radius: 10.0, start: -FRAC_PI_2, sweep: PI }, + Seg2::Line { a: Vec2::new(50.0, 10.0), b: Vec2::new(10.0, 10.0) }, + Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: FRAC_PI_2, sweep: PI }, + ], + }; + let plate = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 4.0).unwrap().pop().unwrap(); + let outline = |z: f64| [Vec3::new(30.0, -10.0, z), Vec3::new(30.0, 10.0, z), Vec3::new(0.0, 0.0, z), Vec3::new(60.0, 0.0, z)]; + let rounded = fillet(&fillet(&plate, &outline(4.0), 1.0).unwrap(), &outline(0.0), 1.0).unwrap(); + // The ramp: from the top at x = 40 down to z = 2 past the end, across the whole width. + let side = Plane::new(Vec3::new(0.0, -6.0, 0.0), Vec3::X, Vec3::Z).unwrap(); + let ramp = + Region2 { outer: Loop2::polygon(&[Vec2::new(40.0, 4.0), Vec2::new(62.0, 2.0), Vec2::new(62.0, 6.0), Vec2::new(40.0, 6.0)]), holes: vec![] }; + let tool = extrude(&side, &[ramp], -12.0, 0.0).unwrap().pop().unwrap(); + let b = boolean(&rounded, &tool, BoolOp::Cut).unwrap().unwrap(); + let text = step_export(&[&b], "t").unwrap(); + // Both ends' walls and the three rounds along the sides it leaves whole; the two the ramp + // crosses stay sheets. + assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 5, "{text}"); + assert_eq!(text.matches("B_SPLINE_SURFACE(").count(), 2, "{text}"); + // No point of the file (control points and placements included) lies past the part's ends. + for line in text.lines().filter(|l| l.contains("CARTESIAN_POINT")) { + let Some(xyz) = line.split("((").nth(1).and_then(|r| r.split("))").next()) else { continue }; + let x: f64 = xyz.split(',').next().unwrap().trim().parse().unwrap(); + assert!((-1e-6..=60.0 + 1e-6).contains(&x), "{line}"); + } + let imp = round_trip(&b); + assert_eq!(imp.bodies.len(), 1); + assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, measure(&b).unwrap().volume) < 1e-4); +} + /// A countersink cut into a plate (issue #612): the cone is a tapered extrusion of a disc, a /// B-spline sheet per half, written as one CONICAL_SURFACE face (the sheets reached below the /// plate). diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index b36c255a..c45e46a6 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -156,6 +156,8 @@ struct Out { /// B-spline cylinder and cone sheets of the body being copied to write as analytic surfaces, /// with the direction of their parameter seam. merged: HashMap, + /// B-spline sheets of the body being copied to write cut down to their faces. + trimmed: HashMap, /// Faces of the body being copied: exchange id → written id. faces_out: HashMap, } @@ -233,6 +235,57 @@ impl Out { } } } + /// A B-spline (or NURBS) surface; `None` for other surfaces. + fn write_sheet(&mut self, s: &truck_modeling::Surface) -> Option { + use truck_modeling::Surface; + let (rows, weights, (ku, kv), (du, dv)): (Vec>, Option>>, _, _) = match s { + Surface::BSplineSurface(b) => ( + b.control_points().iter().map(|r| r.iter().map(|p| [p.x, p.y, p.z]).collect()).collect(), + None, + b.knot_vecs().clone(), + (b.udegree(), b.vdegree()), + ), + Surface::NurbsSurface(n) => { + let b = n.non_rationalized(); + let rows: Option>> = b + .control_points() + .iter() + .map(|r| r.iter().map(|p| (p.w.abs() > 1e-300).then(|| [p.x / p.w, p.y / p.w, p.z / p.w])).collect()) + .collect(); + ( + rows?, + Some(b.control_points().iter().map(|r| r.iter().map(|p| p.w).collect()).collect()), + b.knot_vecs().clone(), + (b.udegree(), b.vdegree()), + ) + } + _ => return None, + }; + let net = rows + .iter() + .map(|r| format!("({})", r.iter().map(|p| format!("#{}", self.point(*p))).collect::>().join(","))) + .collect::>() + .join(","); + let list = |v: Vec| v.join(","); + let ((ku, mu), (kv, mv)) = (ku.to_single_multi(), kv.to_single_multi()); + let knots = format!( + "({}),({}),({}),({}),.UNSPECIFIED.", + list(mu.iter().map(|m| m.to_string()).collect()), + list(mv.iter().map(|m| m.to_string()).collect()), + list(ku.iter().map(|k| real(*k)).collect()), + list(kv.iter().map(|k| real(*k)).collect()), + ); + let base = format!("{du},{dv},({net}),.UNSPECIFIED.,.U.,.U.,.U."); + Some(match weights { + None => self.add_shared(format!("B_SPLINE_SURFACE_WITH_KNOTS('',{base},{knots})")), + Some(w) => { + let w = w.iter().map(|r| format!("({})", list(r.iter().map(|x| real(*x)).collect()))).collect::>().join(","); + self.add_shared(format!( + "(BOUNDED_SURFACE()B_SPLINE_SURFACE({base})B_SPLINE_SURFACE_WITH_KNOTS({knots})GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE(({w}))REPRESENTATION_ITEM('')SURFACE())" + )) + } + }) + } fn placement(&mut self, o: [f64; 3], z: [f64; 3], x: [f64; 3]) -> u64 { let (po, dz, dx) = (self.point(o), self.direction(z), self.direction(x)); self.add_shared(format!("AXIS2_PLACEMENT_3D('',#{po},#{dz},#{dx})")) @@ -291,6 +344,11 @@ impl Out { map.insert(id, new); continue; } + if !done && let Some(sheet) = self.trimmed.get(&id).cloned() { + let new = self.write_sheet(&sheet).ok_or_else(|| format!("cannot write the cut sheet #{id}"))?; + map.insert(id, new); + continue; + } if !done { stack.push((id, true)); let mut refs = Vec::new(); @@ -723,12 +781,18 @@ fn square_or_ruled(ex: &p21::Exchange, fe: &p21::Entity, kind: &AnalyticKind) -> /// Points along an edge's curve (its ends for a line, None for a conic it cannot sample). fn edge_samples(ex: &p21::Exchange, edge: u64) -> Option> { + edge_points(ex, edge, 4) +} + +/// `n + 1` points along an edge's curve, ends included (its ends for a line, None for a conic it +/// cannot sample). +fn edge_points(ex: &p21::Exchange, edge: u64, n: usize) -> Option> { use truck_modeling::{BoundedCurve, ParametricCurve}; let p = ex.get(edge)?.params(); let ends = [vertex_point(ex, p.get(1)?.as_ref_id()?)?, vertex_point(ex, p.get(2)?.as_ref_id()?)?]; let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 }; let sample = |f: &dyn Fn(f64) -> truck_modeling::Point3, (t0, t1): (f64, f64)| { - (0..=4).map(|k| f(t0 + (t1 - t0) * k as f64 / 4.0)).map(|q| [q.x, q.y, q.z]).collect::>() + (0..=n).map(|k| f(t0 + (t1 - t0) * k as f64 / n.max(1) as f64)).map(|q| [q.x, q.y, q.z]).collect::>() }; match crate::step_in::geom::curve(&cx, p.get(3)?.as_ref_id()?, 0).ok()? { crate::step_in::geom::CurveGeo::Line { .. } => Some(ends.to_vec()), @@ -1231,14 +1295,18 @@ fn analytic_sheets(ex: &p21::Exchange, merged_surfaces: &std::collections::HashS let x = match seam_dir { Some(x) => Some(x), None => { + // Points along the edges, not just their ends: the corners of a half turn (a + // slot's round end) point both ways and cancel out (#1198). The sheet's own + // middle where even those do. let mean = loops .iter() .flatten() - .filter_map(|(edge, _)| ex.get(*edge)?.params().get(1)?.as_ref_id()) - .filter_map(|v| vertex_point(ex, v)) + .filter_map(|(edge, _)| edge_samples(ex, *edge)) + .flatten() .filter_map(|q| unit(radial(q))) .fold([0.0; 3], add); - unit(mul(mean, -1.0)) + let (AnalyticKind::Cylinder { x: middle, .. } | AnalyticKind::Cone { x: middle, .. }) = kind else { continue }; + Some(unit(mul(mean, -1.0)).unwrap_or(middle)) } }; if let Some(x) = x { @@ -1248,9 +1316,113 @@ fn analytic_sheets(ex: &p21::Exchange, merged_surfaces: &std::collections::HashS out } -/// The B-rep entities of one body as truck writes them, parsed, and the B-spline sheets to write -/// as cylinders and cones (see `analytic_sheets`). Without `merge` the file is truck's, as is. -fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap, HashMap)> { +/// Margin left round a face when its sheet is cut down to it, as a share of the face's own +/// parameter range on each side. +const SHEET_MARGIN: f64 = 0.02; + +/// B-spline sheets that reach well past the one face on them, cut down to that face's parameter +/// range (and a margin), so readers bound the part by its faces (#1198): a round's sheet runs on +/// past both ends of the edge it rounds, and when a later cut crosses the round, the crossing edge +/// keeps it from being written as a cylinder (see `square_or_ruled`). Sheets written as analytic +/// surfaces, flat ones, sheets under several faces, and those whose face's edges do not all lie +/// on the piece cut out (a face across a closed sheet's seam) are left alone. +fn trimmed_sheets(ex: &p21::Exchange, analytic: &HashMap) -> HashMap { + let live: Vec = ex + .entities + .values() + .filter_map(|e| if matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL") { e.params().get(1).and_then(Param::as_list) } else { None }) + .flatten() + .filter_map(Param::as_ref_id) + .collect(); + let mut users: HashMap = HashMap::new(); + for e in live.iter().filter_map(|f| ex.get(*f)) { + if let Some(sid) = e.params().get(2).and_then(Param::as_ref_id) { + *users.entry(sid).or_default() += 1; + } + } + let mut out = HashMap::new(); + for e in live.iter().filter_map(|f| ex.get(*f)) { + let Some(sid) = e.params().get(2).and_then(Param::as_ref_id) else { continue }; + if users.get(&sid) != Some(&1) || analytic.contains_key(&sid) { + continue; + } + let Some(se) = ex.get(sid) else { continue }; + if !(se.has("B_SPLINE_SURFACE") || se.name() == "B_SPLINE_SURFACE_WITH_KNOTS") || planar_bspline(ex, se).is_some() { + continue; + } + let Some(pts) = face_edges(ex, e).and_then(|ls| ls.iter().flatten().map(|(x, _)| edge_points(ex, *x, 16)).collect::>>()) else { + continue; + }; + let pts: Vec = pts.into_iter().flatten().collect(); + let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 }; + let Ok(sheet) = crate::step_in::geom::bspline_surface(&cx, se) else { continue }; + if let Ok(Some(cut)) = crate::guard("STEP sheet trim", || Ok(trim_sheet(&sheet, &pts))) { + out.insert(sid, cut); + } + } + out +} + +/// `sheet` cut down to the parameter range `pts` (points on it) cover, with [`SHEET_MARGIN`]; +/// `None` when that would not make it much smaller, or when a point is not on the piece cut out. +fn trim_sheet(sheet: &truck_modeling::Surface, pts: &[V3]) -> Option { + use truck_modeling::{ParametricSurface, SearchNearestParameter, Surface}; + let (Some((u0, u1)), Some((v0, v1))) = sheet.try_range_tuple() else { return None }; + // Edges made by intersecting surfaces are fitted to them, a micron or so off the sheet; a + // point on the wrong piece of it is far further. + let (lo, hi) = pts.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| { + ([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])]) + }); + let tol = 1e-3 + 1e-4 * norm(sub(hi, lo)); + let near = |s: &Surface, p: V3| -> Option<(f64, f64)> { + let q = truck_modeling::Point3::new(p[0], p[1], p[2]); + let (u, v) = s.search_nearest_parameter(q, None, 100)?; + let r = s.subs(u, v); + (norm(sub([r.x, r.y, r.z], p)) <= tol).then_some((u, v)) + }; + let (mut ua, mut ub, mut va, mut vb) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY); + for p in pts { + let (u, v) = near(sheet, *p)?; + (ua, ub, va, vb) = (ua.min(u), ub.max(u), va.min(v), vb.max(v)); + } + let (du, dv) = ((ub - ua) * SHEET_MARGIN, (vb - va) * SHEET_MARGIN); + let (ua, ub, va, vb) = ((ua - du).max(u0), (ub + du).min(u1), (va - dv).max(v0), (vb + dv).min(v1)); + // Worth it only where a parameter keeps less than four fifths of its range. + let keeps = |a: f64, b: f64, lo: f64, hi: f64| (b - a) / (hi - lo); + if !(ua < ub && va < vb) || (keeps(ua, ub, u0, u1) > 0.8 && keeps(va, vb, v0, v1) > 0.8) { + return None; + } + // The piece between the new ends: `ucut` keeps what lies before the cut and returns the rest. + macro_rules! cut { + ($s:expr) => {{ + let mut s = $s.clone(); + if ua > u0 { + s = s.ucut(ua); + } + if ub < u1 { + s.ucut(ub); + } + if va > v0 { + s = s.vcut(va); + } + if vb < v1 { + s.vcut(vb); + } + s + }}; + } + let piece = match sheet { + Surface::BSplineSurface(b) => Surface::BSplineSurface(cut!(b)), + Surface::NurbsSurface(n) => Surface::NurbsSurface(cut!(n)), + _ => return None, + }; + pts.iter().all(|p| near(&piece, *p).is_some()).then_some(piece) +} + +/// The B-rep entities of one body as truck writes them, parsed, the B-spline sheets to write +/// as cylinders and cones (see `analytic_sheets`) and those to write cut down to their faces +/// (see `trimmed_sheets`). Without `merge` the file is truck's, as is. +fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap, HashMap, HashMap)> { let text = crate::step::truck_step(&[b], "SolidCraft")?; let mut ex = p21::parse(&text).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?; collapse_short_edges(&mut ex); @@ -1270,17 +1442,18 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect(); diff --git a/crates/kernel/src/surface_kind.rs b/crates/kernel/src/surface_kind.rs index 9ef1f2da..2982fb59 100644 --- a/crates/kernel/src/surface_kind.rs +++ b/crates/kernel/src/surface_kind.rs @@ -3,7 +3,10 @@ use solidcraft_geom::Vec3; +use truck_modeling as mt; + use crate::Body; +use crate::body::from_p3; use crate::offset::{Surf, surf_of}; /// A face's analytic surface. Axes are unit vectors. @@ -51,6 +54,85 @@ pub fn face_surface(b: &Body, face: usize) -> Option { }) } +/// The circle (or arc of one) an edge's curve lies on, from its exact curve. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EdgeCircle { + pub center: Vec3, + /// Unit normal of the circle's plane, oriented so the edge runs counter-clockwise about it. + pub axis: Vec3, + pub radius: f64, + /// The edge's ends (the same point for a closed circle). + pub start: Vec3, + pub end: Vec3, + /// The angle the edge spans (radians, 2π for a whole circle). + pub angle: f64, +} + +/// The circle edge `edge` (in the order of [`Body::edges`] and the tessellated mesh's edges) +/// lies on, if its curve is a circle or an arc of one. `None` for other curves, mesh bodies +/// and an index out of range. +pub fn edge_circle(b: &Body, edge: usize) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + if b.mesh.is_some() { + return None; + } + let e = Body::unique_edges(&b.solid).into_iter().nth(edge)?; + let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + crate::guard("edge circle", || { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let at = |f: f64| from_p3(c.subs(t0 + (t1 - t0) * f)); + let samples: Vec = (0..=24).map(|i| at(f64::from(i) / 24.0)).collect(); + Ok(circle_through(&samples, tol)) + }) + .ok() + .flatten() +} + +/// The circle through evenly spaced samples of a curve (first to last), if they all lie on one. +fn circle_through(s: &[Vec3], tol: f64) -> Option { + let (start, end) = (*s.first()?, *s.last()?); + let closed = start.dist(end) <= tol; + // Three well-spread samples: a quarter, half and three quarters for a closed curve. + let n = s.len().checked_sub(1)?; + let (a, b, c) = if closed { (*s.get(n / 4)?, *s.get(n / 2)?, *s.get(3 * n / 4)?) } else { (start, *s.get(n / 2)?, end) }; + 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); + if !(radius.is_finite() && radius > tol) || s.iter().any(|p| (p.dist(center) - radius).abs() > tol || (*p - center).dot(axis).abs() > tol) { + return None; + } + let x = (start - center).normalized()?; + let y = axis.cross(x); + let turn = |p: Vec3| { + let d = p - center; + let t = d.dot(y).atan2(d.dot(x)); + if t < 0.0 { t + std::f64::consts::TAU } else { t } + }; + // Accumulate the turn sample to sample so the span is right whichever way the curve runs. + let mut angle = 0.0; + let mut last = 0.0; + for p in s.iter().skip(1) { + let t = turn(*p); + let mut d = t - last; + if d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } else if d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + angle += d; + last = t; + } + let (axis, angle) = if angle < 0.0 { (axis * -1.0, -angle) } else { (axis, angle) }; + let angle = if closed { std::f64::consts::TAU } else { angle.min(std::f64::consts::TAU) }; + Some(EdgeCircle { center, axis, radius, start, end, angle }) +} + impl FaceSurface { /// The point of the whole surface (not only of its face) nearest `p`. `None` where every /// point of a ring is as near (on a cylinder's or cone's axis, at a sphere's or torus's diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 7d6186ee..ce8a67ed 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1753,6 +1753,33 @@ fn draft_of_a_round_ended_pocket() { assert!(rel(v, 80.0 * 60.0 * 25.0 - pocket) < 1e-4, "{v} vs {}", 80.0 * 60.0 * 25.0 - pocket); } +/// Rounded edges elsewhere on the body don't stop a draft: only the faces round the drafted +/// walls' corners must be walls or caps, the rest keep their surfaces and edges. +#[test] +fn draft_of_a_pocket_on_a_body_with_a_far_rounded_edge() { + let block = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 25.0)).unwrap(); + let tool = box_solid(Vec3::new(25.0, 20.0, 15.0), Vec3::new(55.0, 40.0, 30.0)).unwrap(); + let b = boolean(&block, &tool, BoolOp::Cut).unwrap().unwrap(); + // The bottom edge along x at y = 0: a cylinder (and two sphere-free ends) far from the pocket. + let r = 2.0; + let b = fillet(&b, &[Vec3::new(40.0, 0.0, 0.0)], r).unwrap(); + let faces_before = measure(&b).unwrap().merged.faces; + let walls = [Vec3::new(25.0, 30.0, 20.0), Vec3::new(40.0, 20.0, 20.0), Vec3::new(55.0, 30.0, 20.0), Vec3::new(40.0, 40.0, 20.0)]; + let a = 5f64.to_radians(); + let d = draft(&b, &walls, &Plane::XY.offset(25.0), Vec3::Z, a).unwrap(); + // The pocket narrows going down: at depth t its walls stand t·tan(a) further in. + let (n, mut pocket) = (2000, 0.0); + for k in 0..n { + let e = (k as f64 + 0.5) / n as f64 * 10.0 * a.tan(); + pocket += (30.0 - 2.0 * e) * (20.0 - 2.0 * e) * 10.0 / n as f64; + } + let round = (r * r - PI * r * r / 4.0) * 80.0; + let want = 80.0 * 60.0 * 25.0 - round - pocket; + let m = measure(&d).unwrap(); + assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume); + assert_eq!(m.merged.faces, faces_before, "the round and every other face survive"); +} + /// A round wall is stored in pieces (a cylinder's two halves); one pick drafts all of it (#547). /// A hole drafted from the top narrows going down: a frustum from R 10 to 10 − 25 · tan 5°. #[test] diff --git a/crates/kernel/src/tspline.rs b/crates/kernel/src/tspline.rs index aa864399..7688c720 100644 --- a/crates/kernel/src/tspline.rs +++ b/crates/kernel/src/tspline.rs @@ -42,21 +42,7 @@ fn vertex(h: HPoint) -> Result { /// One face from a clamped tensor surface, bounded by its four boundary curves. fn tensor_face(n: &NurbsSurface) -> Result { - let [nu, nv] = n.count(); - let [ku, kv] = n.knots(); - let (ku, kv) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())); - let corners = [n.point(0, 0), n.point(nu - 1, 0), n.point(nu - 1, nv - 1), n.point(0, nv - 1)]; - let v: Vec = corners.into_iter().map(vertex).collect::>()?; - let row = |j: usize| -> Vec { (0..nu).map(|i| n.point(i, j)).collect() }; - let column = |i: usize| -> Vec { (0..nv).map(|j| n.point(i, j)).collect() }; - let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) }; - let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail); - let bottom = edge(v0, v1, nurbs_curve(ku.clone(), &row(0))?)?; - let right = edge(v1, v2, nurbs_curve(kv.clone(), &column(nu - 1))?)?; - let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?; - let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?; - let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into(); - mt::Face::try_new(vec![wire], crate::spline_conv::surface(n)?).map_err(fail) + crate::spline_conv::tensor_face(n) } /// A parameter point, exact (knot values are shared, so equal points have equal bits). diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 5ed9cf94..6f895754 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -214,7 +214,7 @@ fn open_stands_a_y_up_step_file_up() { assert_eq!(r["features"], 1, "{r}"); assert!((height(&mut s) - 5.0).abs() < 1e-6, "as written"); let r = tool(&mut s, "open", json!({"path": step, "up": "y", "names": "product"})); - assert_eq!(r["features"], 2, "the import and the turn: {r}"); + assert_eq!(r["features"], 1, "the import, its component turned: {r}"); assert!((height(&mut s) - 40.0).abs() < 1e-6, "Y became Z"); for args in [json!({"path": step, "up": "x"}), json!({"path": step, "names": "label"}), json!({"path": step, "up": 1})] { @@ -1373,3 +1373,21 @@ fn tool_annotations_tell_the_whole_truth() { } assert!(tools.iter().all(|t| t["annotations"]["readOnlyHint"].is_boolean()), "every tool is annotated"); } + +/// `measure` with `items` (#1392): a sketch circle's diameter and radius, and two circles' +/// centre-to-centre with the closest and farthest distances. +#[test] +fn measure_items_gives_diameters_and_centre_distances() { + let mut s = server(); + tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}})); + let a = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}})); + let b = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [30, 40], "radius": 2}})); + let id = |r: &Value| r["curves"][0].as_str().or(r["result"]["curves"][0].as_str()).unwrap().to_string(); + let (a, b) = (id(&a), id(&b)); + let one = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}]})); + assert_eq!((one["diameter"].as_f64(), one["radius"].as_f64()), (Some(6.0), Some(3.0)), "{one}"); + let two = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}, {"type": "sketch_curve", "id": b}]})); + assert!((two["center_distance"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{two}"); + assert!((two["min"].as_f64().unwrap() - 45.0).abs() < 1e-9 && (two["max"].as_f64().unwrap() - 55.0).abs() < 1e-9, "{two}"); + assert!(two["distance_mm"].is_number() && two["from"].is_array(), "{two}"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 13737beb..fafbb136 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -135,8 +135,17 @@ pub fn tool_definitions() -> Vec { "measure", "Measure bodies", "Volume (mm³), surface area (mm²), centre of mass, bounding box and face/edge/vertex counts of one body or all \ - bodies, with totals.", - obj(json!({"body": string("Body name (default: all bodies)")}), &[]), + bodies, with totals. With `items` (one or two selections, as `execute` takes them: faces, edges, vertices, \ + sketch curves, profiles): one item's length, area or position, a circle's, arc's or cylinder's `diameter` and \ + `radius` (with `circumference`, or an arc's `arc_length` and `angle_deg`), a slot's `slot` sizes; two items' \ + `min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis).", + obj( + json!({ + "body": string("Body name (default: all bodies)"), + "items": {"type": "array", "items": {"type": "object"}, "description": "One or two selections to measure instead of bodies, e.g. {\"type\": \"face\", \"body\": \"Body1\", \"index\": 3, \"point\": [x,y,z]}"}, + }), + &[], + ), Effect::ReadOnly, ), tool( @@ -240,10 +249,10 @@ pub fn tool_definitions() -> Vec { "open", "Open a design", "Open a .solidcraft design file, a STEP file (.step/.stp: a new design whose bodies come from the file, as an \ - Import base feature) or a 3MF/STL mesh (.3mf/.stl: mesh bodies). Replaces the current design; insert into it \ + Import base feature in a component named after the file; an assembly's parts become nested components) or a \ + 3MF/STL mesh (.3mf/.stl: mesh bodies in such a component); the result reports the component (id, component_name). Replaces the current design; insert into it \ instead with the file.insert_step (STEP) or file.insert_mesh (3MF/STL) commands. A STEP file from a system \ - that has Y up opens lying on its back: `up: \"y\"` turns it a quarter turn about X (Y becomes Z) with a Move \ - feature. `names` picks what the bodies are named after when the solids' own names say nothing useful.", + that has Y up opens lying on its back: `up: \"y\"` turns its component a quarter turn about X (Y becomes Z). `names` picks what the bodies are named after when the solids' own names say nothing useful.", obj( json!({ "path": string("Path to a .solidcraft, .step/.stp, .3mf or .stl file"), @@ -388,6 +397,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { + if let Some(items) = a.get("items") { + return exec(b, "inspect.measure", json!({ "items": items })); + } let p = match str_arg(a, "body") { Some(body) => json!({"bodies": [body]}), None => json!({}), diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 999d90af..8e02c73f 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -12,7 +12,9 @@ workspace = true solidcraft-geom = { workspace = true } serde = { workspace = true } image = { workspace = true, features = ["jpeg"] } +moxcms = { workspace = true } png = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/backend.rs b/crates/render/src/backend.rs index d9f4ba97..1f25d719 100644 --- a/crates/render/src/backend.rs +++ b/crates/render/src/backend.rs @@ -116,6 +116,47 @@ impl Default for RenderLimits { } } +impl RenderLimits { + /// The largest size with the aspect of `width` × `height`, never larger than it, within + /// these limits (and the scratch budget, when a layout is given). Both axes scale by the + /// same factor. `None` for a zero size or limits that admit no pixel at all. + pub fn fit(self, width: u64, height: u64, scratch: Option) -> Option<(u64, u64)> { + let fits = |w: u64, h: u64| { + w <= self.max_dimension + && h <= self.max_dimension + && w.checked_mul(h).is_some_and(|p| p <= self.max_pixels) + && scratch.is_none_or(|s| s.estimate(w, h).is_ok_and(|b| b <= self.max_scratch_bytes)) + }; + if width == 0 || height == 0 { + return None; + } + if fits(width, height) { + return Some((width, height)); + } + let (w, h) = (width as f64, height as f64); + let mut budget = self.max_pixels as f64; + if let Some(s) = scratch { + let per_pixel = (s.sample_scale as f64).powi(2) * s.sample_bytes_per_pixel as f64 + + s.output_bytes_per_pixel as f64 + + s.encoding_bytes_per_pixel as f64; + if per_pixel > 0.0 { + budget = budget.min(self.max_scratch_bytes as f64 / per_pixel); + } + } + let side = self.max_dimension as f64; + let mut scale = (side / w).min(side / h).min((budget / (w * h)).sqrt()).min(1.0); + // Float rounding can land one pixel over a bound: step down until it fits. + for _ in 0..64 { + let size = (((w * scale).floor() as u64).max(1), ((h * scale).floor() as u64).max(1)); + if fits(size.0, size.1) { + return Some(size); + } + scale *= 0.999; + } + None + } +} + /// Estimated pixel-buffer cost, excluding scene/texture/geometry and codec-internal memory. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -162,6 +203,13 @@ pub struct ExecutorDescriptor { pub scratch: ScratchLayout, } +impl ExecutorDescriptor { + /// The largest request size at this aspect this executor admits ([`RenderLimits::fit`]). + pub fn fit(&self, width: u64, height: u64) -> Option<(u64, u64)> { + self.limits.fit(width, height, Some(self.scratch)) + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BackendDescriptor { diff --git a/crates/render/src/backend/tests.rs b/crates/render/src/backend/tests.rs index a08f7c21..09de97e6 100644 --- a/crates/render/src/backend/tests.rs +++ b/crates/render/src/backend/tests.rs @@ -188,3 +188,24 @@ fn identifiers_and_json_reject_unsafe_or_unsupported_input() { assert!(serde_json::from_str::(text).is_err()); } } + +#[test] +fn fit_scales_both_axes_into_dimension_pixel_and_scratch_limits() { + let limits = RenderLimits::default(); + assert_eq!(limits.fit(1920, 1080, None), Some((1920, 1080))); + assert_eq!(limits.fit(4096, 4096, None), Some((2048, 2048))); + assert_eq!(limits.fit(0, 1080, None), None); + // One long side: the dimension limit binds first. + assert_eq!(limits.fit(8192, 16, None), Some((4096, 8))); + for (w, h) in [(3840, 2160), (5120, 2880), (7680, 4320), (2160, 3840)] { + let (fw, fh) = limits.fit(w, h, None).unwrap(); + assert!(fw <= 4096 && fh <= 4096 && fw * fh <= limits.max_pixels, "{w}x{h} -> {fw}x{fh}"); + // Same aspect (within a pixel) and close to the budget. + assert!((fw as f64 / fh as f64 - w as f64 / h as f64).abs() < 2.0 / fh.min(fw) as f64, "{fw}x{fh}"); + assert!(fw * fh > limits.max_pixels * 99 / 100, "{fw}x{fh}"); + } + // A scratch layout tighter than the pixel limit binds instead. + let scratch = ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: 128, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 0 }; + let (fw, fh) = limits.fit(3840, 2160, Some(scratch)).unwrap(); + assert!(scratch.estimate(fw, fh).unwrap() <= limits.max_scratch_bytes && fw * fh < limits.max_pixels, "{fw}x{fh}"); +} diff --git a/crates/render/src/texture_input.rs b/crates/render/src/texture_input.rs index 034f83d5..27f99870 100644 --- a/crates/render/src/texture_input.rs +++ b/crates/render/src/texture_input.rs @@ -4,6 +4,8 @@ use image::{ImageDecoder, ImageFormat, ImageReader, Limits}; use serde::{Deserialize, Serialize}; use std::{io::Cursor, sync::Arc}; +pub mod normalization; + pub const MAX_TEXTURE_BYTES: usize = 16 * 1024 * 1024; pub const MAX_TEXTURE_EDGE: u32 = 2048; const MAX_CODEC_BYTES: u64 = 64 * 1024 * 1024; @@ -71,25 +73,29 @@ fn reader(bytes: &[u8], format: ImageFormat) -> ImageReader> { fn guarded(work: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("texture decoder failed"))? } +fn admitted_format(bytes: &[u8], capacity: usize) -> Result { + if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || capacity > MAX_TEXTURE_BYTES { + return Err(invalid("texture input must contain at most 16 MiB")); + } + let format = image::guess_format(bytes).map_err(|_| invalid("texture must be PNG or JPEG"))?; + if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) { + return Err(invalid("texture must be PNG or JPEG; HDR uses the Radiance input utility")); + } + // Whole-image codecs may return pixels before noticing a missing trailer. This + // marker check rejects that truncation; it is not a general container validator. + let complete = match format { + ImageFormat::Png => bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"), + ImageFormat::Jpeg => bytes.ends_with(b"\xff\xd9"), + _ => false, + }; + if !complete { + return Err(invalid("texture is missing its final terminator")); + } + Ok(format) +} impl TextureSource { pub fn new(bytes: Vec, role: MapRole) -> Result { - if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || bytes.capacity() > MAX_TEXTURE_BYTES { - return Err(invalid("texture input must contain at most 16 MiB")); - } - let format = image::guess_format(&bytes).map_err(|_| invalid("texture must be PNG or JPEG"))?; - if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) { - return Err(invalid("texture must be PNG or JPEG; HDR uses the Radiance input utility")); - } - // Whole-image codecs may return pixels before noticing a missing trailer. This - // marker check rejects that truncation; it is not a general container validator. - let complete = match format { - ImageFormat::Png => bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"), - ImageFormat::Jpeg => bytes.ends_with(b"\xff\xd9"), - _ => false, - }; - if !complete { - return Err(invalid("texture is missing its final terminator")); - } + let format = admitted_format(&bytes, bytes.capacity())?; let (width, height) = guarded(|| { let mut decoder = reader(&bytes, format).into_decoder().map_err(|e| invalid(e.to_string()))?; if !matches!(decoder.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::Rgb8 | image::ColorType::Rgba8) { diff --git a/crates/render/src/texture_input/normalization.rs b/crates/render/src/texture_input/normalization.rs new file mode 100644 index 00000000..4dcee595 --- /dev/null +++ b/crates/render/src/texture_input/normalization.rs @@ -0,0 +1,307 @@ +//! Explicit high-precision normalization. No sampler, persistent resource or capability. + +mod icc; + +mod decode; + +use super::{MapRole, Result, admitted_format, guarded, invalid}; +use image::{ImageFormat, metadata::Orientation}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +pub const MAX_WORKING_BYTES: usize = 256 * 1024 * 1024; +pub const MAX_NORMALIZED_BYTES: usize = 64 * 1024 * 1024; +const GAMUT_TOLERANCE: f64 = 3.0 / 65535.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnprofiledColor { + Refuse, + /// Declare sRGB primaries; use stated PNG gamma, or sRGB transfer when absent. + DeclareSrgbPrimaries, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OrientationPolicy { + Refuse, + Apply, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GamutPolicy { + /// Refuse values beyond the documented finite-precision edge tolerance. + Refuse, + /// Explicit preview policy, with every clamped RGB channel counted in the receipt. + Clip, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct NormalizationOptions { + pub unprofiled_color: UnprofiledColor, + pub orientation: OrientationPolicy, + pub gamut: GamutPolicy, + /// Conservative buffer/workspace admission estimate, not a process-RSS ceiling. + pub max_working_bytes: usize, +} +impl Default for NormalizationOptions { + fn default() -> Self { + Self { + unprofiled_color: UnprofiledColor::Refuse, + orientation: OrientationPolicy::Refuse, + gamut: GamutPolicy::Refuse, + max_working_bytes: MAX_WORKING_BYTES, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ColorConversion { + RawData, + DeclaredSrgb, + PngSrgb, + PngGammaSrgbPrimaries { encoding_gamma: f64 }, + IccMatrixTrc { profile_sha256: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct NormalizationReceipt { + pub recipe_version: u32, + pub source_sha256: String, + pub normalized_sha256: String, + pub format: String, + pub encoded_bytes: usize, + pub retained_encoded_capacity: usize, + pub normalized_bytes: usize, + pub role: MapRole, + pub source_dimensions: [u32; 2], + pub source_channel_bits: u8, + pub output_dimensions: [u32; 2], + pub output_encoding: String, + pub output_precision: String, + pub source_exif_orientation: u8, + pub exif_metadata_present: bool, + pub exif_orientation_tag_present: bool, + pub orientation_applied: bool, + pub conversion: ColorConversion, + pub ignored_png_color_metadata: bool, + pub gamut_policy: GamutPolicy, + pub gamut_tolerance: f64, + pub clamped_rgb_channels: u64, + pub estimated_working_bytes: usize, + pub options: NormalizationOptions, +} + +/// Encoded source only. Decoding, metadata admission and normalization happen in `decode`. +/// The caller must schedule whole-image codec/CMS work away from an interactive UI thread. +#[derive(Clone)] +pub struct NormalizationSource { + bytes: Arc>, + format: ImageFormat, + role: MapRole, + options: NormalizationOptions, +} +impl NormalizationSource { + pub fn new(bytes: Vec, role: MapRole, options: NormalizationOptions) -> Result { + let format = admitted_format(&bytes, bytes.capacity())?; + if options.max_working_bytes == 0 || options.max_working_bytes > MAX_WORKING_BYTES { + return Err(invalid("normalization working budget must be positive and at most 256 MiB")); + } + if bytes.capacity().checked_add(decode::CODEC_BYTES).is_none_or(|minimum| minimum > options.max_working_bytes) { + return Err(invalid("normalization working budget must cover encoded storage and the codec reservation")); + } + Ok(Self { bytes: Arc::new(bytes), format, role, options }) + } + + pub fn decode(&self, cancelled: impl Fn() -> bool) -> Result { + check_cancel(&cancelled)?; + let raw = guarded(|| decode::image(&self.bytes, self.format, self.options.max_working_bytes, self.bytes.capacity()))?; + check_cancel(&cancelled)?; + let orientation = raw.orientation; + if orientation != Orientation::NoTransforms { + if self.options.orientation == OrientationPolicy::Refuse { + return Err(invalid("normalization requires explicit orientation consent")); + } + if self.role == MapRole::NormalOpenGl { + return Err(invalid("oriented normal maps need an explicit vector/basis conversion policy")); + } + } + let conversion = raw.conversion(self.role, self.options.unprofiled_color)?; + let cms = match &conversion { + ColorConversion::IccMatrixTrc { .. } => { + let profile = raw.icc.as_ref().ok_or_else(|| invalid("ICC metadata disappeared"))?; + Some(icc::prepare(profile)?) + } + _ => None, + }; + check_cancel(&cancelled)?; + let [width, height] = raw.dimensions; + let dimensions = match orientation { + Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH => [height, width], + _ => [width, height], + }; + let sample_count = usize::try_from(u64::from(width) * u64::from(height) * 4).map_err(|_| invalid("normalized image size overflow"))?; + if sample_count.checked_mul(4).is_none_or(|n| n > MAX_NORMALIZED_BYTES) { + return Err(invalid("normalized float pixels exceed 64 MiB")); + } + let mut pixels = zeroed::(sample_count)?; + let row_channels = usize::try_from(u64::from(width) * 3).map_err(|_| invalid("normalization row overflow"))?; + let mut input = zeroed::(row_channels)?; + let mut converted = zeroed::(row_channels)?; + let mut clamped_rgb_channels = 0; + for y in 0..height { + check_cancel(&cancelled)?; + for (x, channels) in input.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let [r, g, b, _] = raw.pixel(u32::try_from(x).map_err(|_| invalid("pixel coordinate overflow"))?, y)?; + channels.copy_from_slice(&[r, g, b]); + } + if let Some(cms) = &cms { + guarded(|| cms.transform(&input, &mut converted).map_err(|e| invalid(format!("ICC conversion: {e}"))))?; + } else { + converted.copy_from_slice(&input); + } + for (x, rgb) in converted.as_chunks::<3>().0.iter().enumerate() { + let x = u32::try_from(x).map_err(|_| invalid("pixel coordinate overflow"))?; + let [_, _, _, alpha] = raw.pixel(x, y)?; + let rgb = *rgb; + let mut rgba = [0.0_f32; 4]; + for (destination, value) in rgba.iter_mut().take(3).zip(rgb) { + if !value.is_finite() { + return Err(invalid("color conversion produced nonfinite values")); + } + if self.options.gamut == GamutPolicy::Refuse && !(-GAMUT_TOLERANCE..=1.0 + GAMUT_TOLERANCE).contains(&value) { + return Err(invalid("color conversion is outside sRGB gamut; explicit preview clipping is required")); + } + let bounded = value.clamp(0.0, 1.0); + if bounded != value { + clamped_rgb_channels += 1; + } + let linear = match conversion { + ColorConversion::RawData => bounded, + ColorConversion::PngGammaSrgbPrimaries { encoding_gamma } => bounded.powf(1.0 / encoding_gamma), + _ => srgb_to_linear(bounded), + }; + if !linear.is_finite() || !(0.0..=1.0).contains(&linear) { + return Err(invalid("normalization produced invalid linear/data channels")); + } + *destination = linear as f32; + } + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(invalid("normalization produced invalid alpha")); + } + if let Some(destination) = rgba.get_mut(3) { + *destination = alpha as f32; + } + let index = destination_index(raw.dimensions, dimensions, orientation, x, y)?; + let end = index.checked_add(4).ok_or_else(|| invalid("oriented pixel offset overflow"))?; + pixels.get_mut(index..end).ok_or_else(|| invalid("oriented pixel lies outside its output"))?.copy_from_slice(&rgba); + } + } + let source_sha256 = digest_bytes(&self.bytes, &cancelled)?; + let mut pixel_hash = Sha256::new(); + for chunk in pixels.chunks(4096) { + check_cancel(&cancelled)?; + for value in chunk { + pixel_hash.update(value.to_le_bytes()); + } + } + check_cancel(&cancelled)?; + let receipt = NormalizationReceipt { + recipe_version: 1, + source_sha256, + normalized_sha256: format!("{:x}", pixel_hash.finalize()), + format: if self.format == ImageFormat::Png { "png" } else { "jpeg" }.into(), + encoded_bytes: self.bytes.len(), + retained_encoded_capacity: self.bytes.capacity(), + normalized_bytes: sample_count * 4, + role: self.role, + source_dimensions: raw.dimensions, + source_channel_bits: raw.source_bits, + output_dimensions: dimensions, + output_encoding: if self.role == MapRole::BaseColor { "linear_srgb" } else { "data" }.into(), + output_precision: "rgba_f32".into(), + source_exif_orientation: orientation.to_exif(), + exif_metadata_present: raw.exif_present, + exif_orientation_tag_present: raw.orientation_tag_present, + orientation_applied: orientation != Orientation::NoTransforms, + ignored_png_color_metadata: self.role != MapRole::BaseColor && raw.has_png_color_metadata(), + conversion, + gamut_policy: self.options.gamut, + gamut_tolerance: GAMUT_TOLERANCE, + clamped_rgb_channels, + estimated_working_bytes: raw.estimated_working_bytes, + options: self.options, + }; + Ok(NormalizedTexture { receipt, pixels }) + } +} + +pub struct NormalizedTexture { + receipt: NormalizationReceipt, + pixels: Vec, +} +impl std::fmt::Debug for NormalizedTexture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NormalizedTexture").field("receipt", &self.receipt).field("sample_count", &self.pixels.len()).finish() + } +} +impl NormalizedTexture { + pub fn receipt(&self) -> &NormalizationReceipt { + &self.receipt + } + pub fn rgba(&self) -> &[f32] { + &self.pixels + } +} + +fn zeroed(length: usize) -> Result> { + let mut values = Vec::new(); + values.try_reserve_exact(length).map_err(|_| invalid("cannot allocate normalization buffer"))?; + values.resize(length, T::default()); + Ok(values) +} +fn check_cancel(cancelled: &impl Fn() -> bool) -> Result<()> { + if cancelled() { Err(invalid("texture normalization cancelled")) } else { Ok(()) } +} +fn digest_bytes(bytes: &[u8], cancelled: &impl Fn() -> bool) -> Result { + let mut digest = Sha256::new(); + for chunk in bytes.chunks(64 * 1024) { + check_cancel(cancelled)?; + digest.update(chunk); + } + Ok(format!("{:x}", digest.finalize())) +} +fn srgb_to_linear(value: f64) -> f64 { + if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) } +} +fn destination_index(source: [u32; 2], output: [u32; 2], orientation: Orientation, x: u32, y: u32) -> Result { + let [width, height] = source; + if x >= width || y >= height || width == 0 || height == 0 { + return Err(invalid("invalid orientation coordinate")); + } + let reverse_x = width - 1 - x; + let reverse_y = height - 1 - y; + let (dx, dy) = match orientation { + Orientation::NoTransforms => (x, y), + Orientation::Rotate90 => (reverse_y, x), + Orientation::Rotate180 => (reverse_x, reverse_y), + Orientation::Rotate270 => (y, reverse_x), + Orientation::FlipHorizontal => (reverse_x, y), + Orientation::FlipVertical => (x, reverse_y), + Orientation::Rotate90FlipH => (y, x), + Orientation::Rotate270FlipH => (reverse_y, reverse_x), + }; + let [output_width, output_height] = output; + if dx >= output_width || dy >= output_height { + return Err(invalid("invalid oriented output dimensions")); + } + let index = u64::from(dy) + .checked_mul(u64::from(output_width)) + .and_then(|value| value.checked_add(u64::from(dx))) + .and_then(|value| value.checked_mul(4)) + .ok_or_else(|| invalid("orientation index overflow"))?; + usize::try_from(index).map_err(|_| invalid("orientation index overflow")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/texture_input/normalization/decode.rs b/crates/render/src/texture_input/normalization/decode.rs new file mode 100644 index 00000000..834539ac --- /dev/null +++ b/crates/render/src/texture_input/normalization/decode.rs @@ -0,0 +1,326 @@ +use super::{ColorConversion, UnprofiledColor, digest_bytes, icc, zeroed}; +use crate::texture_input::{MAX_TEXTURE_EDGE, Result, invalid}; +use image::{ImageDecoder, ImageFormat, Limits, metadata::Orientation}; +use std::io::Cursor; + +mod jpeg_markers; +mod png_markers; + +pub(super) const CODEC_BYTES: usize = 64 * 1024 * 1024; +const CMS_WORKSPACE_ESTIMATE: usize = 16 * 1024 * 1024; +const MAX_EXIF_BYTES: usize = 64 * 1024; +const SRGB_CHROMATICITIES: [u32; 8] = [31270, 32900, 64000, 33000, 30000, 60000, 15000, 6000]; + +#[derive(PartialEq)] +struct Metadata { + icc: Option>, + orientation: Orientation, + exif_present: bool, + orientation_tag_present: bool, + srgb: bool, + gamma: Option, + chromaticities: Option<[u32; 8]>, + other_color: bool, +} +pub(super) struct RawImage { + pub dimensions: [u32; 2], + pub source_bits: u8, + pub orientation: Orientation, + pub exif_present: bool, + pub orientation_tag_present: bool, + pub icc: Option>, + pub estimated_working_bytes: usize, + bits: u8, + channels: usize, + pixels: Vec, + srgb: bool, + gamma: Option, + chromaticities: Option<[u32; 8]>, + other_color: bool, +} +impl RawImage { + pub fn has_png_color_metadata(&self) -> bool { + self.srgb || self.gamma.is_some() || self.chromaticities.is_some() || self.other_color + } + pub fn conversion(&self, role: crate::texture_input::MapRole, policy: UnprofiledColor) -> Result { + if role != crate::texture_input::MapRole::BaseColor { + if self.icc.is_some() { + return Err(invalid("ICC profiles on data maps require an explicit separate interpretation policy")); + } + return Ok(ColorConversion::RawData); + } + if self.other_color { + return Err(invalid("PNG CICP/HDR metadata is outside initial SDR texture normalization")); + } + if let Some(profile) = &self.icc { + if self.srgb { + return Err(invalid("PNG has conflicting ICC and sRGB declarations")); + } + if self.channels < 3 { + return Err(invalid("ICC conversion initially requires RGB/RGBA source samples")); + } + icc::admit(profile)?; + return Ok(ColorConversion::IccMatrixTrc { profile_sha256: digest_bytes(profile, &|| false)? }); + } + if self.srgb { + return Ok(ColorConversion::PngSrgb); + } + if policy == UnprofiledColor::Refuse { + return Err(invalid("unprofiled color requires an explicit sRGB-primary declaration")); + } + if self.chromaticities.is_some_and(|values| values.into_iter().zip(SRGB_CHROMATICITIES).any(|(a, b)| a.abs_diff(b) > 1)) { + return Err(invalid("PNG chromaticities differ from the declared sRGB primaries")); + } + if let Some(gamma) = self.gamma { + let encoding_gamma = f64::from(gamma) / 100_000.0; + if !(0.1..=10.0).contains(&encoding_gamma) { + return Err(invalid("PNG encoding gamma is outside [0.1, 10]")); + } + return Ok(ColorConversion::PngGammaSrgbPrimaries { encoding_gamma }); + } + Ok(ColorConversion::DeclaredSrgb) + } + pub fn pixel(&self, x: u32, y: u32) -> Result<[f64; 4]> { + let [width, height] = self.dimensions; + if x >= width || y >= height { + return Err(invalid("pixel lies outside decoded image")); + } + let pixel = usize::try_from(u64::from(y) * u64::from(width) + u64::from(x)).map_err(|_| invalid("pixel offset overflow"))?; + let base = pixel.checked_mul(self.channels).ok_or_else(|| invalid("sample offset overflow"))?; + let sample = |channel: usize| -> Result { + let index = base.checked_add(channel).ok_or_else(|| invalid("sample offset overflow"))?; + if self.bits == 16 { + let start = index.checked_mul(2).ok_or_else(|| invalid("16-bit sample offset overflow"))?; + let end = start.checked_add(2).ok_or_else(|| invalid("16-bit sample offset overflow"))?; + let bytes: [u8; 2] = self.pixels.get(start..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated 16-bit pixel"))?; + Ok(f64::from(u16::from_be_bytes(bytes)) / 65535.0) + } else { + self.pixels.get(index).map(|v| f64::from(*v) / 255.0).ok_or_else(|| invalid("truncated 8-bit pixel")) + } + }; + match self.channels { + 1 => { + let value = sample(0)?; + Ok([value, value, value, 1.0]) + } + 2 => { + let value = sample(0)?; + Ok([value, value, value, sample(1)?]) + } + 3 => Ok([sample(0)?, sample(1)?, sample(2)?, 1.0]), + 4 => Ok([sample(0)?, sample(1)?, sample(2)?, sample(3)?]), + _ => Err(invalid("unsupported decoded channel layout")), + } + } +} + +fn dimensions(width: u32, height: u32, encoded: usize, budget: usize) -> Result { + if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE { + return Err(invalid("normalization dimensions exceed 2048x2048")); + } + let pixels = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| invalid("image size overflow"))?; + let rows = usize::try_from(u64::from(width) * 3 * 8 * 2).map_err(|_| invalid("row workspace overflow"))?; + let estimate = pixels + .checked_mul(8 + 16) + .and_then(|v| v.checked_add(encoded)) + .and_then(|v| v.checked_add(CODEC_BYTES + CMS_WORKSPACE_ESTIMATE + 2 * icc::MAX_ICC_BYTES + rows)) + .ok_or_else(|| invalid("normalization working estimate overflow"))?; + if estimate > budget { + return Err(invalid("normalization exceeds its conservative working budget")); + } + Ok(estimate) +} +fn exif_u16(bytes: &[u8], offset: usize, little: bool) -> Result { + let end = offset.checked_add(2).ok_or_else(|| invalid("EXIF offset overflow"))?; + let data: [u8; 2] = bytes.get(offset..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated EXIF SHORT"))?; + Ok(if little { u16::from_le_bytes(data) } else { u16::from_be_bytes(data) }) +} +fn exif_u32(bytes: &[u8], offset: usize, little: bool) -> Result { + let end = offset.checked_add(4).ok_or_else(|| invalid("EXIF offset overflow"))?; + let data: [u8; 4] = bytes.get(offset..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated EXIF LONG"))?; + Ok(if little { u32::from_le_bytes(data) } else { u32::from_be_bytes(data) }) +} +/// Read only classic TIFF IFD0's Orientation. Other EXIF values/thumbnail IFDs are +/// not decoded or certified. TIFF's default for a missing orientation is top-left. +fn orientation(exif: Option<&[u8]>) -> Result<(Orientation, bool)> { + let Some(bytes) = exif else { + return Ok((Orientation::NoTransforms, false)); + }; + if bytes.len() > MAX_EXIF_BYTES { + return Err(invalid("EXIF metadata exceeds 64 KiB")); + } + let little = match bytes.get(..2) { + Some(b"II") => true, + Some(b"MM") => false, + _ => return Err(invalid("invalid EXIF byte order")), + }; + if exif_u16(bytes, 2, little)? != 42 { + return Err(invalid("EXIF needs a classic TIFF header")); + } + let start = usize::try_from(exif_u32(bytes, 4, little)?).map_err(|_| invalid("EXIF IFD offset overflow"))?; + if start < 8 || start % 2 != 0 { + return Err(invalid("invalid EXIF IFD0 offset")); + } + let count = usize::from(exif_u16(bytes, start, little)?); + if count == 0 || count > 128 { + return Err(invalid("EXIF IFD0 needs 1..128 entries")); + } + let begin = start.checked_add(2).ok_or_else(|| invalid("EXIF IFD offset overflow"))?; + let end = begin.checked_add(count * 12).ok_or_else(|| invalid("EXIF IFD size overflow"))?; + let entries = bytes.get(begin..end).ok_or_else(|| invalid("truncated EXIF IFD0"))?; + let _next_ifd = exif_u32(bytes, end, little)?; + let mut result = None; + for entry in entries.as_chunks::<12>().0 { + if exif_u16(entry, 0, little)? != 0x0112 { + continue; + } + if result.is_some() || exif_u16(entry, 2, little)? != 3 || exif_u32(entry, 4, little)? != 1 { + return Err(invalid("EXIF Orientation must be one unique SHORT")); + } + let value = u8::try_from(exif_u16(entry, 8, little)?).map_err(|_| invalid("invalid EXIF orientation"))?; + result = Some(Orientation::from_exif(value).ok_or_else(|| invalid("EXIF orientation must be in 1..8"))?); + } + Ok((result.unwrap_or(Orientation::NoTransforms), result.is_some())) +} +fn png_metadata(info: &png::Info<'_>) -> Result { + if info.animation_control.is_some() || info.frame_control.is_some() { + return Err(invalid("animated PNG textures are unsupported")); + } + let icc = match info.icc_profile.as_ref() { + Some(profile) if profile.len() <= icc::MAX_ICC_BYTES => { + let mut copy = zeroed::(profile.len())?; + copy.copy_from_slice(profile); + Some(copy) + } + Some(_) => return Err(invalid("embedded ICC exceeds 256 KiB")), + None => None, + }; + let chromaticities = info.chrm_chunk.map(|chrm| { + [ + chrm.white.0.into_scaled(), + chrm.white.1.into_scaled(), + chrm.red.0.into_scaled(), + chrm.red.1.into_scaled(), + chrm.green.0.into_scaled(), + chrm.green.1.into_scaled(), + chrm.blue.0.into_scaled(), + chrm.blue.1.into_scaled(), + ] + }); + let (orientation, orientation_tag_present) = orientation(info.exif_metadata.as_deref())?; + Ok(Metadata { + icc, + orientation, + exif_present: info.exif_metadata.is_some(), + orientation_tag_present, + srgb: info.srgb.is_some(), + gamma: info.gama_chunk.map(|v| v.into_scaled()), + chromaticities, + other_color: info.coding_independent_code_points.is_some() + || info.mastering_display_color_volume.is_some() + || info.content_light_level.is_some(), + }) +} +pub(super) fn image(bytes: &[u8], format: ImageFormat, budget: usize, encoded_capacity: usize) -> Result { + match format { + ImageFormat::Png => png_image(bytes, budget, encoded_capacity), + ImageFormat::Jpeg => jpeg_image(bytes, budget, encoded_capacity), + _ => Err(invalid("normalization accepts PNG or JPEG only")), + } +} +fn png_image(bytes: &[u8], budget: usize, encoded_capacity: usize) -> Result { + let markers = png_markers::read(bytes)?; + let mut decoder = png::Decoder::new(Cursor::new(bytes)); + decoder.set_limits(png::Limits { bytes: CODEC_BYTES }); + decoder.set_ignore_text_chunk(true); + decoder.set_transformations(png::Transformations::EXPAND); + let mut reader = decoder.read_info().map_err(|e| invalid(e.to_string()))?; + let info = reader.info(); + markers.check(info)?; + let size = [info.width, info.height]; + let estimate = dimensions(info.width, info.height, encoded_capacity, budget)?; + let source_bits = info.bit_depth as u8; + let before = png_metadata(info)?; + let length = reader.output_buffer_size().ok_or_else(|| invalid("PNG decoded storage overflow"))?; + if length > 32 * 1024 * 1024 { + return Err(invalid("PNG decoded samples exceed 32 MiB")); + } + let mut pixels = zeroed::(length)?; + let output = reader.next_frame(&mut pixels).map_err(|e| invalid(e.to_string()))?; + reader.finish().map_err(|e| invalid(e.to_string()))?; + let after = png_metadata(reader.info())?; + if before != after { + return Err(invalid("late PNG color/orientation metadata requires explicit separate handling")); + } + if [output.width, output.height] != size || output.buffer_size() != pixels.len() { + return Err(invalid("PNG output dimensions changed")); + } + let bits = output.bit_depth as u8; + if !matches!(bits, 8 | 16) || output.color_type == png::ColorType::Indexed { + return Err(invalid("PNG expansion did not yield 8/16-bit samples")); + } + let Metadata { icc, orientation, exif_present, orientation_tag_present, srgb, gamma, chromaticities, other_color } = after; + Ok(RawImage { + dimensions: size, + source_bits, + orientation, + exif_present, + orientation_tag_present, + icc, + estimated_working_bytes: estimate, + bits, + channels: output.color_type.samples(), + pixels, + srgb, + gamma, + chromaticities, + other_color, + }) +} +fn jpeg_image(bytes: &[u8], budget: usize, encoded_capacity: usize) -> Result { + let markers = jpeg_markers::read(bytes)?; + let mut decoder = image::codecs::jpeg::JpegDecoder::new(Cursor::new(bytes)).map_err(|e| invalid(e.to_string()))?; + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_TEXTURE_EDGE); + limits.max_image_height = Some(MAX_TEXTURE_EDGE); + limits.max_alloc = Some(CODEC_BYTES as u64); + decoder.set_limits(limits).map_err(|e| invalid(e.to_string()))?; + let (width, height) = decoder.dimensions(); + let estimate = dimensions(width, height, encoded_capacity, budget)?; + let channels = match decoder.color_type() { + image::ColorType::L8 => 1, + image::ColorType::Rgb8 => 3, + _ => return Err(invalid("normalization initially accepts only 8-bit JPEG channels")), + }; + let icc = decoder.icc_profile().map_err(|e| invalid(e.to_string()))?; + if icc.as_ref().is_some_and(|p| p.len() > icc::MAX_ICC_BYTES || p.capacity() > icc::MAX_ICC_BYTES) { + return Err(invalid("embedded ICC exceeds 256 KiB")); + } + let exif = decoder.exif_metadata().map_err(|e| invalid(e.to_string()))?; + if markers != (icc.is_some(), exif.is_some()) { + return Err(invalid("JPEG decoder did not retain declared ICC/EXIF metadata")); + } + let (orientation, orientation_tag_present) = orientation(exif.as_deref())?; + let length = usize::try_from(decoder.total_bytes()).map_err(|_| invalid("JPEG decoded storage overflow"))?; + if length > 32 * 1024 * 1024 { + return Err(invalid("JPEG decoded samples exceed 32 MiB")); + } + let mut pixels = zeroed::(length)?; + decoder.read_image(&mut pixels).map_err(|e| invalid(e.to_string()))?; + Ok(RawImage { + dimensions: [width, height], + source_bits: 8, + orientation, + exif_present: exif.is_some(), + orientation_tag_present, + icc, + estimated_working_bytes: estimate, + bits: 8, + channels, + pixels, + srgb: false, + gamma: None, + chromaticities: None, + other_color: false, + }) +} diff --git a/crates/render/src/texture_input/normalization/decode/jpeg_markers.rs b/crates/render/src/texture_input/normalization/decode/jpeg_markers.rs new file mode 100644 index 00000000..be9e3d2a --- /dev/null +++ b/crates/render/src/texture_input/normalization/decode/jpeg_markers.rs @@ -0,0 +1,75 @@ +//! Metadata presence/framing admission from T.81 and ICC's JPEG embedding note. +//! This does not decode entropy data or replace the established JPEG codec. +use crate::texture_input::{Result, invalid}; + +pub(super) fn read(bytes: &[u8]) -> Result<(bool, bool)> { + if bytes.get(..2) != Some(b"\xff\xd8") { + return Err(invalid("invalid JPEG SOI")); + } + let mut offset = 2; + let mut entropy = false; + let mut scan_seen = false; + let mut exif = false; + let mut icc = false; + let mut segments = 0; + while offset < bytes.len() { + if bytes.get(offset) != Some(&255) { + if !entropy { + return Err(invalid("JPEG marker expected")); + } + offset += 1; + continue; + } + while bytes.get(offset) == Some(&255) { + offset += 1; + } + let marker = *bytes.get(offset).ok_or_else(|| invalid("truncated JPEG marker"))?; + offset += 1; + if entropy && (marker == 0 || (0xd0..=0xd7).contains(&marker)) { + continue; + } + entropy = false; + segments += 1; + if segments > 4096 { + return Err(invalid("JPEG normalization exceeds 4096 marker segments")); + } + if marker == 0xd9 { + return if offset == bytes.len() { Ok((icc, exif)) } else { Err(invalid("JPEG EOI must be final")) }; + } + if marker == 0 || marker == 0xd8 || marker == 1 || (0xd0..=0xd7).contains(&marker) { + return Err(invalid("unsupported JPEG standalone marker")); + } + let header_end = offset.checked_add(2).ok_or_else(|| invalid("JPEG offset overflow"))?; + let length: [u8; 2] = bytes.get(offset..header_end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated JPEG length"))?; + let length = usize::from(u16::from_be_bytes(length)); + let end = offset.checked_add(length).ok_or_else(|| invalid("JPEG segment overflow"))?; + if length < 2 { + return Err(invalid("invalid JPEG segment length")); + } + let data = bytes.get(header_end..end).ok_or_else(|| invalid("truncated JPEG segment"))?; + if marker == 0xe2 && data.starts_with(b"ICC_PROFILE\0") { + if scan_seen || data.len() <= 14 { + return Err(invalid("late or truncated JPEG ICC metadata")); + } + icc = true; // The codec assembles sequences; presence must survive that validation. + } + if marker == 0xe1 && data.starts_with(b"Exif\0\0") { + if scan_seen || exif || data.len() <= 6 { + return Err(invalid("duplicate, late or truncated JPEG EXIF metadata")); + } + exif = true; + } + if (0xc0..=0xcf).contains(&marker) + && !matches!(marker, 0xc4 | 0xc8 | 0xcc) + && (!matches!(marker, 0xc0..=0xc2) || data.first() != Some(&8) || !matches!(data.get(5), Some(1 | 3))) + { + return Err(invalid("initial JPEG normalization requires 8-bit grayscale or three-component DCT input")); + } + if marker == 0xda { + scan_seen = true; + entropy = true; + } + offset = end; + } + Err(invalid("JPEG EOI missing")) +} diff --git a/crates/render/src/texture_input/normalization/decode/png_markers.rs b/crates/render/src/texture_input/normalization/decode/png_markers.rs new file mode 100644 index 00000000..d163a0d1 --- /dev/null +++ b/crates/render/src/texture_input/normalization/decode/png_markers.rs @@ -0,0 +1,66 @@ +//! Bounded framing/presence checks; the PNG crate still decodes and validates chunks. +use crate::texture_input::{Result, invalid}; + +const SEMANTIC: [[u8; 4]; 8] = [*b"iCCP", *b"sRGB", *b"gAMA", *b"cHRM", *b"eXIf", *b"cICP", *b"mDCV", *b"cLLI"]; + +pub(super) struct Markers([bool; 8]); + +impl Markers { + pub fn check(&self, info: &png::Info<'_>) -> Result<()> { + let parsed = [ + info.icc_profile.is_some(), + info.srgb.is_some(), + info.gama_chunk.is_some(), + info.chrm_chunk.is_some(), + info.exif_metadata.is_some(), + info.coding_independent_code_points.is_some(), + info.mastering_display_color_volume.is_some(), + info.content_light_level.is_some(), + ]; + if self.0 != parsed { + return Err(invalid("PNG decoder did not retain declared color/orientation metadata")); + } + Ok(()) + } +} + +pub(super) fn read(bytes: &[u8]) -> Result { + if bytes.get(..8) != Some(b"\x89PNG\r\n\x1a\n") { + return Err(invalid("invalid PNG signature")); + } + let mut offset = 8_usize; + let mut markers = [false; 8]; + let mut pixels_seen = false; + for _ in 0..4096 { + let header_end = offset.checked_add(8).ok_or_else(|| invalid("PNG chunk offset overflow"))?; + let header = bytes.get(offset..header_end).ok_or_else(|| invalid("truncated PNG chunk header"))?; + let length: [u8; 4] = header.get(..4).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("invalid PNG length"))?; + let length = usize::try_from(u32::from_be_bytes(length)).map_err(|_| invalid("PNG length overflow"))?; + let kind = header.get(4..8).ok_or_else(|| invalid("invalid PNG chunk type"))?; + let end = header_end.checked_add(length).and_then(|v| v.checked_add(4)).ok_or_else(|| invalid("PNG chunk size overflow"))?; + if end > bytes.len() { + return Err(invalid("truncated PNG chunk")); + } + if let Some(index) = SEMANTIC.iter().position(|tag| tag == kind) { + let seen = markers.get_mut(index).ok_or_else(|| invalid("PNG metadata index overflow"))?; + if *seen || pixels_seen { + return Err(invalid("duplicate or late PNG color/orientation metadata is unsupported")); + } + *seen = true; + } + if kind == b"IDAT" { + pixels_seen = true; + } + if kind == b"IEND" { + if length != 0 || end != bytes.len() { + return Err(invalid("PNG IEND must be empty and final")); + } + if matches!(markers, [true, true, ..]) { + return Err(invalid("PNG has conflicting ICC and sRGB declarations")); + } + return Ok(Markers(markers)); + } + offset = end; + } + Err(invalid("PNG normalization exceeds 4096 chunks")) +} diff --git a/crates/render/src/texture_input/normalization/icc.rs b/crates/render/src/texture_input/normalization/icc.rs new file mode 100644 index 00000000..d0ebb1a5 --- /dev/null +++ b/crates/render/src/texture_input/normalization/icc.rs @@ -0,0 +1,307 @@ +//! Bounded RGB matrix/TRC profile admission before calling the existing permissive CMS. +//! Header/tag layout follows the public ICC.1 profile specification, not another CAD tool. + +use crate::texture_input::{Result, guarded, invalid}; +use moxcms::{ColorProfile, DataColorSpace, Layout, ParsingOptions, ProfileClass, RenderingIntent, TransformF64Executor, TransformOptions}; +use std::sync::Arc; + +pub(super) const MAX_ICC_BYTES: usize = 256 * 1024; +const MAX_TAGS: usize = 64; +const MAX_TAG_BYTES: usize = 64 * 1024; +const MAX_TRC_VALUES: usize = 4096; + +fn word(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(4).ok_or_else(|| invalid("ICC offset overflow"))?; + let value: [u8; 4] = bytes.get(offset..end).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated ICC word"))?; + usize::try_from(u32::from_be_bytes(value)).map_err(|_| invalid("ICC word is too large")) +} + +#[derive(Clone, Copy)] +struct TagRange { + signature: [u8; 4], + start: usize, + end: usize, +} + +/// Reject unsupported conversion semantics instead of silently falling back to a matrix. +pub(super) fn admit(bytes: &[u8]) -> Result<()> { + if !(132..=MAX_ICC_BYTES).contains(&bytes.len()) || word(bytes, 0)? != bytes.len() { + return Err(invalid("ICC profile must have its declared size, at most 256 KiB")); + } + if !matches!(bytes.get(8), Some(2 | 4)) + || !matches!(bytes.get(12..16), Some(b"scnr" | b"mntr")) + || bytes.get(16..20) != Some(b"RGB ") + || bytes.get(20..24) != Some(b"XYZ ") + || bytes.get(36..40) != Some(b"acsp") + || word(bytes, 64)? > 3 + { + return Err(invalid("only version-2/4 RGB input/display ICC profiles with XYZ PCS are supported")); + } + let count = word(bytes, 128)?; + if count == 0 || count > MAX_TAGS { + return Err(invalid("ICC profile must have 1..64 tags")); + } + let table_end = 132 + count * 12; // count has already been capped. + let table = bytes.get(132..table_end).ok_or_else(|| invalid("truncated ICC tag table"))?; + let mut ranges: Vec = Vec::new(); + ranges.try_reserve_exact(count).map_err(|_| invalid("cannot allocate ICC tag table"))?; + for entry in table.as_chunks::<12>().0 { + let signature: [u8; 4] = entry.get(..4).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("invalid ICC tag"))?; + if !matches!( + &signature, + b"rXYZ" + | b"gXYZ" + | b"bXYZ" + | b"rTRC" + | b"gTRC" + | b"bTRC" + | b"wtpt" + | b"bkpt" + | b"chad" + | b"desc" + | b"cprt" + | b"dmnd" + | b"dmdd" + | b"tech" + | b"view" + | b"meas" + | b"lumi" + | b"chrm" + ) { + return Err(invalid("ICC tag is outside the initial RGB matrix/TRC profile policy")); + } + let start = word(entry, 4)?; + let size = word(entry, 8)?; + let end = start.checked_add(size).ok_or_else(|| invalid("ICC tag range overflow"))?; + if start < table_end || start % 4 != 0 || !(8..=MAX_TAG_BYTES).contains(&size) { + return Err(invalid("ICC tag range is invalid or exceeds 64 KiB")); + } + let data = bytes.get(start..end).ok_or_else(|| invalid("ICC tag extends past its profile"))?; + for earlier in &ranges { + // ICC permits identical shared data ranges, notably shared tone curves. + if signature == earlier.signature || (start < earlier.end && earlier.start < end && (start != earlier.start || end != earlier.end)) { + return Err(invalid("ICC has duplicate tags or partially overlapping data")); + } + } + if data.get(4..8) != Some(&[0; 4]) { + return Err(invalid("ICC tag reserved bytes must be zero")); + } + if matches!(&signature, b"rXYZ" | b"gXYZ" | b"bXYZ" | b"wtpt" | b"bkpt" | b"lumi") && (size != 20 || data.get(..4) != Some(b"XYZ ")) { + return Err(invalid("ICC colorant/white-point tags must contain one XYZ value")); + } + if matches!(&signature, b"rTRC" | b"gTRC" | b"bTRC") { + admit_curve(data)?; + } + if &signature == b"chad" && (size != 44 || data.get(..4) != Some(b"sf32")) { + return Err(invalid("ICC chromatic adaptation must be one 3x3 matrix")); + } + if &signature == b"chrm" { + // Informational device chromaticities, not a replacement for PCS colorants. + // Initially admit three explicit channels (type 0), not enumerated phosphors. + if size != 36 || data.get(..4) != Some(b"chrm") || data.get(8..12) != Some(&[0, 3, 0, 0]) { + return Err(invalid("ICC chromaticities require three explicit device channels")); + } + for pair in data.get(12..).ok_or_else(|| invalid("truncated ICC chromaticities"))?.as_chunks::<8>().0 { + let x = word(pair, 0)?; + let y = word(pair, 4)?; + if y == 0 || x > 65536 || y > 65536 || x + y > 65536 { + return Err(invalid("ICC device chromaticities are outside the initial xy policy")); + } + } + } + ranges.push(TagRange { signature, start, end }); + } + for required in [*b"rXYZ", *b"gXYZ", *b"bXYZ", *b"rTRC", *b"gTRC", *b"bTRC", *b"wtpt"] { + if !ranges.iter().any(|tag| tag.signature == required) { + return Err(invalid("ICC matrix/TRC profile is missing a required tag")); + } + } + Ok(()) +} + +fn admit_curve(data: &[u8]) -> Result<()> { + match data.get(..4) { + Some(b"curv") => { + let count = word(data, 8)?; + if count > MAX_TRC_VALUES || data.len() < 12 + count * 2 { + return Err(invalid("ICC tone curve exceeds 4096 values or is truncated")); + } + } + Some(b"para") => { + let kind: [u8; 2] = data.get(8..10).and_then(|v| v.try_into().ok()).ok_or_else(|| invalid("truncated ICC parametric curve"))?; + let parameters = match u16::from_be_bytes(kind) { + 0 => 1, + 1 => 3, + 2 => 4, + 3 => 5, + 4 => 7, + _ => return Err(invalid("unsupported ICC parametric curve type")), + }; + if data.len() < 12 + parameters * 4 { + return Err(invalid("truncated ICC parametric curve parameters")); + } + } + _ => return Err(invalid("ICC tone curve must be a bounded curve or parametric curve")), + } + Ok(()) +} + +/// ICC transform returns encoded sRGB; the caller separately linearizes RGB and preserves +/// alpha. Extended range is essential: the default destination LUT clips before callers +/// can detect gamut loss. f64 retains a 16-bit source LUT when an analytic curve is unavailable; +/// the CMS still uses f32 matrix/curve arithmetic internally, not full f64 colorimetry. +pub(super) fn prepare(bytes: &[u8]) -> Result> { + admit(bytes)?; + guarded(|| { + let options = ParsingOptions { max_profile_size: MAX_ICC_BYTES + 1, max_allowed_clut_size: 0, max_allowed_trc_size: MAX_TRC_VALUES }; + let source = ColorProfile::new_from_slice_with_options(bytes, options).map_err(|e| invalid(format!("ICC profile: {e}")))?; + if source.color_space != DataColorSpace::Rgb + || source.pcs != DataColorSpace::Xyz + || !matches!(source.profile_class, ProfileClass::InputDevice | ProfileClass::DisplayDevice) + || !source.is_matrix_shaper() + { + return Err(invalid("ICC profile does not provide the admitted RGB matrix/TRC conversion")); + } + // The pinned CMS's same-curve optimization can treat two matching curves as + // three matching curves. Keep initial admission narrower than that ambiguity. + if source.red_trc != source.green_trc || source.red_trc != source.blue_trc { + return Err(invalid("initial ICC conversion requires identical RGB tone curves")); + } + let options = TransformOptions { + rendering_intent: RenderingIntent::RelativeColorimetric, + allow_use_cicp_transfer: false, + prefer_fixed_point: false, + allow_extended_range_rgb_xyz: true, + ..TransformOptions::default() + }; + source.create_transform_f64(Layout::Rgb, &ColorProfile::new_srgb(), Layout::Rgb, options).map_err(|e| invalid(format!("ICC transform: {e}"))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profile(linear: bool) -> Vec { + let mut profile = ColorProfile::new_srgb(); + profile.cicp = None; + // Original even-length text keeps the pinned writer's tags aligned. Its default + // odd-length text can produce an unaligned following tag; admission stays strict. + let text = + moxcms::ProfileText::Localizable(vec![moxcms::LocalizableString::new("en".into(), "US".into(), "Original synthetic fixture".into())]); + profile.description = Some(text.clone()); + profile.copyright = Some(text); + if linear { + let curve = moxcms::ToneReprCurve::Parametric(vec![1.0]); + profile.red_trc = Some(curve.clone()); + profile.green_trc = Some(curve.clone()); + profile.blue_trc = Some(curve); + } + profile.encode().unwrap() + } + + #[test] + fn linear_rgb_profile_is_converted_instead_of_discarded() { + let transform = prepare(&profile(true)).unwrap(); + let mut out = [0.0; 3]; + transform.transform(&[0.25, 0.5, 0.75], &mut out).unwrap(); + for (input, actual) in [0.25_f64, 0.5, 0.75].into_iter().zip(out) { + let expected = 1.055 * input.powf(1.0 / 2.4) - 0.055; + assert!((actual - expected).abs() < 3.0 / 65535.0, "{actual} != {expected}"); + } + } + + #[test] + fn adjacent_16_bit_source_values_remain_distinguishable() { + let transform = prepare(&profile(false)).unwrap(); + let input = [32768.0 / 65535.0, 32769.0 / 65535.0, 0.0]; + let mut out = [0.0; 3]; + transform.transform(&input, &mut out).unwrap(); + assert!(out[1] - out[0] > 0.5 / 65535.0); + assert!((out[0] - input[0]).abs() < 3.0 / 65535.0); + assert!((out[1] - input[1]).abs() < 3.0 / 65535.0); + } + + #[test] + fn shared_curve_ranges_are_allowed_but_partial_overlap_is_not() { + let mut encoded = profile(false); + let count = word(&encoded, 128).unwrap(); + let entries: Vec<_> = encoded[132..132 + count * 12] + .as_chunks::<12>() + .0 + .iter() + .enumerate() + .map(|(i, tag)| (132 + i * 12, <[u8; 4]>::try_from(&tag[..4]).unwrap())) + .collect(); + let red = entries.iter().find(|(_, signature)| signature == b"rTRC").unwrap().0; + let shared = encoded[red + 4..red + 12].to_vec(); + for &(offset, signature) in &entries { + if signature == *b"gTRC" || signature == *b"bTRC" { + encoded[offset + 4..offset + 12].copy_from_slice(&shared); + } + } + assert!(prepare(&encoded).is_ok()); + let mut overlap = profile(false); + let first_start = word(&overlap, 136).unwrap(); + overlap[148..152].copy_from_slice(&(u32::try_from(first_start).unwrap() + 4).to_be_bytes()); + assert!(admit(&overlap).unwrap_err().to_string().contains("overlapping")); + } + + #[test] + fn malformed_profiles_and_unsupported_semantics_refuse_before_transform() { + let original = profile(false); + for length in [0, 1, 127, 131, original.len() - 1] { + assert!(admit(&original[..length]).is_err()); + } + let mut large = vec![0; MAX_ICC_BYTES + 1]; + assert!(admit(&large).is_err()); + large.clear(); + for (offset, replacement) in [(8, vec![3]), (16, b"CMYK".to_vec()), (128, 65u32.to_be_bytes().to_vec()), (132, b"A2B0".to_vec())] { + let mut bad = original.clone(); + bad[offset..offset + replacement.len()].copy_from_slice(&replacement); + assert!(admit(&bad).is_err()); + } + for (offset, replacement) in [(136, u32::MAX), (140, u32::MAX), (136, 253)] { + let mut bad = original.clone(); + bad[offset..offset + 4].copy_from_slice(&replacement.to_be_bytes()); + assert!(admit(&bad).is_err()); + } + let mut duplicate = original; + let first: [u8; 4] = duplicate[132..136].try_into().unwrap(); + duplicate[144..148].copy_from_slice(&first); + assert!(admit(&duplicate).is_err()); + } + + #[test] + fn explicit_device_chromaticities_are_validated_without_replacing_pcs_colorants() { + let mut encoded = profile(true); + let count = word(&encoded, 128).unwrap(); + let table_end = 132 + count * 12; + encoded.splice(table_end..table_end, [0; 12]); + for entry in encoded[132..table_end].as_chunks_mut::<12>().0 { + let shifted = u32::try_from(word(entry, 4).unwrap() + 12).unwrap(); + entry[4..8].copy_from_slice(&shifted.to_be_bytes()); + } + let start = (encoded.len() + 3) & !3; + encoded.resize(start, 0); + let mut data = b"chrm\0\0\0\0\0\x03\0\0".to_vec(); + for value in [0.64_f64, 0.33, 0.3, 0.6, 0.15, 0.06] { + data.extend_from_slice(&((value * 65536.0).round() as u32).to_be_bytes()); + } + encoded.extend_from_slice(&data); + let size = u32::try_from(encoded.len()).unwrap(); + encoded[..4].copy_from_slice(&size.to_be_bytes()); + encoded[128..132].copy_from_slice(&u32::try_from(count + 1).unwrap().to_be_bytes()); + encoded[table_end..table_end + 4].copy_from_slice(b"chrm"); + encoded[table_end + 4..table_end + 8].copy_from_slice(&u32::try_from(start).unwrap().to_be_bytes()); + encoded[table_end + 8..table_end + 12].copy_from_slice(&36_u32.to_be_bytes()); + let mut result = [0.0; 3]; + prepare(&encoded).unwrap().transform(&[0.25, 0.5, 0.75], &mut result).unwrap(); + assert!((result[0] - (1.055 * 0.25_f64.powf(1.0 / 2.4) - 0.055)).abs() < 3.0 / 65535.0); + for (offset, replacement) in [(start + 9, 4), (start + 11, 1), (start + 4, 1), (start + 12, 255)] { + let mut bad = encoded.clone(); + bad[offset] = replacement; + assert!(admit(&bad).is_err()); + } + } +} diff --git a/crates/render/src/texture_input/normalization/tests.rs b/crates/render/src/texture_input/normalization/tests.rs new file mode 100644 index 00000000..88a7d374 --- /dev/null +++ b/crates/render/src/texture_input/normalization/tests.rs @@ -0,0 +1,365 @@ +use super::*; +use crate::texture_input::TextureSource; +use std::{borrow::Cow, cell::Cell}; + +fn png(info: png::Info<'static>, pixels: &[u8]) -> Vec { + let mut bytes = Vec::new(); + { + let encoder = png::Encoder::with_info(&mut bytes, info).unwrap(); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(pixels).unwrap(); + } + bytes +} +fn info(width: u32, height: u32, color: png::ColorType, depth: png::BitDepth) -> png::Info<'static> { + let mut info = png::Info::with_size(width, height); + info.color_type = color; + info.bit_depth = depth; + info +} +fn options() -> NormalizationOptions { + NormalizationOptions { unprofiled_color: UnprofiledColor::DeclareSrgbPrimaries, orientation: OrientationPolicy::Apply, ..Default::default() } +} +fn exif(orientation: u8) -> Vec { + // Original minimal little-endian TIFF IFD: one SHORT Orientation entry. + let mut bytes = b"II\x2a\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00".to_vec(); + bytes.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + bytes +} +fn linear_profile() -> Vec { + let mut profile = moxcms::ColorProfile::new_srgb(); + profile.cicp = None; + let text = moxcms::ProfileText::Localizable(vec![moxcms::LocalizableString::new("en".into(), "US".into(), "Original synthetic fixture".into())]); + profile.description = Some(text.clone()); + profile.copyright = Some(text); + let curve = moxcms::ToneReprCurve::Parametric(vec![1.0]); + profile.red_trc = Some(curve.clone()); + profile.green_trc = Some(curve.clone()); + profile.blue_trc = Some(curve); + profile.encode().unwrap() +} +fn close(actual: f32, expected: f64) { + assert!((f64::from(actual) - expected).abs() < 1e-7, "{actual} != {expected}"); +} + +#[test] +fn sixteen_bit_data_and_alpha_are_retained_without_eight_bit_quantization() { + let values = [32768_u16, 32769, 65535, 12345]; + let bytes = png(info(1, 1, png::ColorType::Rgba, png::BitDepth::Sixteen), &values.into_iter().flat_map(u16::to_be_bytes).collect::>()); + assert!(TextureSource::new(bytes.clone(), MapRole::Roughness).is_err(), "legacy utility must retain its refusal"); + let source = NormalizationSource::new(bytes.clone(), MapRole::Roughness, NormalizationOptions::default()).unwrap(); + let result = source.decode(|| false).unwrap(); + for (value, channel) in values.into_iter().zip(result.rgba()) { + close(*channel, f64::from(value) / 65535.0); + } + assert!(result.rgba()[1] > result.rgba()[0]); + assert_eq!(result.receipt().source_channel_bits, 16); + assert_eq!(result.receipt().conversion, ColorConversion::RawData); + assert_eq!(result.receipt().output_encoding, "data"); + assert_eq!(result.receipt().source_sha256, format!("{:x}", Sha256::digest(&bytes))); + assert_eq!(source.decode(|| false).unwrap().receipt(), result.receipt()); +} + +#[test] +fn declared_color_is_linearized_once_and_alpha_stays_unassociated() { + let bytes = png(info(1, 1, png::ColorType::Rgba, png::BitDepth::Eight), &[128, 64, 255, 0]); + assert!(NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).is_err()); + let source = NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap(); + let result = source.decode(|| false).unwrap(); + close(result.rgba()[0], 0.21586050011389926); + close(result.rgba()[1], 0.05126945837404324); + close(result.rgba()[2], 1.0); + close(result.rgba()[3], 0.0); + assert_eq!(result.receipt().conversion, ColorConversion::DeclaredSrgb); +} + +#[test] +fn png_gamma_and_srgb_metadata_have_explicit_meaning_and_data_is_not_color_corrected() { + let mut metadata = info(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight); + metadata.gama_chunk = Some(png::ScaledFloat::from_scaled(100_000)); + metadata.source_gamma = metadata.gama_chunk; + let bytes = png(metadata.clone(), &[128]); + let color = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap(); + close(color.rgba()[0], 128.0 / 255.0); + assert_eq!(color.receipt().conversion, ColorConversion::PngGammaSrgbPrimaries { encoding_gamma: 1.0 }); + let data = NormalizationSource::new(bytes, MapRole::Metallic, Default::default()).unwrap().decode(|| false).unwrap(); + close(data.rgba()[0], 128.0 / 255.0); + assert!(data.receipt().ignored_png_color_metadata); + metadata.srgb = Some(png::SrgbRenderingIntent::RelativeColorimetric); + let tagged = NormalizationSource::new(png(metadata, &[128]), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap(); + close(tagged.rgba()[0], 0.21586050011389926); + assert_eq!(tagged.receipt().conversion, ColorConversion::PngSrgb); +} + +#[test] +fn full_image_icc_conversion_is_nonidentity_and_never_changes_alpha_or_data_semantics() { + let profile = linear_profile(); + let mut metadata = info(1, 1, png::ColorType::Rgba, png::BitDepth::Eight); + metadata.icc_profile = Some(Cow::Owned(profile.clone())); + let bytes = png(metadata, &[64, 128, 192, 80]); + let source = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap(); + let result = source.decode(|| false).unwrap(); + for (channel, value) in result.rgba().iter().take(3).zip([64.0, 128.0, 192.0]) { + assert!((f64::from(*channel) - value / 255.0).abs() < 8.0 / 65535.0); + } + close(result.rgba()[3], 80.0 / 255.0); + assert_eq!(result.receipt().conversion, ColorConversion::IccMatrixTrc { profile_sha256: format!("{:x}", Sha256::digest(&profile)) }); + assert!( + NormalizationSource::new(bytes, MapRole::Roughness, Default::default()) + .unwrap() + .decode(|| false) + .unwrap_err() + .to_string() + .contains("data maps") + ); +} + +#[test] +fn every_orientation_is_an_exact_pixel_permutation_and_normal_vectors_refuse_rotation() { + let cases = [ + (1, [3, 2], vec![1, 2, 3, 4, 5, 6]), + (2, [3, 2], vec![3, 2, 1, 6, 5, 4]), + (3, [3, 2], vec![6, 5, 4, 3, 2, 1]), + (4, [3, 2], vec![4, 5, 6, 1, 2, 3]), + (5, [2, 3], vec![1, 4, 2, 5, 3, 6]), + (6, [2, 3], vec![4, 1, 5, 2, 6, 3]), + (7, [2, 3], vec![6, 3, 5, 2, 4, 1]), + (8, [2, 3], vec![3, 6, 2, 5, 1, 4]), + ]; + for (orientation, dimensions, expected) in cases { + let mut metadata = info(3, 2, png::ColorType::Grayscale, png::BitDepth::Eight); + metadata.exif_metadata = Some(Cow::Owned(exif(orientation))); + let bytes = png(metadata, &[1, 2, 3, 4, 5, 6]); + let result = NormalizationSource::new(bytes.clone(), MapRole::Roughness, options()).unwrap().decode(|| false).unwrap(); + assert_eq!(result.receipt().output_dimensions, dimensions); + assert_eq!(result.receipt().source_exif_orientation, orientation); + for (pixel, value) in result.rgba().as_chunks::<4>().0.iter().zip(expected) { + close(pixel[0], f64::from(value) / 255.0); + } + if orientation != 1 { + let normal = NormalizationSource::new(bytes.clone(), MapRole::NormalOpenGl, options()).unwrap().decode(|| false).unwrap_err(); + assert!(normal.to_string().contains("vector/basis")); + assert!(NormalizationSource::new(bytes, MapRole::Roughness, Default::default()).unwrap().decode(|| false).is_err()); + } + } +} + +#[test] +fn cancellation_after_work_does_not_publish_partial_pixels_and_source_can_be_reused() { + let bytes = png(info(2, 3, png::ColorType::Grayscale, png::BitDepth::Eight), &[1, 2, 3, 4, 5, 6]); + let source = NormalizationSource::new(bytes, MapRole::Roughness, Default::default()).unwrap(); + assert!(source.decode(|| true).unwrap_err().to_string().contains("cancelled")); + for threshold in [4, 6] { + let calls = Cell::new(0); + let error = source + .decode(|| { + let count = calls.get(); + calls.set(count + 1); + count >= threshold + }) + .unwrap_err(); + assert!(error.to_string().contains("cancelled")); + } + assert_eq!(source.decode(|| false).unwrap().rgba().len(), 24); +} + +#[test] +fn retained_encoded_capacity_is_charged_to_the_working_budget() { + let mut bytes = png(info(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight), &[42]); + let baseline = NormalizationSource::new(bytes.clone(), MapRole::Roughness, Default::default()).unwrap().decode(|| false).unwrap(); + let budget = baseline.receipt().estimated_working_bytes; + bytes.reserve_exact(65536); + let source = + NormalizationSource::new(bytes, MapRole::Roughness, NormalizationOptions { max_working_bytes: budget, ..Default::default() }).unwrap(); + assert!(source.decode(|| false).unwrap_err().to_string().contains("working budget")); + assert_eq!(baseline.receipt().normalized_bytes, 16); +} + +#[test] +fn exif_without_an_orientation_uses_the_tiff_default_and_big_endian_is_supported() { + let mut metadata = info(3, 2, png::ColorType::Grayscale, png::BitDepth::Eight); + let mut missing = exif(3); + missing[10..12].copy_from_slice(&0x0100_u16.to_le_bytes()); // ImageWidth, no Orientation. + metadata.exif_metadata = Some(Cow::Owned(missing)); + let result = NormalizationSource::new(png(metadata.clone(), &[1, 2, 3, 4, 5, 6]), MapRole::Roughness, Default::default()) + .unwrap() + .decode(|| false) + .unwrap(); + assert!(result.receipt().exif_metadata_present); + assert!(!result.receipt().exif_orientation_tag_present); + assert_eq!(result.receipt().source_exif_orientation, 1); + let big = b"MM\x00\x2a\x00\x00\x00\x08\x00\x01\x01\x12\x00\x03\x00\x00\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00".to_vec(); + metadata.exif_metadata = Some(Cow::Owned(big)); + let result = NormalizationSource::new(png(metadata, &[1, 2, 3, 4, 5, 6]), MapRole::Roughness, options()).unwrap().decode(|| false).unwrap(); + assert_eq!(result.receipt().output_dimensions, [2, 3]); + for (pixel, value) in result.rgba().as_chunks::<4>().0.iter().zip([4.0, 1.0, 5.0, 2.0, 6.0, 3.0]) { + close(pixel[0], value / 255.0); + } +} + +#[test] +fn budgets_dimensions_metadata_and_truncation_refuse_without_silent_repair() { + let bytes = png(info(1, 1, png::ColorType::Grayscale, png::BitDepth::Eight), &[1]); + for budget in [0, MAX_WORKING_BYTES + 1] { + assert!( + NormalizationSource::new(bytes.clone(), MapRole::Roughness, NormalizationOptions { max_working_bytes: budget, ..Default::default() }) + .is_err() + ); + } + assert!( + NormalizationSource::new(bytes.clone(), MapRole::Roughness, NormalizationOptions { max_working_bytes: 1, ..Default::default() }).is_err() + ); + assert!(NormalizationSource::new(bytes[..bytes.len() - 1].to_vec(), MapRole::Roughness, Default::default()).is_err()); + let oversized = png(info(2049, 1, png::ColorType::Grayscale, png::BitDepth::Eight), &vec![0; 2049]); + assert!(NormalizationSource::new(oversized, MapRole::Roughness, Default::default()).unwrap().decode(|| false).is_err()); + let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight); + metadata.exif_metadata = Some(Cow::Owned(vec![0, 1, 2])); + assert!(NormalizationSource::new(png(metadata, &[1, 2, 3]), MapRole::Roughness, options()).unwrap().decode(|| false).is_err()); + let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight); + metadata.chrm_chunk = Some(png::SourceChromaticities::new((0.3127, 0.3290), (0.64, 0.33), (0.21, 0.71), (0.15, 0.06))); + metadata.source_chromaticities = metadata.chrm_chunk; + assert!(NormalizationSource::new(png(metadata, &[1, 2, 3]), MapRole::BaseColor, options()).unwrap().decode(|| false).is_err()); +} + +#[test] +fn jpeg_decode_records_explicit_eight_bit_precision_and_linearization() { + use image::ImageEncoder; + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 100).write_image(&[128, 128, 128], 1, 1, image::ExtendedColorType::Rgb8).unwrap(); + let result = NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap(); + assert_eq!(result.receipt().format, "jpeg"); + assert_eq!(result.receipt().source_channel_bits, 8); + assert_eq!(result.receipt().output_dimensions, [1, 1]); + assert!((f64::from(result.rgba()[0]) - 0.21586050011389926).abs() < 0.01, "JPEG is a lossy input codec"); +} + +#[test] +fn indexed_transparency_expands_without_premultiplying_color() { + let mut metadata = info(2, 1, png::ColorType::Indexed, png::BitDepth::Eight); + metadata.palette = Some(Cow::Owned(vec![255, 128, 64, 10, 20, 30])); + metadata.trns = Some(Cow::Owned(vec![0, 128])); + let result = NormalizationSource::new(png(metadata, &[0, 1]), MapRole::Roughness, Default::default()).unwrap().decode(|| false).unwrap(); + for (actual, expected) in result.rgba().iter().zip([255.0, 128.0, 64.0, 0.0, 10.0, 20.0, 30.0, 128.0]) { + close(*actual, expected / 255.0); + } +} + +#[test] +fn out_of_gamut_icc_requires_explicit_preview_clipping_and_records_loss() { + let mut profile = moxcms::ColorProfile::new_from_slice(&linear_profile()).unwrap(); + profile.red_colorant.x *= 2.0; // Original synthetic stress matrix, not a measured material. + let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight); + metadata.icc_profile = Some(Cow::Owned(profile.encode().unwrap())); + let bytes = png(metadata, &[255, 0, 0]); + let error = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap_err(); + assert!(error.to_string().contains("gamut"), "{error}"); + let result = NormalizationSource::new(bytes, MapRole::BaseColor, NormalizationOptions { gamut: GamutPolicy::Clip, ..Default::default() }) + .unwrap() + .decode(|| false) + .unwrap(); + assert!(result.receipt().clamped_rgb_channels > 0); + assert_eq!(result.receipt().gamut_policy, GamutPolicy::Clip); + assert!(result.rgba().iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); +} + +fn with_chunk(kind: [u8; 4], data: &[u8], late: bool) -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgb); + let mut writer = encoder.write_header().unwrap(); + if !late { + writer.write_chunk(png::chunk::ChunkType(kind), data).unwrap(); + } + writer.write_image_data(&[20, 40, 60]).unwrap(); + if late { + writer.write_chunk(png::chunk::ChunkType(kind), data).unwrap(); + } + } + bytes +} + +#[test] +fn malformed_or_late_semantic_chunks_never_fall_back_to_untagged_color() { + for (kind, data, late) in [ + (*b"iCCP", vec![0, 0, 1, 2, 3], false), + (*b"sRGB", vec![255], false), + (*b"gAMA", vec![0], false), + (*b"eXIf", exif(6), true), + (*b"gAMA", 100_000_u32.to_be_bytes().to_vec(), true), + ] { + let source = NormalizationSource::new(with_chunk(kind, &data, late), MapRole::BaseColor, options()).unwrap(); + assert!(source.decode(|| false).is_err(), "silently accepted {kind:?}"); + } + // Unknown ancillary metadata is left to the established codec, not blanket-refused. + assert!(NormalizationSource::new(with_chunk(*b"teSt", &[42], false), MapRole::BaseColor, options()).unwrap().decode(|| false).is_ok()); + let mut bad_crc = with_chunk(*b"gAMA", &100_000_u32.to_be_bytes(), false); + let index = bad_crc.windows(4).position(|v| v == b"gAMA").unwrap() + 8; + bad_crc[index] ^= 1; + assert!(NormalizationSource::new(bad_crc, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err()); +} + +#[test] +fn duplicate_conflicting_and_excessive_metadata_refuses() { + let mut bytes = with_chunk(*b"sRGB", &[0], false); + let srgb = bytes[33..46].to_vec(); // Original fixture's 13-byte sRGB chunk. + bytes.splice(33..33, srgb); + assert!(NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err()); + let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight); + metadata.icc_profile = Some(Cow::Owned(linear_profile())); + let mut bytes = png(metadata, &[20, 40, 60]); + let srgb = with_chunk(*b"sRGB", &[0], false)[33..46].to_vec(); + bytes.splice(33..33, srgb); + assert!(NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err()); + let mut bytes = with_chunk(*b"teSt", &[42], false); + let chunk = bytes[33..46].to_vec(); + bytes.splice(33..33, chunk.repeat(4096)); + assert!(NormalizationSource::new(bytes, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap_err().to_string().contains("4096")); +} + +#[test] +fn invalid_exif_values_types_and_offsets_refuse() { + for (offset, value) in [(18, 0), (18, 9), (12, 4), (14, 2), (4, 7)] { + let mut data = exif(1); + data[offset] = value; + let bytes = with_chunk(*b"eXIf", &data, false); + assert!(NormalizationSource::new(bytes, MapRole::Roughness, options()).unwrap().decode(|| false).is_err()); + } +} + +#[test] +fn differing_icc_channel_curves_refuse_instead_of_using_a_same_curve_optimization() { + let mut profile = moxcms::ColorProfile::new_from_slice(&linear_profile()).unwrap(); + profile.blue_trc = Some(moxcms::ToneReprCurve::Parametric(vec![2.0])); + let mut metadata = info(1, 1, png::ColorType::Rgb, png::BitDepth::Eight); + metadata.icc_profile = Some(Cow::Owned(profile.encode().unwrap())); + let error = + NormalizationSource::new(png(metadata, &[128, 128, 128]), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap_err(); + assert!(error.to_string().contains("identical RGB tone curves")); +} + +#[test] +fn jpeg_icc_conversion_and_metadata_presence_are_checked_without_fallback() { + use image::ImageEncoder; + let mut bytes = Vec::new(); + { + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 100); + encoder.set_icc_profile(linear_profile()).unwrap(); + encoder.write_image(&[128, 128, 128], 1, 1, image::ExtendedColorType::Rgb8).unwrap(); + } + let result = NormalizationSource::new(bytes.clone(), MapRole::BaseColor, Default::default()).unwrap().decode(|| false).unwrap(); + assert!((result.rgba()[0] - 128.0 / 255.0).abs() < 0.01, "linear ICC must differ from sRGB interpretation"); + let position = bytes.windows(12).position(|v| v == b"ICC_PROFILE\0").unwrap(); + let mut malformed = bytes.clone(); + malformed[position + 12] = 0; // Illegal sequence number makes the codec return None. + assert!(NormalizationSource::new(malformed, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap_err().to_string().contains("retain")); + let mut duplicate_exif = bytes.clone(); + let exif = [b"Exif\0\0".as_slice(), &exif(1)].concat(); + let segment = [vec![255, 225], u16::try_from(exif.len() + 2).unwrap().to_be_bytes().to_vec(), exif].concat(); + duplicate_exif.splice(2..2, segment.repeat(2)); + assert!(NormalizationSource::new(duplicate_exif, MapRole::BaseColor, options()).unwrap().decode(|| false).is_err()); + let mut late = bytes; + let end = late.len() - 2; + late.splice(end..end, segment); + assert!(NormalizationSource::new(late, MapRole::BaseColor, options()).unwrap().decode(|| false).unwrap_err().to_string().contains("late")); +} diff --git a/crates/sketch/src/font_tests.rs b/crates/sketch/src/font_tests.rs index d5076966..11a45fa3 100644 --- a/crates/sketch/src/font_tests.rs +++ b/crates/sketch/src/font_tests.rs @@ -223,7 +223,8 @@ fn text_in_a_font_closes_into_profiles_with_counters() { let folder = Folder::new("profiles"); let (at, height) = (v(10.0, 20.0), 7.0); let font = folder.font(); - let source = LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()), style: Default::default() }; + let source = + LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()), style: Default::default(), exprs: Default::default() }; let mut sk = Sketch::new(); sk.add_link(LinkKind::Text, source, &text_geometry_in(Some(&font), "O", at, height, 0.0).unwrap()).unwrap(); // The block with the counter in it is 8 x 7 mm minus 4 x 3 mm; the counter is a region of its own. @@ -303,6 +304,7 @@ fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() { angle: 0.0, font: font.map(str::to_string), style: Default::default(), + exprs: Default::default(), }; // No font: nothing is written, so the text of a design reads and writes as it did before fonts, and a design saved // before them reads as text in the built-in font. diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 60690953..8b08f97f 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -46,7 +46,7 @@ pub use decompose::Budget; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text}; pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check}; pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE}; -pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom}; +pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom}; pub use model::{ Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_COORD, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView, }; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 2d8c8422..4f57b9ba 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -84,9 +84,47 @@ pub enum LinkSource { /// Bold or italic, alignment, spacing and the curve the text follows. #[serde(default, skip_serializing_if = "crate::text::TextStyle::is_plain")] style: crate::text::TextStyle, + /// The expressions behind the numbers above that were typed as expressions. + #[serde(default, skip_serializing_if = "TextExprs::is_empty")] + exprs: TextExprs, }, } +/// The expressions behind a text's numbers that were typed as expressions: its place (`x`, `y`), +/// its height and its angle. A number without an expression is the number typed. The numbers +/// of `LinkSource::Text` are what the expressions gave last: the document evaluates them against +/// its parameters before the text's outlines are rebuilt (`Document::apply_text_values`), and +/// the sketch crate, which knows no parameters, only keeps them. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct TextExprs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub angle: Option, +} + +impl TextExprs { + pub fn is_empty(&self) -> bool { + *self == TextExprs::default() + } + + /// The expressions set, each with what it sets. + pub fn list(&self) -> Vec<(&'static str, &String)> { + [("Text X", &self.x), ("Text Y", &self.y), ("Text height", &self.height), ("Text angle", &self.angle)] + .into_iter() + .filter_map(|(what, e)| e.as_ref().map(|e| (what, e))) + .collect() + } + + pub fn list_mut(&mut self) -> Vec<&mut String> { + [&mut self.x, &mut self.y, &mut self.height, &mut self.angle].into_iter().filter_map(Option::as_mut).collect() + } +} + impl LinkSource { /// Short description for messages and the browser. pub fn describe(&self) -> String { @@ -482,6 +520,30 @@ impl Sketch { self.links.iter().find(|l| l.id == id) } + /// The expressions of the sketch's texts, each with what it sets (`Text height`…). + pub fn text_exprs(&self) -> Vec<(&'static str, &String)> { + self.links + .iter() + .filter_map(|l| match &l.source { + LinkSource::Text { exprs, .. } => Some(exprs.list()), + _ => None, + }) + .flatten() + .collect() + } + + /// Mutable view of [`Sketch::text_exprs`], for renaming the parameters they refer to. + pub fn text_exprs_mut(&mut self) -> Vec<&mut String> { + self.links + .iter_mut() + .filter_map(|l| match &mut l.source { + LinkSource::Text { exprs, .. } => Some(exprs.list_mut()), + _ => None, + }) + .flatten() + .collect() + } + /// Curves owned by a link, in creation order. pub fn link_curves(&self, id: &str) -> Vec { self.curves.iter().enumerate().filter(|(_, c)| c.link.as_deref() == Some(id)).map(|(i, _)| i).collect() diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 5ea713c3..2a483cc2 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -438,7 +438,9 @@ impl ConstraintKind { OffsetDistance { .. } => (vec![], vec![]), } } - pub(crate) fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> bool { + /// Renumber the points and curves the constraint refers to; false when one has no new + /// number (the constraint is then partly renumbered and should be dropped). + pub fn remap(&mut self, pmap: &dyn Fn(usize) -> Option, cmap: &dyn Fn(usize) -> Option) -> bool { use ConstraintKind::*; let mut ok = true; let mut fp = |i: &mut usize| match pmap(*i) { @@ -949,6 +951,54 @@ impl Sketch { Ok(()) } + /// The sketch scaled by `factor` about its origin, for its first dimension (#1365): drawn + /// geometry has no size of its own until a dimension gives it one, so the first length + /// dimension scales the whole sketch to its value instead of distorting it. Only a sketch + /// with nothing that fixes a size: no other driving length dimension, nothing fixed, + /// projected or patterned, no offsets and no 3D curves. Every geometric constraint + /// (coincident, horizontal, parallel, tangent, equal, symmetric, …) and every angle holds + /// on the scaled sketch as it held before. `None` when the sketch does not qualify. + pub fn scaled_for_first_dimension(&self, factor: f64) -> Option { + if !(factor.is_finite() && (1e-6..=1e6).contains(&factor)) { + return None; + } + let sizes = self.constraints.iter().any(|c| match c.kind { + ConstraintKind::Fix { .. } | ConstraintKind::PatternPoint { .. } | ConstraintKind::PatternRadius { .. } => true, + ref k => k.is_dimension() && !k.is_angle() && !c.driven, + }); + if sizes + || !self.links.is_empty() + || !self.patterns.is_empty() + || !self.offsets.is_empty() + || !self.freeform_offsets.is_empty() + || !self.wires.is_empty() + || self.curves.iter().any(|c| c.fixed || c.link.is_some()) + || self.points.iter().enumerate().any(|(i, p)| (p.fixed || p.link.is_some()) && (i != 0 || p.pos != Vec2::ZERO)) + { + return None; + } + let mut out = self.clone(); + for p in out.points.iter_mut().filter(|p| !p.fixed) { + p.pos = p.pos * factor; + if !in_range(p.pos) { + return None; + } + } + for c in &mut out.curves { + if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind { + *r *= factor; + if !radius_in_range(*r) { + return None; + } + } + } + // Dimension texts keep their places relative to what they measure. + for t in out.constraints.iter_mut().filter_map(|c| c.text.as_mut()) { + *t = *t * factor; + } + Some(out) + } + /// Is point `i` held in place: fixed, projected (linked), fixed by a Fix constraint or a /// point of a fixed curve. pub fn point_locked(&self, i: usize) -> bool { diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 9278b4fd..823a17a3 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -650,7 +650,7 @@ fn merging_adjacent_regions() { /// outlines. fn text_sketch(text: &str, at: Vec2, height: f64) -> Sketch { let mut sk = Sketch::new(); - let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default() }; + let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default(), exprs: Default::default() }; sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap(); sk } diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs index d5388758..4b8d6287 100644 --- a/crates/sketch/src/text.rs +++ b/crates/sketch/src/text.rs @@ -375,7 +375,7 @@ impl Sketch { /// The outline geometry of a text source of this sketch: its path, when it has one, is a /// curve of this sketch as it is now. pub fn text_source_geometry(&self, src: &LinkSource) -> Result> { - let LinkSource::Text { text, at, height, angle, font, style } = src else { + let LinkSource::Text { text, at, height, angle, font, style, .. } = src else { return Err(SketchError::Invalid("not a text".into())); }; let path = match &style.path { diff --git a/crates/sketch/src/text_style_tests.rs b/crates/sketch/src/text_style_tests.rs index 397b24f2..e4dc2d25 100644 --- a/crates/sketch/src/text_style_tests.rs +++ b/crates/sketch/src/text_style_tests.rs @@ -134,6 +134,7 @@ fn a_sketch_sets_its_text_on_one_of_its_curves() { angle: 0.7, font: None, style: TextStyle { path: Some(path.into()), ..TextStyle::default() }, + exprs: Default::default(), }; // On the line: `at` and the angle do not place it. let on = sk.text_source_geometry(&source(&id)).unwrap(); @@ -149,7 +150,15 @@ fn a_sketch_sets_its_text_on_one_of_its_curves() { #[test] fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() { - let plain = LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 5.0, angle: 0.0, font: None, style: TextStyle::default() }; + let plain = LinkSource::Text { + text: "A".into(), + at: Vec2::ZERO, + height: 5.0, + angle: 0.0, + font: None, + style: TextStyle::default(), + exprs: Default::default(), + }; let json = serde_json::to_value(&plain).unwrap(); assert!(json.get("style").is_none(), "{json}"); let styled = LinkSource::Text { @@ -167,6 +176,7 @@ fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() { path: Some("l3".into()), reverse: true, }, + exprs: Default::default(), }; let json = serde_json::to_value(&styled).unwrap(); assert_eq!(json["style"]["align"], "right"); diff --git a/crates/ui-egui/examples/acp_test_agent.rs b/crates/ui-egui/examples/acp_test_agent.rs new file mode 100644 index 00000000..f4121319 --- /dev/null +++ b/crates/ui-egui/examples/acp_test_agent.rs @@ -0,0 +1,226 @@ +//! A scripted ACP agent for trying the agent chat without an account (docs/agent-chat.md): +//! it speaks the Agent Client Protocol on stdin and stdout like the real adapters, starts the MCP +//! server the chat gives it (`solidcraft-cli mcp --connect …`) and models through it. No model +//! runs: what it does is fixed. +//! +//! ```text +//! cargo build --release -p solidcraft-ui-egui --example acp_test_agent +//! # Preferences → General → Agent chat → Add custom command: target/release/examples/acp_test_agent +//! ``` +//! +//! A message: a plan, a box made with one `batch`, a fillet, a measurement. A message with "save" +//! in it only saves to the user's Documents folder (which the chat holds for Allow or Deny); +//! with "ask" in it the agent first asks its own permission for the batch. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +fn send(v: &Value) { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{v}"); + let _ = out.flush(); +} + +fn update(session: &str, u: Value) { + send(&json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session, "update": u}})); +} + +fn say(session: &str, text: &str) { + update(session, json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}})); +} + +fn tool(session: &str, id: &str, title: &str, kind: &str, status: &str, output: Option<&str>) { + let mut u = json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "title": title, "kind": kind, "status": status}); + if status == "pending" { + u["sessionUpdate"] = json!("tool_call"); + } + if let Some(o) = output { + u["content"] = json!([{"type": "content", "content": {"type": "text", "text": o}}]); + } + update(session, u); +} + +fn plan(session: &str, done: usize) { + let steps = ["Make a 40 × 30 × 20 mm block", "Round its vertical edges", "Measure it"]; + let entries: Vec = steps + .iter() + .enumerate() + .map(|(i, s)| { + let status = if i < done { + "completed" + } else if i == done { + "in_progress" + } else { + "pending" + }; + json!({"content": s, "priority": "medium", "status": status}) + }) + .collect(); + update(session, json!({"sessionUpdate": "plan", "entries": entries})); +} + +/// The MCP server the chat handed over, as a child speaking JSON-RPC lines. +struct Mcp { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next: u64, +} + +impl Mcp { + fn start(server: &Value) -> Option { + let program = server.get("command")?.as_str()?; + let args: Vec = server.get("args")?.as_array()?.iter().filter_map(|a| a.as_str().map(str::to_string)).collect(); + let mut child = Command::new(program).args(&args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let stdin = child.stdin.take()?; + let stdout = BufReader::new(child.stdout.take()?); + let mut m = Mcp { child, stdin, stdout, next: 0 }; + m.call("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "acp-test-agent", "version": "1"}}))?; + let _ = writeln!(m.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + Some(m) + } + + fn call(&mut self, method: &str, params: Value) -> Option { + self.next += 1; + let id = self.next; + writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).ok()?; + self.stdin.flush().ok()?; + loop { + let mut line = String::new(); + if self.stdout.read_line(&mut line).ok()? == 0 { + return None; + } + let v: Value = serde_json::from_str(line.trim()).ok()?; + if v.get("id").and_then(Value::as_u64) == Some(id) { + return Some(v); + } + } + } + + /// A tool's text and whether it failed. + fn tool(&mut self, name: &str, args: Value) -> (String, bool) { + match self.call("tools/call", json!({"name": name, "arguments": args})) { + Some(r) => { + let text = r.pointer("/result/content/0/text").and_then(Value::as_str).unwrap_or("").to_string(); + let failed = r.pointer("/result/isError").and_then(Value::as_bool).unwrap_or(false) || r.get("error").is_some(); + let text = if text.is_empty() { r.get("error").map(Value::to_string).unwrap_or_default() } else { text }; + (text.chars().take(600).collect(), failed) + } + None => ("the MCP server did not answer".into(), true), + } + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn main() { + let session = "test-session-1"; + let mut mcp: Option = None; + let stdin = std::io::stdin(); + let mut lines = stdin.lock().lines(); + while let Some(Ok(line)) = lines.next() { + let Ok(msg) = serde_json::from_str::(line.trim()) else { continue }; + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let reply = |result: Value| send(&json!({"jsonrpc": "2.0", "id": id, "result": result})); + match msg.get("method").and_then(Value::as_str) { + Some("initialize") => reply(json!({ + "protocolVersion": 1, + "agentInfo": {"name": "acp-test-agent", "title": "SolidCraft test agent", "version": env!("CARGO_PKG_VERSION")}, + "agentCapabilities": {"promptCapabilities": {"image": true}}, + "authMethods": [], + })), + Some("session/new") => { + mcp = msg.pointer("/params/mcpServers/0").and_then(Mcp::start); + reply(json!({"sessionId": session, "modes": {"currentModeId": "default", "availableModes": [ + {"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept edits"}, {"id": "plan", "name": "Plan"}, + ]}})); + } + Some("session/prompt") => { + let text: String = msg + .pointer("/params/prompt") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|b| b.get("text").and_then(Value::as_str)).collect::>().join(" ")) + .unwrap_or_default() + .to_lowercase(); + let images = msg.pointer("/params/prompt").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|b| b["type"] == "image").count()); + let Some(m) = mcp.as_mut() else { + say(session, "I have no SolidCraft tools in this session."); + reply(json!({"stopReason": "end_turn"})); + continue; + }; + // "save": only the save (the design is there already). + if text.contains("save") { + let path = std::env::var_os("HOME").map(std::path::PathBuf::from).unwrap_or_default().join("Documents/block.solidcraft"); + let title = format!("save {}", path.display()); + say(session, "I'll save the design to your Documents folder."); + tool(session, "t4", &title, "edit", "in_progress", None); + let (out, failed) = m.tool("save", json!({"path": path.to_string_lossy()})); + tool(session, "t4", &title, "edit", if failed { "failed" } else { "completed" }, Some(&out)); + say(session, if failed { "\nThe save didn't go through." } else { "\nSaved." }); + reply(json!({"stopReason": "end_turn"})); + continue; + } + plan(session, 0); + if images > 0 { + say(session, "Thanks for the picture of the view. "); + } + say(session, "I'll make a 40 × 30 × 20 mm block with one batch, round its vertical edges, then measure it."); + if text.contains("ask") { + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "pending", None); + send(&json!({"jsonrpc": "2.0", "id": 900, "method": "session/request_permission", "params": { + "sessionId": session, + // Named as the Claude Code adapter names an MCP tool. + "toolCall": {"toolCallId": "t1", "title": "mcp__solidcraft__batch"}, + "options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}, {"optionId": "reject", "name": "Reject", "kind": "reject_once"}], + }})); + // Wait for the answer (other lines are not expected meanwhile). + let mut allowed = false; + for l in lines.by_ref().map_while(Result::ok) { + let Ok(a) = serde_json::from_str::(l.trim()) else { continue }; + if a.get("id").and_then(Value::as_u64) == Some(900) { + allowed = a.pointer("/result/outcome/optionId").and_then(Value::as_str) == Some("allow"); + break; + } + } + if !allowed { + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "failed", Some("rejected")); + say(session, "\nOK, I left the design as it was."); + reply(json!({"stopReason": "end_turn"})); + continue; + } + } + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "in_progress", None); + let (out, failed) = m.tool( + "batch", + json!({"commands": [{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0], "body_name": "Block"}}], "verbose": false}), + ); + tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", if failed { "failed" } else { "completed" }, Some(&out)); + plan(session, 1); + // The block's vertical edges, by a point on each (its corner is at the origin). + let vertical = json!([[0, 0, 10], [40, 0, 10], [40, 30, 10], [0, 30, 10]]); + tool(session, "t2", "execute: solid.fillet r = 4 mm", "edit", "in_progress", None); + let (out, failed) = + m.tool("execute", json!({"command": "solid.fillet", "params": {"edges": vertical, "radius": 4}, "verbose": false})); + tool(session, "t2", "execute: solid.fillet r = 4 mm", "edit", if failed { "failed" } else { "completed" }, Some(&out)); + plan(session, 2); + tool(session, "t3", "measure", "read", "in_progress", None); + let (out, failed) = m.tool("measure", json!({})); + tool(session, "t3", "measure", "read", if failed { "failed" } else { "completed" }, Some(&out)); + plan(session, 3); + say(session, "\nDone: the block is 40 × 30 × 20 mm with 4 mm rounds on its vertical edges."); + reply(json!({"stopReason": "end_turn"})); + } + Some("session/set_mode") => reply(json!({})), + Some("session/cancel") => {} + Some(_) if !id.is_null() => send(&json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": "method not found"}})), + _ => {} + } + } +} diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs new file mode 100644 index 00000000..20814ad5 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -0,0 +1,499 @@ +//! The client side of the Agent Client Protocol (ACP, ), version 1: +//! JSON-RPC 2.0, one message per line over the agent's stdin/stdout. This file only builds and +//! reads messages; the process lives in `process.rs` and the state in `mod.rs`, so everything +//! here is plain data and tested without an agent. +//! +//! What SolidCraft says it can do (`clientCapabilities`): no file system and no terminal (the agent +//! reaches the design through SolidCraft's MCP server instead), and terminal login, so an agent +//! that needs one describes a command for the user to run rather than asking for a key. + +use serde_json::{Value, json}; + +/// The protocol version we speak. +pub const PROTOCOL_VERSION: u64 = 1; + +/// JSON-RPC "method not found", our answer to every agent request we don't serve. +pub const METHOD_NOT_FOUND: i64 = -32601; +/// The error code ACP agents use for "authentication required". +pub const AUTH_REQUIRED: i64 = -32000; + +/// One request or notification to send. +pub fn request(id: u64, method: &str, params: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) +} +pub fn notification(method: &str, params: Value) -> Value { + json!({"jsonrpc": "2.0", "method": method, "params": params}) +} +pub fn reply(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} +pub fn reply_error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +pub fn initialize_params(version: &str) -> Value { + json!({ + "protocolVersion": PROTOCOL_VERSION, + "clientCapabilities": { + "fs": {"readTextFile": false, "writeTextFile": false}, + "terminal": false, + "auth": {"terminal": true}, + "_meta": {"terminal-auth": true}, + }, + "clientInfo": {"name": "solidcraft", "title": "SolidCraft", "version": version}, + }) +} + +/// The MCP server the agent starts to reach the design: `solidcraft-cli mcp --connect` bridged to +/// this app's control port. The server finds the port's token in the token file the app wrote +/// (docs/control-protocol.md), so the token never travels through the agent, and it keeps its +/// default folder limits (docs/mcp.md): no `--allow-any-path`. +#[derive(Clone, Debug, PartialEq)] +pub struct McpBridge { + pub cli: std::path::PathBuf, + pub port: u16, +} + +impl McpBridge { + pub fn to_json(&self) -> Value { + json!({ + "name": "solidcraft", + "command": self.cli.to_string_lossy(), + "args": ["mcp", "--connect", format!("127.0.0.1:{}", self.port)], + "env": [], + }) + } +} + +/// What every session is told about where it is. Agents that don't read `_meta` ignore it. +pub const SYSTEM_NOTE: &str = "You are running inside SolidCraft, a parametric 3D CAD application, as the user's assistant. \ +The open design is reachable through the `solidcraft` MCP server: use its tools (list_commands, batch, execute, inspect_design, \ +measure, screenshot, set_parameter, undo) to read and change the design the user is looking at, and verify with measure or \ +inspect_design. Prefer one `batch` over many small calls. Saving, opening or exporting files waits for the user's approval in \ +the chat, and each message allows a limited number of changes. Don't use shell or file tools: work through the SolidCraft tools. \ +Keep answers short."; + +/// The Claude Code adapter's own tools that reach the computer rather than the design: turned off +/// for the session (`_meta.claudeCode.options.disallowedTools`), so it works through SolidCraft's. +pub const CLAUDE_DISALLOWED_TOOLS: &[&str] = + &["Bash", "BashOutput", "KillShell", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task"]; + +pub fn session_new_params(cwd: &std::path::Path, bridge: Option<&McpBridge>) -> Value { + let servers: Vec = bridge.map(McpBridge::to_json).into_iter().collect(); + json!({ + "cwd": cwd.to_string_lossy(), + "mcpServers": servers, + "_meta": { + "systemPrompt": {"append": SYSTEM_NOTE}, + // Hosts may opt a session out of "bypass permissions" (the Claude adapter reads it): + // every tool call the agent asks about reaches the panel. + // Its own shell and file tools are off: the agent works through SolidCraft's tools. + "claudeCode": {"options": {"allowDangerouslySkipPermissions": false, "disallowedTools": CLAUDE_DISALLOWED_TOOLS}}, + }, + }) +} + +pub fn prompt_params(session: &str, parts: &[super::backend::PromptPart]) -> Value { + json!({"sessionId": session, "prompt": super::backend::content_blocks(parts)}) +} + +/// The agent's modes in a `session/new` reply: the current one and each `(id, name)`. +pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> { + let m = session.get("modes")?; + let current = m.get("currentModeId").and_then(Value::as_str).unwrap_or("").to_string(); + let available: Vec<(String, String)> = m + .get("availableModes")? + .as_array()? + .iter() + .take(16) + .filter_map(|x| { + let id = x.get("id")?.as_str()?.to_string(); + let name = x.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(); + Some((id, name)) + }) + .collect(); + (!available.is_empty()).then_some((current, available)) +} + +/// Whether the agent takes pictures in a message (`agentCapabilities.promptCapabilities.image`). +pub fn takes_images(init: &Value) -> bool { + init.pointer("/agentCapabilities/promptCapabilities/image").and_then(Value::as_bool).unwrap_or(false) +} + +/// One way to log in that the agent offers (`authMethods` in its `initialize` reply). +#[derive(Clone, Debug, PartialEq)] +pub struct AuthMethod { + pub id: String, + pub name: String, + pub description: String, + pub kind: AuthKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AuthKind { + /// The agent signs in by itself when asked (`authenticate`), e.g. by opening a browser. + Agent, + /// The user runs the agent's own login in a terminal: the agent's command plus `args`. + Terminal { args: Vec, command: Option<(String, Vec)> }, + /// The agent reads a key from an environment variable the user sets themselves. + EnvVar { vars: Vec }, +} + +pub fn auth_methods(init: &Value) -> Vec { + let strings = |v: Option<&Value>| -> Vec { + v.and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + init.get("authMethods") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(32) + .filter_map(|m| { + let s = |k: &str| m.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let id = s("id"); + if id.is_empty() { + return None; + } + let kind = match m.get("type").and_then(Value::as_str) { + Some("terminal") => { + let meta = m.get("_meta").and_then(|x| x.get("terminal-auth")); + let command = meta.and_then(|t| Some((t.get("command")?.as_str()?.to_string(), strings(t.get("args"))))); + AuthKind::Terminal { args: strings(m.get("args")), command } + } + Some("env_var") => AuthKind::EnvVar { + vars: m + .get("vars") + .and_then(Value::as_array) + .map(|v| v.iter().take(16).filter_map(|x| x.get("name").and_then(Value::as_str).map(str::to_string)).collect()) + .unwrap_or_default(), + }, + // A key the agent would take from the client: SolidCraft never handles + // keys, so this becomes "set it in your environment" instead. + _ if m.get("_meta").and_then(|x| x.get("api-key")).is_some() => { + let provider = m + .get("_meta") + .and_then(|x| x.get("api-key")) + .and_then(|k| k.get("provider")) + .and_then(Value::as_str) + .unwrap_or("agent"); + AuthKind::EnvVar { vars: vec![format!("{}_API_KEY", provider.to_ascii_uppercase())] } + } + _ => AuthKind::Agent, + }; + Some(AuthMethod { id, name: s("name"), description: s("description"), kind }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The agent's name and version from its `initialize` reply. +pub fn agent_info(init: &Value) -> String { + let i = init.get("agentInfo"); + let s = |k: &str| i.and_then(|i| i.get(k)).and_then(Value::as_str); + match (s("title").or(s("name")), s("version")) { + (Some(n), Some(v)) => format!("{n} {v}"), + (Some(n), None) => n.to_string(), + _ => String::new(), + } +} + +/// A tool call's state as the agent reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolStatus { + Pending, + InProgress, + Completed, + Failed, +} + +impl ToolStatus { + pub fn parse(s: &str) -> Option { + Some(match s { + "pending" => ToolStatus::Pending, + "in_progress" => ToolStatus::InProgress, + "completed" => ToolStatus::Completed, + "failed" => ToolStatus::Failed, + _ => return None, + }) + } +} + +/// One choice of a permission request. +#[derive(Clone, Debug, PartialEq)] +pub struct PermissionOption { + pub id: String, + pub name: String, + /// `allow_once`, `allow_always`, `reject_once` or `reject_always`. + pub kind: String, +} + +/// What a message from the agent means for the panel. +#[derive(Clone, Debug, PartialEq)] +pub enum Incoming { + /// The reply to one of our requests. + Reply { id: u64, result: Result }, + /// Text the agent streams (`thought`: its reasoning, shown dimmed). + Text { thought: bool, text: String }, + /// A new tool call, or news about one (`title`/`status` when they changed). + Tool { id: String, title: Option, kind: Option, status: Option, output: Option }, + /// The agent's plan: each entry with its status. + Plan(Vec<(String, String)>), + /// The agent asks before running a tool call; answered with [`permission_reply`]. + Permission { rpc: Value, title: String, options: Vec }, + /// The agent says whether it is logged in (an extension both adapters send). + AuthStatus(String), + /// The agent switched to another of its modes. + ModeChanged(String), + /// A request we don't serve (file system, terminal, …): answer "method not found". + Unsupported { rpc: Value, method: String }, + /// Anything else (mode and command lists, usage): nothing to show. + Ignored, +} + +/// The longest text kept from one tool call's output. +const MAX_OUTPUT: usize = 2000; + +fn content_text(c: &Value) -> Option { + // A content block, or a tool call content item wrapping one (`{type: "content", content}`). + let block = if c.get("type").and_then(Value::as_str) == Some("content") { c.get("content")? } else { c }; + match block.get("type").and_then(Value::as_str) { + Some("text") => block.get("text").and_then(Value::as_str).map(str::to_string), + Some("image") => Some("[image]".into()), + Some("resource_link") => block.get("uri").and_then(Value::as_str).map(|u| format!("[{u}]")), + Some("diff") => block.get("path").and_then(Value::as_str).map(|p| format!("[diff {p}]")), + Some("terminal") => Some("[terminal]".into()), + _ => None, + } +} + +fn tool_output(u: &Value) -> Option { + let parts: Vec = u.get("content")?.as_array()?.iter().take(16).filter_map(content_text).collect(); + if parts.is_empty() { + return None; + } + let mut s = parts.join("\n"); + if s.len() > MAX_OUTPUT { + let mut cut = MAX_OUTPUT; + while !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + s.push('…'); + } + Some(s) +} + +/// Read one line from the agent. +pub fn parse(line: &str) -> Option { + let msg: Value = serde_json::from_str(line.trim()).ok()?; + let method = msg.get("method").and_then(Value::as_str); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + match (method, msg.get("id")) { + (None, Some(id)) => { + let id = id.as_u64()?; + let result = match msg.get("error") { + Some(e) => { + Err((e.get("code").and_then(Value::as_i64).unwrap_or(0), e.get("message").and_then(Value::as_str).unwrap_or("error").to_string())) + } + None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)), + }; + Some(Incoming::Reply { id, result }) + } + (Some("session/request_permission"), Some(id)) => { + let call = params.get("toolCall"); + let title = call.and_then(|c| c.get("title")).and_then(Value::as_str).unwrap_or("").to_string(); + let options = params + .get("options") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(8) + .filter_map(|o| { + Some(PermissionOption { + id: o.get("optionId")?.as_str()?.to_string(), + name: o.get("name").and_then(Value::as_str).unwrap_or("").to_string(), + kind: o.get("kind").and_then(Value::as_str).unwrap_or("").to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + Some(Incoming::Permission { rpc: id.clone(), title, options }) + } + (Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }), + (Some("session/update"), None) => Some(update(params.get("update")?)), + (Some("_auth/status_update"), None) => { + params.get("authStatus").and_then(|s| s.get("label")).and_then(Value::as_str).map(|l| Incoming::AuthStatus(l.to_string())) + } + _ => Some(Incoming::Ignored), + } +} + +fn update(u: &Value) -> Incoming { + let s = |k: &str| u.get(k).and_then(Value::as_str).map(str::to_string); + match u.get("sessionUpdate").and_then(Value::as_str) { + Some(kind @ ("agent_message_chunk" | "agent_thought_chunk")) => match u.get("content").and_then(content_text) { + Some(text) => Incoming::Text { thought: kind == "agent_thought_chunk", text }, + None => Incoming::Ignored, + }, + Some("tool_call" | "tool_call_update") => match s("toolCallId") { + Some(id) => Incoming::Tool { + id, + title: s("title"), + kind: s("kind"), + status: u.get("status").and_then(Value::as_str).and_then(ToolStatus::parse), + output: tool_output(u), + }, + None => Incoming::Ignored, + }, + Some("current_mode_update") => match s("currentModeId").or_else(|| s("modeId")) { + Some(m) => Incoming::ModeChanged(m), + None => Incoming::Ignored, + }, + Some("plan") => Incoming::Plan( + u.get("entries") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(64) + .map(|e| { + let g = |k: &str| e.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + (g("content"), g("status")) + }) + .collect() + }) + .unwrap_or_default(), + ), + _ => Incoming::Ignored, + } +} + +/// The answer to a permission request: the option picked, or `None` for "cancelled". +pub fn permission_reply(rpc: Value, option: Option<&str>) -> Value { + let outcome = match option { + Some(o) => json!({"outcome": "selected", "optionId": o}), + None => json!({"outcome": "cancelled"}), + }; + reply(rpc, json!({"outcome": outcome})) +} + +/// Split a command line into the program and its arguments: whitespace separates, double or +/// single quotes group, a backslash before a quote keeps it. No shell runs it. +pub fn split_command(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut started = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match (quote, c) { + (_, '\\') if matches!(chars.peek(), Some('"') | Some('\'')) => { + if let Some(n) = chars.next() { + cur.push(n); + } + started = true; + } + (None, '"' | '\'') => { + quote = Some(c); + started = true; + } + (Some(q), _) if c == q => quote = None, + (None, c) if c.is_whitespace() => { + if started { + out.push(std::mem::take(&mut cur)); + started = false; + } + } + _ => { + cur.push(c); + started = true; + } + } + } + if started { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commands_split_like_a_shell_without_one() { + assert_eq!(split_command("gemini --acp"), ["gemini", "--acp"]); + assert_eq!(split_command(r#" "C:\Program Files\x\agent.exe" --flag 'a b' "#), [r"C:\Program Files\x\agent.exe", "--flag", "a b"]); + assert_eq!(split_command(r#"a "" b"#), ["a", "", "b"]); + assert!(split_command(" ").is_empty()); + } + + #[test] + fn the_session_gets_the_bridged_mcp_server_without_a_token_or_any_path() { + let b = McpBridge { cli: "/opt/solidcraft/solidcraft-cli".into(), port: 41234 }; + let p = session_new_params(std::path::Path::new("/home/u/designs"), Some(&b)); + let s = &p["mcpServers"][0]; + assert_eq!(s["name"], "solidcraft"); + assert_eq!(s["args"], json!(["mcp", "--connect", "127.0.0.1:41234"])); + let text = p.to_string(); + assert!(!text.contains("allow-any-path") && !text.contains("TOKEN") && !text.contains("token"), "{text}"); + assert!(p["_meta"]["claudeCode"]["options"]["disallowedTools"].as_array().is_some_and(|t| t.iter().any(|x| x == "Bash"))); + assert_eq!(session_new_params(std::path::Path::new("/tmp"), None)["mcpServers"], json!([])); + } + + #[test] + fn auth_methods_of_both_adapters_are_read() { + // As the Claude adapter offers them to a client with terminal login. + let claude = json!({"authMethods": [{"description": "Use Claude subscription ", "name": "Claude Subscription", "id": "claude-ai-login", + "type": "terminal", "args": ["--cli", "auth", "login", "--claudeai"], + "_meta": {"terminal-auth": {"command": "/usr/bin/node", "args": ["/x/dist/index.js", "--cli", "auth", "login", "--claudeai"], "label": "Claude Login"}}}]}); + let m = auth_methods(&claude); + assert_eq!(m.len(), 1); + let AuthKind::Terminal { args, command } = &m[0].kind else { panic!("{m:?}") }; + assert_eq!(args.last().map(String::as_str), Some("--claudeai")); + assert_eq!(command.as_ref().map(|c| c.0.as_str()), Some("/usr/bin/node")); + // As the Codex adapter offers them. + let codex = json!({"authMethods": [{"id": "chatgpt", "name": "Login with ChatGPT"}, + {"type": "env_var", "id": "openai-api-key", "name": "Use OPENAI_API_KEY", "vars": [{"name": "OPENAI_API_KEY"}]}]}); + let m = auth_methods(&codex); + assert_eq!(m[0].kind, AuthKind::Agent); + // Codex 2.x offers its key login as a method the client would feed: shown as a variable. + let codex2 = json!({"authMethods": [{"id": "api-key", "name": "API Key", "_meta": {"api-key": {"provider": "openai"}}}]}); + assert_eq!(auth_methods(&codex2)[0].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] }); + assert_eq!(m[1].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] }); + } + + #[test] + fn updates_permissions_and_replies_are_told_apart() { + let text = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hi"}}}}"#; + assert_eq!(parse(text), Some(Incoming::Text { thought: false, text: "Hi".into() })); + let tool = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"t1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ok"}}]}}}"#; + assert_eq!( + parse(tool), + Some(Incoming::Tool { id: "t1".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) }) + ); + let perm = r#"{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"toolCallId":"t1","title":"batch"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}"#; + let Some(Incoming::Permission { rpc, title, options }) = parse(perm) else { panic!() }; + assert_eq!((title.as_str(), options.len(), options[1].kind.as_str()), ("batch", 2, "reject_once")); + assert_eq!(permission_reply(rpc.clone(), Some("allow"))["result"]["outcome"], json!({"outcome": "selected", "optionId": "allow"})); + assert_eq!(permission_reply(rpc, None)["result"]["outcome"]["outcome"], "cancelled"); + let fs = r#"{"jsonrpc":"2.0","id":8,"method":"fs/read_text_file","params":{}}"#; + assert!(matches!(parse(fs), Some(Incoming::Unsupported { .. }))); + let err = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"Authentication required"}}"#; + assert_eq!(parse(err), Some(Incoming::Reply { id: 3, result: Err((AUTH_REQUIRED, "Authentication required".into())) })); + assert_eq!(parse("not json"), None); + let mode = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}}"#; + assert_eq!(parse(mode), Some(Incoming::ModeChanged("plan".into()))); + let new = json!({"sessionId": "s", "modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept Edits"}]}}); + assert_eq!(modes(&new), Some(("default".into(), vec![("default".into(), "Default".into()), ("acceptEdits".into(), "Accept Edits".into())]))); + } + + #[test] + fn long_tool_output_is_cut_on_a_character_boundary() { + let long = "é".repeat(3000); + let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t", "content": [{"type": "content", "content": {"type": "text", "text": long}}]}); + let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() }; + assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…')); + } +} diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs new file mode 100644 index 00000000..6e11bd49 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -0,0 +1,246 @@ +//! The ACP backend (desktop only): starts the agent the user picked as a child process and holds +//! the protocol's side of the conversation (initialize, the session, prompts, permission questions, +//! sign-in). The panel sees only [`Event`]s. + +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use super::acp::{self, Incoming}; +use super::backend::{ChatBackend, Event, PromptPart, StartContext}; +use super::process::{AgentProcess, FromAgent}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Pending { + Initialize, + SessionNew, + Prompt, + Authenticate, + SetMode, +} + +/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port +/// would otherwise reach the agent and outrank the chat endpoint's own token file. +const ENV_REMOVE: &[&str] = &["SOLIDCRAFT_CONTROL_TOKEN"]; + +pub struct AcpBackend { + proc: AgentProcess, + cwd: std::path::PathBuf, + bridge: Option, + next_id: u64, + pending: HashMap, + session: Option, + agent: String, + images: bool, + auth: Vec, + /// Open permission questions: our key → the agent's request id. + permissions: HashMap, + next_key: u64, + exited: bool, +} + +impl AcpBackend { + /// Start `argv` and say hello (`initialize`); the session follows when the agent answers. + pub fn start(argv: &[String], ctx: StartContext) -> Result { + let wake = ctx.wake; + let proc = AgentProcess::spawn(argv, &ctx.cwd, ENV_REMOVE, wake)?; + let mut b = AcpBackend { + proc, + cwd: ctx.cwd, + bridge: ctx.bridge, + next_id: 0, + pending: HashMap::new(), + session: None, + agent: String::new(), + images: false, + auth: Vec::new(), + permissions: HashMap::new(), + next_key: 0, + exited: false, + }; + b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))); + Ok(b) + } + + fn request(&mut self, what: Pending, method: &str, params: Value) { + self.next_id += 1; + self.pending.insert(self.next_id, what); + self.proc.send(&acp::request(self.next_id, method, params)); + } + + fn new_session(&mut self) { + let params = acp::session_new_params(&self.cwd, self.bridge.as_ref()); + self.request(Pending::SessionNew, "session/new", params); + } + + fn handle(&mut self, msg: Incoming, out: &mut Vec) { + match msg { + Incoming::Reply { id, result } => { + let Some(what) = self.pending.remove(&id) else { return }; + match (what, result) { + (Pending::Initialize, Ok(v)) => { + self.agent = acp::agent_info(&v); + self.images = acp::takes_images(&v); + self.auth = acp::auth_methods(&v); + self.new_session(); + } + (Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) { + Some(s) => { + self.session = Some(s.to_string()); + if let Some((current, available)) = acp::modes(&v) { + out.push(Event::Modes { current, available }); + } + out.push(Event::Ready { agent: self.agent.clone(), images: self.images }); + } + None => out.push(Event::Error("session/new: no sessionId".into())), + }, + (Pending::Prompt, Ok(v)) => { + let reason = v.get("stopReason").and_then(Value::as_str).unwrap_or(""); + out.push(Event::TurnEnded { reason: if reason == "end_turn" { String::new() } else { reason.to_string() } }); + } + // Signed in: try the session again. + (Pending::Authenticate, Ok(_)) => self.new_session(), + (Pending::SetMode, Ok(_)) => {} + (what, Err((code, message))) => { + let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth"); + if auth { + self.session = None; + if what == Pending::Prompt { + out.push(Event::TurnEnded { reason: "auth".into() }); + } + out.push(Event::AuthRequired { methods: self.auth.clone() }); + } else { + if what == Pending::Prompt { + out.push(Event::TurnEnded { reason: "error".into() }); + } + out.push(Event::Error(message)); + } + } + } + } + Incoming::Text { thought, text } => out.push(Event::Text { thought, text }), + Incoming::Tool { id, title, kind, status, output } => out.push(Event::Tool { id, title, kind, status, output }), + Incoming::Plan(p) => out.push(Event::Plan(p)), + Incoming::Permission { rpc, title, options } => { + self.next_key += 1; + self.permissions.insert(self.next_key, rpc); + out.push(Event::Permission { key: self.next_key, title, options }); + } + Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)), + Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }), + Incoming::Unsupported { rpc, method } => { + self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`"))); + } + Incoming::Ignored => {} + } + } +} + +impl ChatBackend for AcpBackend { + fn poll(&mut self) -> Vec { + let mut out = Vec::new(); + if self.exited { + return out; + } + for _ in 0..512 { + match self.proc.from_agent.try_recv() { + Ok(FromAgent::Line(l)) => { + if let Some(m) = acp::parse(&l) { + self.handle(m, &mut out); + } + } + Ok(FromAgent::Exited(tail)) => { + self.exited = true; + self.session = None; + self.pending.clear(); + out.push(Event::Exited { tail }); + break; + } + Err(_) => break, + } + } + out + } + + fn prompt(&mut self, parts: &[PromptPart]) { + if let Some(s) = self.session.clone() { + self.request(Pending::Prompt, "session/prompt", acp::prompt_params(&s, parts)); + } + } + + fn cancel(&mut self) { + // Open permission questions are answered "cancelled" first, as the protocol asks. + for (_, rpc) in self.permissions.drain() { + self.proc.send(&acp::permission_reply(rpc, None)); + } + if let Some(s) = &self.session { + self.proc.send(&acp::notification("session/cancel", json!({"sessionId": s}))); + } + } + + fn answer_permission(&mut self, key: u64, option: Option<&str>) { + if let Some(rpc) = self.permissions.remove(&key) { + self.proc.send(&acp::permission_reply(rpc, option)); + } + } + + fn authenticate(&mut self, method: &str) { + self.request(Pending::Authenticate, "authenticate", json!({"methodId": method})); + } + + fn set_mode(&mut self, mode: &str) { + if let Some(s) = self.session.clone() { + self.request(Pending::SetMode, "session/set_mode", json!({"sessionId": s, "modeId": mode})); + } + } + + fn pid(&self) -> Option { + self.proc.pid() + } + + fn shutdown(&mut self, wait: bool) { + self.proc.shutdown(wait); + } +} + +/// Test connection: start the agent, say hello, open a session without tools, end it. What it +/// answered (its name and version), or what went wrong (it isn't signed in, it didn't answer). +pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result { + let mut p = AgentProcess::spawn(argv, cwd, ENV_REMOVE, || {})?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let wait_for = |p: &AgentProcess, id: u64| -> Result { + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + match p.from_agent.recv_timeout(left) { + Ok(FromAgent::Line(l)) => { + if let Some(Incoming::Reply { id: got, result }) = acp::parse(&l) + && got == id + { + return result.map_err(|(code, m)| { + if code == acp::AUTH_REQUIRED || m.to_ascii_lowercase().contains("auth") { + crate::i18n::tr("ui.agent_chat_state_auth").to_string() + } else { + m + } + }); + } + } + Ok(FromAgent::Exited(tail)) => { + return Err(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string())); + } + Err(_) => return Err(crate::i18n::tr("ui.ai_no_answer").to_string()), + } + } + }; + p.send(&acp::request(1, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION")))); + let init = wait_for(&p, 1); + let result = init.and_then(|v| { + p.send(&acp::request(2, "session/new", acp::session_new_params(cwd, None))); + wait_for(&p, 2).map(|_| { + let who = acp::agent_info(&v); + crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })]) + }) + }); + p.shutdown(true); + result +} diff --git a/crates/ui-egui/src/agent_chat/agents.rs b/crates/ui-egui/src/agent_chat/agents.rs new file mode 100644 index 00000000..865f7c47 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/agents.rs @@ -0,0 +1,234 @@ +//! The agents the chat can use: the installed ones it finds by itself (Claude Code, Codex, +//! Gemini CLI, OpenCode, …) and the ones the user adds (another ACP command, or an +//! OpenAI-compatible endpoint such as Ollama, LM Studio or OpenAI). Also the permission modes. +//! Preferences only: no design data, no keys (those are in the system keychain). + +use serde::{Deserialize, Serialize}; + +/// How much the agent may do without asking. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Mode { + /// Every change to the design, file and destructive action asks first. + Ask, + /// Modelling commands run; files and destructive actions still ask (the default). + #[default] + Edits, + /// Nothing asks (crash reports and the folder limits still hold). + All, +} + +impl Mode { + pub const ALL: [Mode; 3] = [Mode::Ask, Mode::Edits, Mode::All]; + pub fn id(self) -> &'static str { + match self { + Mode::Ask => "ask", + Mode::Edits => "edits", + Mode::All => "all", + } + } + pub fn parse(s: &str) -> Option { + Mode::ALL.into_iter().find(|m| m.id() == s) + } + pub fn label(self) -> &'static str { + crate::i18n::tr(match self { + Mode::Ask => "ui.ai_mode_ask", + Mode::Edits => "ui.ai_mode_edits", + Mode::All => "ui.ai_mode_all", + }) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + /// A program that speaks the Agent Client Protocol. + #[default] + Acp, + /// An OpenAI-compatible chat completions endpoint. + Api, +} + +/// An agent the user added. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AgentConfig { + pub id: String, + pub name: String, + pub kind: Kind, + /// ACP: the command line. + pub command: String, + /// API: the endpoint (`…/v1`) and model. + pub base_url: String, + pub model: String, + /// API: a key is stored for it in the system keychain. + pub key: bool, +} + +/// An agent as the picker and the switcher show it. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub id: String, + pub name: String, + pub kind: Kind, + /// ACP: what to start. + pub argv: Vec, + pub version: Option, + /// `signed_in`, `key`, `signed_out`, `unknown` (found agents), or empty. + pub login: &'static str, + /// Found installed (not added by the user). + pub found: bool, + pub base_url: String, + pub model: String, + pub key: bool, +} + +impl Entry { + fn from_config(c: &AgentConfig) -> Entry { + Entry { + id: c.id.clone(), + name: if c.name.trim().is_empty() { c.id.clone() } else { c.name.clone() }, + kind: c.kind, + argv: super::acp::split_command(&c.command), + version: None, + login: "", + found: false, + base_url: c.base_url.clone(), + model: c.model.clone(), + key: c.key, + } + } + /// A short line under the name: the version, the model, or the command. + pub fn detail(&self) -> String { + match self.kind { + Kind::Api => format!("{} · {}", self.model, self.base_url), + Kind::Acp => match &self.version { + Some(v) => v.clone(), + None => super::panel::short_command(&self.argv.join(" ")), + }, + } + } +} + +/// Every agent the chat can use: the installed ones found, then the ones the user added. +pub fn entries(app: &crate::SolveApp) -> Vec { + let mut out = Vec::new(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(d) = &app.agent_chat.detection { + out.extend(d.found.iter().map(|f| Entry { + id: f.id.to_string(), + name: f.name.to_string(), + kind: Kind::Acp, + argv: f.argv.clone(), + version: f.version.clone(), + login: f.login.id(), + found: true, + base_url: String::new(), + model: String::new(), + key: false, + })); + } + out.extend(app.preferences.agents.iter().map(Entry::from_config)); + out +} + +/// The agent a conversation uses: the one picked in the panel, else the default, else the first. +pub fn current(app: &crate::SolveApp) -> Option { + let all = entries(app); + let pick = |id: &str| all.iter().find(|e| e.id == id).cloned(); + app.agent_chat.agent_id.as_deref().and_then(pick).or_else(|| pick(&app.preferences.agent_default)).or_else(|| all.first().cloned()) +} + +/// The permission mode for an agent: its own, else the global one. +pub fn mode_for(p: &crate::prefs::Prefs, id: &str) -> Mode { + p.agent_permissions.get(id).copied().unwrap_or(p.ai_permissions) +} + +/// Whether `tool` is always allowed for `agent`. +pub fn always_allowed(p: &crate::prefs::Prefs, agent: &str, tool: &str) -> bool { + p.agent_always_allow.get(agent).is_some_and(|t| t.iter().any(|x| x == tool)) +} + +/// Remember an "always allow" answer. +pub fn remember(p: &mut crate::prefs::Prefs, agent: &str, tool: &str) { + let list = p.agent_always_allow.entry(agent.to_string()).or_default(); + if !list.iter().any(|x| x == tool) && list.len() < 500 { + list.push(tool.chars().take(200).collect()); + } +} + +/// A free id for a new agent: `prefix-1`, `prefix-2`, … +pub fn new_id(p: &crate::prefs::Prefs, prefix: &str) -> String { + (1..).map(|n| format!("{prefix}-{n}")).find(|id| !p.agents.iter().any(|a| &a.id == id)).unwrap_or_else(|| prefix.to_string()) +} + +/// OpenAI-compatible endpoints people use: name, base URL, whether it needs a key. +pub const API_PRESETS: &[(&str, &str, bool)] = &[ + ("Ollama", "http://localhost:11434/v1", false), + ("LM Studio", "http://localhost:1234/v1", false), + ("OpenAI", "https://api.openai.com/v1", true), +]; + +/// Whether a key may be sent to `url`: only over HTTPS, or to this computer. +pub fn key_safe_for(url: &str) -> bool { + let u = url.trim().to_ascii_lowercase(); + if u.starts_with("https://") { + return true; + } + let Some(rest) = u.strip_prefix("http://") else { return false }; + let host = rest.split(['/', '?']).next().unwrap_or(""); + let host = host.rsplit_once('@').map_or(host, |(_, h)| h); + let name = if host.starts_with('[') { + host.split(']').next().map(|h| format!("{h}]")).unwrap_or_default() + } else { + host.split(':').next().unwrap_or("").to_string() + }; + matches!(name.as_str(), "localhost" | "127.0.0.1" | "[::1]") +} + +/// A base URL the API backend accepts: `http(s)://host…`, without a trailing slash. +pub fn normalize_url(url: &str) -> Result { + let u = url.trim().trim_end_matches('/').to_string(); + let lower = u.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) || u.len() < 10 || u.contains(char::is_whitespace) { + return Err(crate::i18n::tr("ui.ai_bad_url").to_string()); + } + Ok(u) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_go_only_over_https_or_to_this_computer() { + assert!(key_safe_for("https://api.openai.com/v1")); + assert!(key_safe_for("http://localhost:11434/v1")); + assert!(key_safe_for("http://127.0.0.1:1234/v1")); + assert!(key_safe_for("http://[::1]:8080/v1")); + assert!(!key_safe_for("http://192.168.1.20:11434/v1")); + assert!(!key_safe_for("http://localhost.evil.example/v1")); + assert!(!key_safe_for("http://user@evil.example/v1")); + assert!(!key_safe_for("ftp://x")); + } + + #[test] + fn modes_and_ids_round_trip() { + for m in Mode::ALL { + assert_eq!(Mode::parse(m.id()), Some(m)); + } + let mut p = crate::prefs::Prefs::default(); + assert_eq!(p.ai_permissions, Mode::Edits, "model edits run, files ask: the default"); + p.agent_permissions.insert("codex".into(), Mode::Ask); + assert_eq!(mode_for(&p, "codex"), Mode::Ask); + assert_eq!(mode_for(&p, "claude"), Mode::Edits); + remember(&mut p, "claude", "mcp__solidcraft__save"); + remember(&mut p, "claude", "mcp__solidcraft__save"); + assert!(always_allowed(&p, "claude", "mcp__solidcraft__save") && !always_allowed(&p, "codex", "mcp__solidcraft__save")); + assert_eq!(p.agent_always_allow["claude"].len(), 1); + p.agents.push(AgentConfig { id: "api-1".into(), ..Default::default() }); + assert_eq!(new_id(&p, "api"), "api-2"); + assert_eq!(normalize_url(" http://localhost:11434/v1/ ").as_deref(), Ok("http://localhost:11434/v1")); + assert!(normalize_url("localhost:11434").is_err()); + } +} diff --git a/crates/ui-egui/src/agent_chat/api_client.rs b/crates/ui-egui/src/agent_chat/api_client.rs new file mode 100644 index 00000000..49a89974 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/api_client.rs @@ -0,0 +1,485 @@ +//! The API backend (desktop only): a model behind an OpenAI-compatible chat completions endpoint +//! (Ollama, LM Studio, OpenAI, any URL the user gives). SolidCraft runs the tool loop itself, with +//! the same tools the ACP agents get: it starts the same MCP server (`solidcraft-cli mcp +//! --connect` to the chat's own endpoint) and is its client, so every tool call passes the same +//! gate, limits and folder checks. The key, when there is one, comes from the system keychain for +//! this connection only. +//! +//! The loop runs on a thread of its own: a model request can take a while. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus}; +use super::process::{AgentProcess, FromAgent, ProcessHandle}; + +/// Model requests for one message, at most (each answer with tool calls is one). +pub const MAX_REQUESTS_PER_TURN: usize = 24; +/// Longest tool result handed back to the model. +const MAX_TOOL_TEXT: usize = 20_000; +/// Messages kept in the conversation (the system note stays). +const MAX_HISTORY: usize = 240; +/// How long one model request may take. +const REQUEST_TIMEOUT_S: u64 = 300; +/// How long one tool call may take (it may wait for the user's Allow). +const TOOL_TIMEOUT: Duration = Duration::from_secs(330); + +/// Tools the model is not offered. +const HIDDEN_TOOLS: &[&str] = &["crash_reports"]; + +pub struct ApiConfig { + pub name: String, + pub base_url: String, + pub model: String, + pub key: Option, + pub http: HttpFn, +} + +enum Cmd { + Prompt(Vec), +} + +pub struct ApiBackend { + cmds: Option>, + events: Receiver, + cancel: Arc, + mcp: Option, +} + +impl ApiBackend { + pub fn start(cfg: ApiConfig, ctx: StartContext) -> Result { + if cfg.key.is_some() && !super::agents::key_safe_for(&cfg.base_url) { + return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string()); + } + // The same MCP server an ACP agent would get. + let mcp = match &ctx.bridge { + Some(b) => { + let argv = vec![b.cli.to_string_lossy().into_owned(), "mcp".into(), "--connect".into(), format!("127.0.0.1:{}", b.port)]; + Some(AgentProcess::spawn(&argv, &ctx.cwd, &["SOLIDCRAFT_CONTROL_TOKEN"], || {})?) + } + None => None, + }; + let handle = mcp.as_ref().map(AgentProcess::handle); + let (cmd_tx, cmd_rx) = channel::(); + let (ev_tx, events) = channel::(); + let cancel = Arc::new(AtomicBool::new(false)); + let stop = cancel.clone(); + let wake: Arc = Arc::from(ctx.wake); + std::thread::spawn(move || { + let mut w = Worker { cfg, mcp, next: 0, tools: Vec::new(), history: Vec::new(), events: ev_tx, wake, cancel: stop }; + w.run(cmd_rx); + }); + Ok(ApiBackend { cmds: Some(cmd_tx), events, cancel, mcp: handle }) + } +} + +impl ChatBackend for ApiBackend { + fn poll(&mut self) -> Vec { + self.events.try_iter().take(512).collect() + } + fn prompt(&mut self, parts: &[PromptPart]) { + self.cancel.store(false, Ordering::Relaxed); + if let Some(c) = &self.cmds { + let _ = c.send(Cmd::Prompt(parts.to_vec())); + } + } + fn cancel(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } + fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {} + fn authenticate(&mut self, _method: &str) {} + fn pid(&self) -> Option { + self.mcp.as_ref().and_then(ProcessHandle::pid) + } + fn shutdown(&mut self, wait: bool) { + self.cancel.store(true, Ordering::Relaxed); + self.cmds = None; + if let Some(h) = self.mcp.take() { + h.shutdown(wait); + } + } +} + +impl Drop for ApiBackend { + fn drop(&mut self) { + self.shutdown(false); + } +} + +struct Worker { + cfg: ApiConfig, + mcp: Option, + next: u64, + /// The MCP tools as OpenAI function tools. + tools: Vec, + history: Vec, + events: Sender, + wake: Arc, + cancel: Arc, +} + +impl Worker { + fn emit(&self, e: Event) { + let _ = self.events.send(e); + (self.wake)(); + } + + fn run(&mut self, cmds: Receiver) { + if let Err(e) = self.start_mcp() { + self.emit(Event::Error(e)); + } + self.history.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE})); + self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true }); + for cmd in cmds { + match cmd { + Cmd::Prompt(parts) => self.turn(parts), + } + } + if let Some(mut m) = self.mcp.take() { + m.shutdown(false); + } + } + + /// One MCP request to the server; its `result`. + fn mcp_call(&mut self, method: &str, params: Value) -> Result { + let Some(m) = self.mcp.as_ref() else { return Err("no SolidCraft tools in this session".into()) }; + self.next += 1; + let id = self.next; + m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + let deadline = std::time::Instant::now() + TOOL_TIMEOUT; + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + match m.from_agent.recv_timeout(left) { + Ok(FromAgent::Line(l)) => { + let Ok(v) = serde_json::from_str::(l.trim()) else { continue }; + if v.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + if let Some(e) = v.get("error") { + return Err(e.get("message").and_then(Value::as_str).unwrap_or("error").to_string()); + } + return Ok(v.get("result").cloned().unwrap_or(Value::Null)); + } + Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()), + Err(_) => return Err("the SolidCraft tools did not answer".into()), + } + } + } + + fn start_mcp(&mut self) -> Result<(), String> { + if self.mcp.is_none() { + return Ok(()); + } + self.mcp_call( + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "solidcraft-agent-chat", "version": env!("CARGO_PKG_VERSION")}}), + )?; + if let Some(m) = &self.mcp { + m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + } + let list = self.mcp_call("tools/list", json!({}))?; + self.tools = openai_tools(&list); + Ok(()) + } + + fn turn(&mut self, parts: Vec) { + self.history.push(user_message(&parts)); + // Keep the system note; drop the oldest messages, never leaving a reply first. + while self.history.len() > MAX_HISTORY { + self.history.remove(1); + } + while self.history.len() > 2 && self.history.get(1).is_some_and(|m| m["role"] != "user") { + self.history.remove(1); + } + for _ in 0..MAX_REQUESTS_PER_TURN { + if self.cancel.load(Ordering::Relaxed) { + self.emit(Event::TurnEnded { reason: "cancelled".into() }); + return; + } + let reply = match self.request() { + Ok(r) => r, + Err(e) => { + self.emit(Event::Error(e)); + self.emit(Event::TurnEnded { reason: "error".into() }); + return; + } + }; + let msg = reply.pointer("/choices/0/message").cloned().unwrap_or(Value::Null); + if let Some(text) = msg.get("content").and_then(Value::as_str).filter(|t| !t.is_empty()) { + self.emit(Event::Text { thought: false, text: text.to_string() }); + } + let calls: Vec = msg.get("tool_calls").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut kept = json!({"role": "assistant", "content": msg.get("content").cloned().unwrap_or(Value::Null)}); + if !calls.is_empty() { + kept["tool_calls"] = Value::Array(calls.clone()); + } + self.history.push(kept); + if calls.is_empty() { + self.emit(Event::TurnEnded { reason: String::new() }); + return; + } + for (i, c) in calls.iter().enumerate().take(32) { + let id = c.get("id").and_then(Value::as_str).map_or_else(|| format!("call-{i}"), str::to_string); + let name = c.pointer("/function/name").and_then(Value::as_str).unwrap_or("").to_string(); + let args: Value = c + .pointer("/function/arguments") + .and_then(|a| if let Some(s) = a.as_str() { serde_json::from_str(s).ok() } else { Some(a.clone()) }) + .unwrap_or(json!({})); + let title = tool_title(&name, &args); + let tool_id = format!("{id}-{}", self.next); + self.emit(Event::Tool { + id: tool_id.clone(), + title: Some(title), + kind: Some(String::new()), + status: Some(ToolStatus::InProgress), + output: None, + }); + let (text, failed) = if self.cancel.load(Ordering::Relaxed) { + ("cancelled by the user".to_string(), true) + } else if HIDDEN_TOOLS.contains(&name.as_str()) + || !self.tools.iter().any(|t| t.pointer("/function/name").and_then(Value::as_str) == Some(name.as_str())) + { + (format!("unknown tool `{name}`"), true) + } else { + match self.mcp_call("tools/call", json!({"name": name, "arguments": args})) { + Ok(r) => tool_text(&r), + Err(e) => (e, true), + } + }; + let shown: String = text.chars().take(2000).collect(); + self.emit(Event::Tool { + id: tool_id, + title: None, + kind: None, + status: Some(if failed { ToolStatus::Failed } else { ToolStatus::Completed }), + output: Some(shown), + }); + self.history.push(json!({"role": "tool", "tool_call_id": id, "content": text})); + } + } + self.emit(Event::Text { thought: false, text: format!("\n({})", crate::i18n::tr("ui.ai_request_limit")) }); + self.emit(Event::TurnEnded { reason: "max_turn_requests".into() }); + } + + fn request(&self) -> Result { + let mut body = json!({"model": self.cfg.model, "messages": self.history}); + if !self.tools.is_empty() { + body["tools"] = Value::Array(self.tools.clone()); + } + let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + if let Some(k) = &self.cfg.key { + headers.push(("Authorization".into(), format!("Bearer {k}"))); + } + let req = HttpRequest { + method: "POST", + url: format!("{}/chat/completions", self.cfg.base_url), + headers, + body: Some(body.to_string().into_bytes()), + timeout_s: REQUEST_TIMEOUT_S, + }; + let (status, bytes) = (self.cfg.http)(&req)?; + let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + if status != 200 { + return Err(http_error(status, &v)); + } + Ok(v) + } +} + +/// An endpoint's refusal as people read it. +pub fn http_error(status: u16, body: &Value) -> String { + let detail = + body.pointer("/error/message").or_else(|| body.get("error")).and_then(Value::as_str).unwrap_or("").chars().take(300).collect::(); + let what = match status { + 401 | 403 => crate::i18n::tr("ui.ai_http_key"), + 404 => crate::i18n::tr("ui.ai_http_not_found"), + 429 => crate::i18n::tr("ui.ai_http_busy"), + _ => "", + }; + format!( + "HTTP {status}{}{}", + if what.is_empty() { String::new() } else { format!(": {what}") }, + if detail.is_empty() { String::new() } else { format!(" ({detail})") } + ) +} + +/// The message for what the user typed (and the view, as a data URL). +fn user_message(parts: &[PromptPart]) -> Value { + let images = parts.iter().any(|p| matches!(p, PromptPart::Image { .. })); + if !images { + let text: Vec<&str> = parts.iter().filter_map(|p| if let PromptPart::Text(t) = p { Some(t.as_str()) } else { None }).collect(); + return json!({"role": "user", "content": text.join("\n")}); + } + let content: Vec = parts + .iter() + .map(|p| match p { + PromptPart::Text(t) => json!({"type": "text", "text": t}), + PromptPart::Image { mime, base64 } => json!({"type": "image_url", "image_url": {"url": format!("data:{mime};base64,{base64}")}}), + }) + .collect(); + json!({"role": "user", "content": content}) +} + +/// MCP `tools/list` as OpenAI function tools (the hidden ones left out). +pub fn openai_tools(list: &Value) -> Vec { + list.get("tools") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(128) + .filter_map(|t| { + let name = t.get("name")?.as_str()?; + if HIDDEN_TOOLS.contains(&name) { + return None; + } + let params = t.get("inputSchema").cloned().filter(Value::is_object).unwrap_or(json!({"type": "object", "properties": {}})); + Some(json!({"type": "function", "function": { + "name": name, + "description": t.get("description").and_then(Value::as_str).unwrap_or(""), + "parameters": params, + }})) + }) + .collect() + }) + .unwrap_or_default() +} + +/// A tool call's result as text for the model, and whether it failed. +fn tool_text(r: &Value) -> (String, bool) { + let failed = r.get("isError").and_then(Value::as_bool).unwrap_or(false); + let parts: Vec = r + .get("content") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .take(16) + .map(|c| match c.get("type").and_then(Value::as_str) { + Some("text") => c.get("text").and_then(Value::as_str).unwrap_or("").to_string(), + Some("image") => "[a picture of the view]".to_string(), + _ => String::new(), + }) + .collect() + }) + .unwrap_or_default(); + let mut text = parts.join("\n"); + if text.len() > MAX_TOOL_TEXT { + let mut cut = MAX_TOOL_TEXT; + while !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + text.push_str("\n…(cut)"); + } + (text, failed) +} + +/// What a tool call card says: the tool and, for `execute` and `batch`, the commands. +fn tool_title(name: &str, args: &Value) -> String { + match name { + "execute" => format!("execute: {}", args.get("command").and_then(Value::as_str).unwrap_or("?")), + "batch" => { + let ids: Vec<&str> = args + .get("commands") + .and_then(Value::as_array) + .map(|c| c.iter().filter_map(|s| s.get("command").and_then(Value::as_str)).take(4).collect()) + .unwrap_or_default(); + format!("batch: {}", ids.join(", ")) + } + _ => name.to_string(), + } +} + +/// Ask an endpoint for its models (Test connection): how many, and whether `model` is one. +pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result { + let mut headers = Vec::new(); + if let Some(k) = key { + if !super::agents::key_safe_for(base_url) { + return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string()); + } + headers.push(("Authorization".to_string(), format!("Bearer {k}"))); + } + let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 }; + let (status, bytes) = http(&req)?; + let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + if status != 200 { + return Err(http_error(status, &v)); + } + let ids: Vec<&str> = + v.get("data").and_then(Value::as_array).map(|a| a.iter().filter_map(|m| m.get("id").and_then(Value::as_str)).collect()).unwrap_or_default(); + let found = model.is_empty() || ids.contains(&model); + Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &ids.len().to_string()), ("model", model)])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_tools_become_function_tools_without_crash_reports() { + let list = json!({"tools": [ + {"name": "batch", "description": "Run commands", "inputSchema": {"type": "object", "properties": {"commands": {"type": "array"}}}}, + {"name": "crash_reports", "description": "x", "inputSchema": {"type": "object"}}, + {"name": "measure"}, + ]}); + let t = openai_tools(&list); + assert_eq!(t.len(), 2); + assert_eq!(t[0]["function"]["name"], "batch"); + assert_eq!(t[1]["function"]["parameters"]["type"], "object"); + } + + #[test] + fn pictures_go_as_data_urls_and_text_alone_as_a_string() { + assert_eq!(user_message(&[PromptPart::Text("hi".into())])["content"], "hi"); + let m = user_message(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]); + assert_eq!(m["content"][1]["image_url"]["url"], "data:image/png;base64,AA=="); + } + + /// A scripted endpoint: one answer with a tool call, then a plain answer. The loop runs the + /// tool (here without SolidCraft tools: an unknown tool fails) and reports the turn's end. + #[test] + fn the_tool_loop_runs_calls_and_ends_the_turn() { + let answers = std::sync::Mutex::new(vec![ + json!({"choices": [{"message": {"role": "assistant", "content": "Done."}}]}), + json!({"choices": [{"message": {"role": "assistant", "content": "Measuring.", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "measure", "arguments": "{}"}}]}}]}), + ]); + let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); + let log = seen.clone(); + let http: HttpFn = Arc::new(move |r: &HttpRequest| { + log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap()); + let a = answers.lock().unwrap().pop().unwrap_or(json!({})); + Ok((200, a.to_string().into_bytes())) + }); + let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http }; + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut b = ApiBackend::start(cfg, ctx).unwrap(); + b.prompt(&[PromptPart::Text("measure it".into())]); + let mut events = Vec::new(); + let start = std::time::Instant::now(); + while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) { + events.extend(b.poll()); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}"); + assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}"); + assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}"); + assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}"); + // The second request carried the tool's answer. + let sent = seen.lock().unwrap(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[1]["messages"].as_array().unwrap().last().unwrap()["role"], "tool"); + b.shutdown(true); + } + + #[test] + fn keys_never_go_over_plain_http_to_another_computer() { + let http: HttpFn = Arc::new(|_: &HttpRequest| Ok((200, b"{}".to_vec()))); + assert!(test_endpoint(&http, "http://10.0.0.2:11434/v1", Some("k"), "m").is_err()); + let cfg = ApiConfig { name: "x".into(), base_url: "http://10.0.0.2/v1".into(), model: "m".into(), key: Some("k".into()), http }; + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + assert!(ApiBackend::start(cfg, ctx).is_err()); + } +} diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs new file mode 100644 index 00000000..301b9276 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -0,0 +1,154 @@ +//! The seam between the chat panel and whatever answers it. The panel (conversation, sign-in, +//! approvals, turns, undo, the tool gate) only talks to a [`ChatBackend`]; the first backend is +//! [`crate::agent_chat::acp_client::AcpBackend`], which drives an agent the user already has over +//! the Agent Client Protocol. Another backend (one that calls a model API itself, say) slots in +//! by implementing the trait and sending its tool calls into [`StartContext::tools`]: they then +//! pass the same gate, count against the same per-turn limit and undo as one step, exactly like +//! the ACP agent's. + +use serde_json::Value; + +pub use super::acp::{AuthMethod, PermissionOption, ToolStatus}; + +/// One part of a message to the agent. +#[derive(Clone, Debug, PartialEq)] +pub enum PromptPart { + Text(String), + /// A picture of the design (PNG), sent only to agents that take images. + Image { + mime: String, + base64: String, + }, +} + +/// What a backend reports to the panel. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// Ready for a message. `agent`: its name and version; `images`: it takes pictures. + Ready { agent: String, images: bool }, + /// Text it streams (`thought`: its reasoning, shown dimmed). + Text { thought: bool, text: String }, + /// A new tool call, or news about one. + Tool { id: String, title: Option, kind: Option, status: Option, output: Option }, + /// Its plan, replaced whole: each entry with its status. + Plan(Vec<(String, String)>), + /// It asks before running something of its own; answered with [`ChatBackend::answer_permission`]. + Permission { key: u64, title: String, options: Vec }, + /// It needs the user to sign in; `methods` are its own ways to do so. + AuthRequired { methods: Vec }, + /// What it says about its login. + AuthStatus(String), + /// Its own modes (an ACP agent's `modes`: ask, accept edits, plan, …) and the current one. + Modes { current: String, available: Vec<(String, String)> }, + /// The message is answered (`reason`: why it stopped, empty for a normal end). + TurnEnded { reason: String }, + /// Something failed; the backend is still running unless [`Event::Exited`] follows. + Error(String), + /// It ended; its last error output. + Exited { tail: Vec }, +} + +/// What a backend gets to start. +pub struct StartContext { + /// Its working folder: an empty folder of SolidCraft's own, not the user's files. + pub cwd: std::path::PathBuf, + /// The MCP server that reaches this window (`solidcraft-cli mcp --connect` to the chat's own + /// endpoint), for backends that start MCP servers; `None` when it could not be set up. + pub bridge: Option, + /// Where tool calls run in process go, for backends that run their own tool loop. + pub tools: Option>, + /// Wakes the window when something arrives. + pub wake: Box, +} + +/// A chat backend. Methods never block the frame; replies arrive through [`ChatBackend::poll`]. +pub trait ChatBackend { + /// What arrived since the last call. + fn poll(&mut self) -> Vec; + /// Send a message (the panel only sends one after [`Event::Ready`] and while no turn runs). + fn prompt(&mut self, parts: &[PromptPart]); + /// Stop the message being answered; open permission questions count as cancelled. + fn cancel(&mut self); + /// Answer an [`Event::Permission`] with one of its options (`None`: cancelled). + fn answer_permission(&mut self, key: u64, option: Option<&str>); + /// Ask the backend to sign in by itself with one of its methods (it opens the browser). + fn authenticate(&mut self, method: &str); + /// Switch to one of the modes it reported in [`Event::Modes`]. + fn set_mode(&mut self, _mode: &str) {} + /// Its process, for status and tests. + fn pid(&self) -> Option { + None + } + /// End it and everything it started. `wait`: block until done (quitting). + fn shutdown(&mut self, wait: bool); +} + +/// One HTTP request of the API backend (the desktop host sends it; nothing else in the chat +/// reaches the network). +#[derive(Clone, Debug, PartialEq)] +pub struct HttpRequest { + pub method: &'static str, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Option>, + pub timeout_s: u64, +} + +/// Sends an [`HttpRequest`]: the status and the body, or why it failed. +pub type HttpFn = std::sync::Arc Result<(u16, Vec), String> + Send + Sync>; + +/// The system keychain (the desktop host's), for API keys: never in the preferences. +pub trait Secrets: Send + Sync { + fn get(&self, id: &str) -> Result, String>; + fn set(&self, id: &str, secret: &str) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +/// Standard base64 with padding (RFC 4648), for pictures in messages. +pub fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() { + if i <= chunk.len() { + out.push(char::from(T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A'))); + } else { + out.push('='); + } + } + } + out +} + +/// A message's parts as ACP content blocks. +pub fn content_blocks(parts: &[PromptPart]) -> Vec { + parts + .iter() + .map(|p| match p { + PromptPart::Text(t) => serde_json::json!({"type": "text", "text": t}), + PromptPart::Image { mime, base64 } => serde_json::json!({"type": "image", "mimeType": mime, "data": base64}), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_matches_the_rfc_examples() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + assert_eq!(base64(i.as_bytes()), o); + } + } + + #[test] + fn pictures_go_as_image_blocks() { + let b = content_blocks(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]); + assert_eq!(b[1]["type"], "image"); + assert_eq!(b[1]["mimeType"], "image/png"); + } +} diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs new file mode 100644 index 00000000..fcd09660 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -0,0 +1,316 @@ +//! Which ACP agents are installed here (desktop only). A desktop app often starts without the +//! shell's PATH, so the usual install folders are searched too. Detection never starts a login +//! or a session: each agent found is asked for `--version` (with a time limit), and whether it +//! looks logged in comes only from whether its own credentials file exists. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// An agent SolidCraft knows how to start in ACP mode. +pub struct Known { + pub id: &'static str, + pub name: &'static str, + /// Program names to look for, in order of preference. + pub programs: &'static [&'static str], + /// Arguments that put it in ACP mode. + pub args: &'static [&'static str], + /// Its one-line install hint. + pub install: &'static str, + /// Its own credentials file, relative to the home folder (`None`: can't tell), and the + /// variable that moves its settings folder (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`) with the file's + /// name inside it. + pub credentials: Option<&'static str>, + pub config_dir_var: Option<(&'static str, &'static str)>, + /// Variables holding a key it uses instead of a login. + pub key_vars: &'static [&'static str], +} + +/// The agents shown in the picker. The first one found is the default. +pub const KNOWN: &[Known] = &[ + Known { + id: "claude", + name: "Claude Code", + programs: &["claude-agent-acp", "claude-code-acp"], + args: &[], + install: "npm install -g @agentclientprotocol/claude-agent-acp", + credentials: Some(".claude/.credentials.json"), + config_dir_var: Some(("CLAUDE_CONFIG_DIR", ".credentials.json")), + key_vars: &["ANTHROPIC_API_KEY"], + }, + Known { + id: "codex", + name: "Codex", + programs: &["codex-acp"], + args: &[], + install: "npm install -g @agentclientprotocol/codex-acp", + credentials: Some(".codex/auth.json"), + config_dir_var: Some(("CODEX_HOME", "auth.json")), + key_vars: &["OPENAI_API_KEY", "CODEX_API_KEY"], + }, + Known { + id: "gemini", + name: "Gemini CLI", + programs: &["gemini"], + args: &["--acp"], + install: "npm install -g @google/gemini-cli", + credentials: Some(".gemini/oauth_creds.json"), + config_dir_var: None, + key_vars: &["GEMINI_API_KEY", "GOOGLE_API_KEY"], + }, + Known { + id: "opencode", + name: "OpenCode", + programs: &["opencode"], + args: &["acp"], + install: "npm install -g opencode-ai", + credentials: Some(".local/share/opencode/auth.json"), + config_dir_var: None, + key_vars: &[], + }, + Known { + id: "copilot", + name: "GitHub Copilot CLI", + programs: &["copilot"], + args: &["--acp"], + install: "npm install -g @github/copilot", + credentials: None, + config_dir_var: None, + key_vars: &["GH_TOKEN", "GITHUB_TOKEN"], + }, + Known { + id: "qwen", + name: "Qwen Code", + programs: &["qwen"], + args: &["--acp"], + install: "npm install -g @qwen-code/qwen-code", + credentials: Some(".qwen/oauth_creds.json"), + config_dir_var: None, + key_vars: &["DASHSCOPE_API_KEY"], + }, + Known { + id: "goose", + name: "goose", + programs: &["goose"], + args: &["acp"], + install: "see block.github.io/goose (installer script or Homebrew)", + credentials: None, + config_dir_var: None, + key_vars: &[], + }, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Login { + /// Its credentials file is there. + LooksLoggedIn, + /// No login file, but one of its key variables is set. + KeyInEnvironment, + /// Its credentials file is missing (it may still use a key from the environment). + NotLoggedIn, + /// It keeps no file we can look at (system keychain, or unknown). + Unknown, +} + +impl Login { + pub fn id(self) -> &'static str { + match self { + Login::LooksLoggedIn => "signed_in", + Login::KeyInEnvironment => "key", + Login::NotLoggedIn => "signed_out", + Login::Unknown => "unknown", + } + } +} + +/// A known agent that isn't installed, with its one-line install hint. +#[derive(Clone, Debug)] +pub struct Missing { + pub id: &'static str, + pub name: &'static str, + pub install: &'static str, +} + +#[derive(Clone, Debug)] +pub struct Found { + pub id: &'static str, + pub name: &'static str, + /// The full command: the program's path and its ACP arguments. + pub argv: Vec, + pub version: Option, + pub login: Login, +} + +#[derive(Clone, Debug, Default)] +pub struct Detection { + pub found: Vec, + /// Known agents not found. + pub missing: Vec, + pub took_ms: u64, +} + +fn home() -> Option { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).filter(|p| p.is_absolute()) +} + +/// PATH, then the usual places npm, Homebrew, nvm, Volta, bun and pipx put commands. +pub fn search_dirs() -> Vec { + let mut dirs: Vec = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); + if let Some(prefix) = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from) { + dirs.push(if cfg!(windows) { prefix.clone() } else { prefix.join("bin") }); + } + if let Some(h) = home() { + for d in [".local/bin", ".npm-global/bin", ".npm-packages/bin", ".volta/bin", ".bun/bin", ".cargo/bin", "bin"] { + dirs.push(h.join(d)); + } + // nvm: every installed Node version's bin. + if let Ok(rd) = std::fs::read_dir(h.join(".nvm/versions/node")) { + let mut v: Vec = rd.flatten().take(64).map(|e| e.path().join("bin")).collect(); + v.sort(); + dirs.extend(v.into_iter().rev()); + } + } + if cfg!(windows) { + if let Some(a) = std::env::var_os("APPDATA") { + dirs.push(PathBuf::from(a).join("npm")); + } + } else { + for d in ["/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin", "/usr/bin"] { + dirs.push(PathBuf::from(d)); + } + } + let mut seen = std::collections::HashSet::new(); + dirs.retain(|d| d.is_absolute() && seen.insert(d.clone())); + dirs +} + +/// The first `program` in `dirs` (on Windows also with `.exe`/`.cmd`). +pub fn find_in(program: &str, dirs: &[PathBuf]) -> Option { + let names: Vec = + if cfg!(windows) { ["", ".exe", ".cmd", ".bat"].iter().map(|e| format!("{program}{e}")).collect() } else { vec![program.to_string()] }; + dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file()) +} + +/// `program --version`'s first line, or `None` after `limit` (the probe is then stopped). +fn version(program: &Path, limit: Duration) -> Option { + let mut cmd = if cfg!(windows) && program.extension().is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(program); + c + } else { + Command::new(program) + }; + let mut child = cmd.arg("--version").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + let mut out = String::new(); + std::io::Read::read_to_string(&mut child.stdout.take()?, &mut out).ok()?; + let line = out.lines().map(str::trim).find(|l| !l.is_empty())?; + Some(version_of(line)) +} + +/// The version number in a `--version` line (`codex-cli 0.162.1`, `2.1.295 (Claude Code)`, +/// `@scope/pkg 2.2.2`), else the line itself, cut short. +fn version_of(line: &str) -> String { + let v = line.split_whitespace().find(|w| w.trim_start_matches('v').starts_with(|c: char| c.is_ascii_digit())).unwrap_or(line); + v.chars().take(40).collect() +} + +/// Whether `k` looks signed in, from files and variables only: nothing is started or asked. +fn login_of(k: &Known, home: Option<&Path>) -> Login { + let file = match k.config_dir_var.and_then(|(var, name)| std::env::var_os(var).filter(|v| !v.is_empty()).map(|d| PathBuf::from(d).join(name))) { + Some(p) => Some(p), + None => k.credentials.zip(home).map(|(rel, h)| h.join(rel)), + }; + let key = || k.key_vars.iter().any(|v| std::env::var_os(v).is_some_and(|x| !x.is_empty())); + match file { + Some(f) if f.is_file() => Login::LooksLoggedIn, + _ if key() => Login::KeyInEnvironment, + // Claude Code keeps its login in the keychain on macOS: the file says nothing there. + _ if cfg!(target_os = "macos") && k.id == "claude" => Login::Unknown, + Some(_) => Login::NotLoggedIn, + None => Login::Unknown, + } +} + +/// Look for every known agent. Blocking (each probe has a time limit): run it off the UI thread. +pub fn detect() -> Detection { + let t0 = Instant::now(); + let dirs = search_dirs(); + let home = home(); + let mut d = Detection::default(); + for k in KNOWN { + let Some(path) = k.programs.iter().find_map(|p| find_in(p, &dirs)) else { + d.missing.push(Missing { id: k.id, name: k.name, install: k.install }); + continue; + }; + let login = login_of(k, home.as_deref()); + let mut argv = vec![path.to_string_lossy().into_owned()]; + argv.extend(k.args.iter().map(|a| a.to_string())); + d.found.push(Found { id: k.id, name: k.name, version: version(&path, Duration::from_secs(3)), argv, login }); + } + d.took_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX); + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_program_is_found_in_the_first_folder_that_has_it() { + let root = std::env::temp_dir().join(format!("sc-agent-find-{}", std::process::id())); + let (a, b) = (root.join("a"), root.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(b.join("codex-acp"), "").unwrap(); + assert_eq!(find_in("codex-acp", &[a.clone(), b.clone()]), Some(b.join("codex-acp"))); + assert_eq!(find_in("gemini", &[a, b]), None); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn the_version_number_is_picked_out_of_the_line() { + assert_eq!(version_of("@agentclientprotocol/codex-acp 2.2.2"), "2.2.2"); + assert_eq!(version_of("2.1.295 (Claude Code)"), "2.1.295"); + assert_eq!(version_of("goose v1.9.0"), "v1.9.0"); + assert_eq!(version_of("dev build"), "dev build"); + } + + #[test] + fn signed_in_comes_from_the_agents_own_file_or_a_key_variable() { + let home = std::env::temp_dir().join(format!("sc-agent-login-{}", std::process::id())); + std::fs::create_dir_all(home.join(".gemini")).unwrap(); + let gemini = KNOWN.iter().find(|k| k.id == "gemini").unwrap(); + let qwen = KNOWN.iter().find(|k| k.id == "qwen").unwrap(); + let goose = KNOWN.iter().find(|k| k.id == "goose").unwrap(); + if std::env::var_os("GEMINI_API_KEY").is_none() && std::env::var_os("GOOGLE_API_KEY").is_none() { + assert_eq!(login_of(gemini, Some(&home)), Login::NotLoggedIn); + } + std::fs::write(home.join(".gemini/oauth_creds.json"), "{}").unwrap(); + assert_eq!(login_of(gemini, Some(&home)), Login::LooksLoggedIn); + if std::env::var_os("DASHSCOPE_API_KEY").is_none() { + assert_eq!(login_of(qwen, Some(&home)), Login::NotLoggedIn); + } + assert_eq!(login_of(goose, Some(&home)), Login::Unknown, "keeps no file we know"); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn every_known_agent_has_a_program_and_an_install_hint() { + for k in KNOWN { + assert!(!k.programs.is_empty() && !k.install.is_empty(), "{}", k.id); + } + assert_eq!(KNOWN.first().map(|k| k.id), Some("claude"), "the Claude Code adapter is the default"); + } +} diff --git a/crates/ui-egui/src/agent_chat/gate.rs b/crates/ui-egui/src/agent_chat/gate.rs new file mode 100644 index 00000000..6317808e --- /dev/null +++ b/crates/ui-egui/src/agent_chat/gate.rs @@ -0,0 +1,219 @@ +//! What the chat's agent may do to the app. Every request its tools send (whatever the backend) +//! passes here before it runs: a short list of methods (run commands, read the design, move the +//! camera, render), with commands sorted into those that run at once, those that wait for the +//! user's Allow or Deny in the panel (anything that reads or writes files or replaces the design), +//! and those that never run for the agent (crash reports, which hold other designs and paths). +//! Plain data and rules, tested without a window. + +use serde_json::Value; + +/// Changes the agent may make in one turn (requests that run commands; reads don't count). The +/// user can continue with another message. +pub const DEFAULT_TURN_LIMIT: u32 = 24; +/// Requests of any kind in one turn, so an agent stuck reading the design in a loop stops too. +pub const MAX_REQUESTS_PER_TURN: u32 = 400; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + /// Runs now. `change`: it runs a command that may change the design (counted per turn). + Run { change: bool }, + /// Waits for Allow or Deny; the text says what it would do. + Ask(String), + /// Never for the agent; the reason goes back to it. + Refuse(String), +} + +/// Methods the bridged MCP server uses; nothing else (clicks, preferences, quitting, the chat +/// itself) is the agent's. +const METHODS: &[&str] = &["engine.commands", "document.inspect", "engine.execute", "engine.batch", "ui.view", "ui.render", "ui.screenshot"]; + +/// Parameter names that hold a file or folder. +const PATH_KEYS: &[&str] = &["path", "paths", "file", "files", "dir", "folder", "out", "output", "filename", "source", "target_path"]; + +/// Commands that only read (not counted as changes). +fn reads_only(id: &str) -> bool { + id.starts_with("inspect.") + || id.starts_with("model.") + || id.ends_with(".inspect") + || id.ends_with(".list") + || matches!(id, "edit.checkpoint" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers") +} + +fn names_a_file(params: &Value) -> Option { + let o = params.as_object()?; + PATH_KEYS.iter().find_map(|k| match o.get(*k) { + Some(Value::String(s)) if !s.is_empty() => Some(s.chars().take(200).collect()), + Some(Value::Array(a)) if !a.is_empty() => Some(a.iter().filter_map(Value::as_str).take(3).collect::>().join(", ")), + _ => None, + }) +} + +/// One command as the agent would run it. `undo_left`: steps this turn may still undo without +/// reaching changes made before it. +pub fn command(id: &str, params: &Value, undo_left: usize) -> Verdict { + let id = id.trim(); + if id.starts_with("diagnostics.") || id.contains("crash") { + return Verdict::Refuse(format!("`{id}` is not available in the agent chat: crash reports stay with the user")); + } + if id.starts_with("app.") || id.starts_with("prefs.") { + return Verdict::Refuse(format!("`{id}` is not available in the agent chat")); + } + // Files, and commands that replace or reload the whole design. + let file_command = id.starts_with("file.") || id.starts_with("doc.") || matches!(id, "render.export" | "render.start"); + if let Some(p) = names_a_file(params) { + return Verdict::Ask(format!("{id}: {p}")); + } + if file_command && !reads_only(id) { + return Verdict::Ask(id.to_string()); + } + // Undo past this turn's first change takes back the user's own work. + if id == "edit.undo" && undo_left == 0 { + return Verdict::Ask(format!("{id} (a step from before this message)")); + } + Verdict::Run { change: !reads_only(id) } +} + +/// One request from the agent's tools: `method` and its `params` as the control channel has them. +pub fn request(method: &str, params: &Value, undo_left: usize) -> Verdict { + if !METHODS.contains(&method) { + return Verdict::Refuse(format!("`{method}` is not available in the agent chat")); + } + match method { + "engine.execute" => { + let id = params.get("command").and_then(Value::as_str).unwrap_or(""); + command(id, params.get("params").unwrap_or(&Value::Null), undo_left) + } + "engine.batch" => { + let Some(steps) = params.get("commands").and_then(Value::as_array) else { return Verdict::Run { change: false } }; + let mut asks = Vec::new(); + let mut change = false; + // A batch can undo as many steps as it has: count down as it goes. + let mut undo_left = undo_left; + for s in steps.iter().take(10_000) { + let id = s.get("command").or_else(|| s.get("id")).and_then(Value::as_str).unwrap_or(""); + match command(id, s.get("params").unwrap_or(&Value::Null), undo_left) { + Verdict::Refuse(e) => return Verdict::Refuse(e), + Verdict::Ask(a) => asks.push(a), + Verdict::Run { change: c } => { + change |= c; + if id == "edit.undo" { + undo_left = undo_left.saturating_sub(1); + } else if c { + undo_left += 1; + } + } + } + } + if asks.is_empty() { + Verdict::Run { change } + } else { + Verdict::Ask(format!("batch: {}", asks.into_iter().take(6).collect::>().join("; "))) + } + } + // A picture saved to a file names it. + "ui.screenshot" | "ui.render" => match names_a_file(params) { + Some(_) if is_scratch(params) => Verdict::Run { change: false }, + Some(p) => Verdict::Ask(format!("{method}: {p}")), + None => Verdict::Run { change: false }, + }, + _ => Verdict::Run { change: false }, + } +} + +/// What "Always allow" remembers for a request: the command it runs (`batch` for a batch). +pub fn tool_key(method: &str, params: &Value) -> String { + match method { + "engine.execute" => params.get("command").and_then(Value::as_str).unwrap_or("").chars().take(120).collect(), + "engine.batch" => "batch".into(), + m => m.into(), + } +} + +/// A request as an approval card says it: the command, or a batch's commands. +pub fn describe(method: &str, params: &Value) -> String { + match method { + "engine.batch" => { + let steps = params.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + let ids: Vec<&str> = steps.iter().filter_map(|s| s.get("command").or_else(|| s.get("id")).and_then(Value::as_str)).take(6).collect(); + let more = if steps.len() > ids.len() { format!(" (+{})", steps.len() - ids.len()) } else { String::new() }; + format!("batch: {}{more}", ids.join(", ")) + } + _ => tool_key(method, params), + } +} + +/// The MCP server saves the pictures it hands back in its own scratch folder, inside its +/// `allow_paths`; those need no question. +fn is_scratch(params: &Value) -> bool { + let Some(path) = params.get("path").and_then(Value::as_str) else { return false }; + let path = std::path::Path::new(path); + if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + return false; + } + let ok = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("png")); + ok && params.get("allow_paths").and_then(Value::as_array).is_some_and(|roots| { + roots + .iter() + .filter_map(Value::as_str) + .map(std::path::Path::new) + .any(|r| r.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("solidcraft-mcp-")) && path.starts_with(r)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn exec(id: &str, params: Value) -> Verdict { + request("engine.execute", &json!({"command": id, "params": params}), 5) + } + + #[test] + fn modelling_runs_and_counts_reads_dont() { + assert_eq!(exec("solid.box", json!({"x": 10})), Verdict::Run { change: true }); + assert_eq!(exec("sketch.inspect", json!({"sketch": 1})), Verdict::Run { change: false }); + assert_eq!(exec("inspect.measure", json!({})), Verdict::Run { change: false }); + assert_eq!(request("document.inspect", &json!({}), 0), Verdict::Run { change: false }); + } + + #[test] + fn files_and_replacing_the_design_ask_first() { + assert!(matches!(exec("file.save", json!({"path": "/tmp/a.solidcraft"})), Verdict::Ask(a) if a.contains("/tmp/a.solidcraft"))); + assert!(matches!(exec("file.new", json!({})), Verdict::Ask(_))); + assert!(matches!(exec("doc.open", json!({"path": "x.step"})), Verdict::Ask(_))); + assert!(matches!(exec("file.export", json!({"path": "x.stl"})), Verdict::Ask(_))); + // Any command given a file, whatever its area. + assert!(matches!(exec("sketch.import", json!({"file": "a.dxf"})), Verdict::Ask(_))); + let batch = json!({"commands": [{"command": "solid.box", "params": {}}, {"command": "file.save", "params": {"path": "/etc/x"}}]}); + assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(a) if a.contains("/etc/x"))); + } + + #[test] + fn crash_reports_and_app_methods_are_never_the_agents() { + assert!(matches!(exec("diagnostics.crash_reports", json!({})), Verdict::Refuse(_))); + assert!(matches!(exec("diagnostics.crash_report", json!({"id": "x"})), Verdict::Refuse(_))); + let batch = json!({"commands": [{"command": "solid.box"}, {"command": "diagnostics.crash_reports"}]}); + assert!(matches!(request("engine.batch", &batch, 0), Verdict::Refuse(_))); + for m in ["app.quit", "ui.click", "ui.prefs", "ui.agent_chat", "engine.script", "ui.key", "ui.file"] { + assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}"); + } + } + + #[test] + fn undo_asks_only_past_the_turns_own_changes() { + assert_eq!(request("engine.execute", &json!({"command": "edit.undo"}), 1), Verdict::Run { change: true }); + assert!(matches!(request("engine.execute", &json!({"command": "edit.undo"}), 0), Verdict::Ask(_))); + let batch = json!({"commands": [{"command": "solid.box"}, {"command": "edit.undo"}, {"command": "edit.undo"}]}); + assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(_))); + } + + #[test] + fn the_mcp_servers_own_pictures_need_no_question() { + let scratch = json!({"path": "/tmp/solidcraft-mcp-41-a3/shot-1.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3"]}); + assert_eq!(request("ui.screenshot", &scratch, 0), Verdict::Run { change: false }); + let elsewhere = json!({"path": "/home/u/Desktop/shot.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3", "/home/u/Desktop"]}); + assert!(matches!(request("ui.screenshot", &elsewhere, 0), Verdict::Ask(_))); + assert_eq!(request("ui.render", &json!({"width": 400}), 0), Verdict::Run { change: false }); + } +} diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs new file mode 100644 index 00000000..af90cfbb --- /dev/null +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -0,0 +1,997 @@ +//! Agent chat: a side panel that talks to a coding agent the user already has (Claude Code, +//! Codex, Gemini CLI, OpenCode, …) over the Agent Client Protocol, and gives that agent +//! SolidCraft's MCP server, bridged to this window, as its way to read and change the open design +//! (docs/agent-chat.md, docs/design/in-app-agent-chat.md). +//! +//! Desktop only: the agent is a child process. Off unless turned on in Preferences. SolidCraft +//! never handles the agent's credentials: an agent that needs a login says so, and the panel +//! shows the agent's own login command for the user to run. +//! +//! - `backend.rs`: the [`backend::ChatBackend`] seam; `acp_client.rs` is the ACP backend. +//! - `gate.rs`: what the agent's tool requests may do (run, ask the user first, or never). +//! - `panel.rs`: the panel itself (docking, minimising, the picker, the conversation). +//! - Here: the conversation, turns (each one undoes as one step), and the requests waiting for +//! Allow or Deny. + +// The web build shows only a note: the process side is desktop only. +#![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))] + +pub mod acp; +#[cfg(not(target_arch = "wasm32"))] +mod acp_client; +pub mod agents; +#[cfg(not(target_arch = "wasm32"))] +mod api_client; +pub mod backend; +#[cfg(not(target_arch = "wasm32"))] +pub mod discover; +pub mod gate; +mod panel; +#[cfg(not(target_arch = "wasm32"))] +mod process; +pub mod settings; + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender}; + +use serde_json::{Value, json}; + +use crate::i18n::{format as trf, tr}; +use crate::{ControlRequest, SolveApp}; +use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus}; + +pub use panel::{show, tip as panel_tip, toggle}; + +/// Most entries kept in the conversation; older ones are dropped. +const MAX_ITEMS: usize = 2000; +/// Longest message sent. +const MAX_PROMPT: usize = 100_000; +/// How long a request waits for Allow or Deny before it is declined (the agent's MCP server gives +/// up on a request after five minutes). +const APPROVAL_TIMEOUT_MS: f64 = 240_000.0; +/// Size of the picture of the view sent with a message. +const VIEW_IMAGE: (usize, usize) = (960, 600); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Off, + Starting, + Ready, + Busy, + NeedsAuth, + Exited, +} + +impl State { + fn id(self) -> &'static str { + match self { + State::Off => "off", + State::Starting => "starting", + State::Ready => "ready", + State::Busy => "busy", + State::NeedsAuth => "auth", + State::Exited => "exited", + } + } + fn label(self) -> &'static str { + tr(match self { + State::Off => "ui.agent_chat_state_off", + State::Starting => "ui.agent_chat_state_starting", + State::Ready => "ui.agent_chat_state_ready", + State::Busy => "ui.agent_chat_state_busy", + State::NeedsAuth => "ui.agent_chat_state_auth", + State::Exited => "ui.agent_chat_state_exited", + }) + } + fn running(self) -> bool { + matches!(self, State::Starting | State::Ready | State::Busy | State::NeedsAuth) + } +} + +#[derive(Clone, Debug)] +pub enum Item { + User { + text: String, + image: bool, + }, + Agent { + thought: bool, + text: String, + }, + Tool { + id: String, + title: String, + kind: String, + status: Option, + output: Option, + }, + Plan(Vec<(String, String)>), + /// The agent asks before running something of its own (its options, its words). + Permission { + key: u64, + title: String, + options: Vec, + answer: Option, + }, + /// A request of the agent's that SolidCraft holds for the user (files, replacing the design, + /// or any change while the mode is "ask"); `tool`: what "Always allow" remembers. + Approval { + id: u64, + title: String, + tool: String, + answer: Option, + }, + /// A message's changes, merged into one undo step; `doc`: the design right after it. + Changes { + steps: usize, + doc: Arc, + undone: bool, + }, + Notice { + text: String, + error: bool, + }, +} + +/// The message being answered: what undo and the limits need. +struct Turn { + /// The design and the undo depth before it. + start_doc: Arc, + start_undo: usize, + /// Requests that ran commands, and requests of any kind. + changes: u32, + requests: u32, + limit_told: bool, + label: String, +} + +/// A request held for Allow or Deny. +struct Held { + id: u64, + req: ControlRequest, + since: f64, +} + +/// Starts the chat's own control endpoint for the agent's MCP server (the desktop host provides +/// it): requests arriving there go to the sender; the port it listens on. +pub type ControlStarter = Box) -> Option>; + +#[derive(Default)] +pub struct AgentChat { + /// The panel is shown (View menu, the application bar's chat button, Ctrl+Shift+A). + pub open: bool, + pub items: Vec, + pub input: String, + state: Option, + agent: String, + images: bool, + /// Send a picture of the view with the next message. + pub attach_view: bool, + auth: Vec, + auth_status: Option, + /// A message typed before the agent was ready. + queued: Option<(String, bool)>, + /// The last message sent, kept to send again after a sign-in. + last: Option<(String, bool)>, + /// The command the running agent was started with. + argv: Vec, + /// The agent picked for this conversation (the header's switcher); `None`: the default. + pub agent_id: Option, + /// The agent the running backend is (for permissions and "always allow"). + running_id: String, + /// The agent's own modes, and the current one. + modes: Vec<(String, String)>, + mode: String, + backend: Option>, + turn: Option, + held: Vec, + next_approval: u64, + /// The chat's control endpoint: the port, and its requests. + pub tools_port: Option, + tools_rx: Option>, + tools_tx: Option>, + /// Settings → AI: the Add agent dialog and connection tests. + pub settings: settings::State, + /// Desktop host hook: start the endpoint on demand. + pub control_starter: Option, + /// The picker is unfolded (it folds to one line once an agent is connected). + picking: Option, + #[cfg(not(target_arch = "wasm32"))] + detection: Option, + #[cfg(not(target_arch = "wasm32"))] + detecting: Option>, +} + +impl Drop for AgentChat { + fn drop(&mut self) { + self.stop_backend(true); + } +} + +impl AgentChat { + pub fn state(&self) -> State { + self.state.unwrap_or(State::Off) + } + fn push(&mut self, item: Item) { + if self.items.len() >= MAX_ITEMS { + self.items.drain(..MAX_ITEMS / 10); + } + self.items.push(item); + } + fn notice(&mut self, text: impl Into, error: bool) { + self.push(Item::Notice { text: text.into(), error }); + } + /// Requests waiting for Allow or Deny. + pub fn waiting(&self) -> usize { + self.held.len() + self.items.iter().filter(|i| matches!(i, Item::Permission { answer: None, .. })).count() + } + + fn stop_backend(&mut self, wait: bool) { + if let Some(mut b) = self.backend.take() { + b.cancel(); + b.shutdown(wait); + } + for h in self.held.drain(..) { + let _ = h.req.reply.send(json!({"ok": false, "error": "the agent chat stopped"})); + } + for i in &mut self.items { + if let Item::Approval { answer: a @ None, .. } = i { + *a = Some(false); + } + if let Item::Permission { answer: a @ None, .. } = i { + *a = Some(tr("ui.agent_chat_cancel").to_string()); + } + } + } + + /// Answer the agent's own permission question at `index` (`None`: cancelled). + pub fn answer(&mut self, index: usize, option: Option<&str>) { + let Some(Item::Permission { key, options, answer, .. }) = self.items.get_mut(index) else { return }; + if answer.is_some() { + return; + } + let label = + option.and_then(|o| options.iter().find(|x| x.id == o)).map_or_else(|| tr("ui.agent_chat_cancel").to_string(), |o| o.name.clone()); + *answer = Some(label); + let key = *key; + if let Some(b) = self.backend.as_mut() { + b.answer_permission(key, option); + } + } + + /// Ask the agent to sign in by itself (an "agent" login method: it opens the browser). + pub fn authenticate(&mut self, method: &str) { + if let Some(b) = self.backend.as_mut() { + b.authenticate(method); + self.state = Some(State::Starting); + } + } + + /// Take in what the backend reported. + fn on_event(&mut self, e: Event) -> Option { + match e { + Event::Ready { agent, images } => { + self.agent = agent; + self.images = images; + self.state = Some(State::Ready); + self.picking = Some(false); + } + Event::Text { thought, text } => { + if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut() + && *t == thought + { + last.push_str(&text); + } else { + self.push(Item::Agent { thought, text }); + } + } + Event::Tool { id, title, kind, status, output } => { + let existing = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id)); + if let Some(Item::Tool { title: t, kind: k, status: s, output: o, .. }) = existing { + if let Some(v) = title { + *t = v; + } + if let Some(v) = kind { + *k = v; + } + if status.is_some() { + *s = status; + } + if output.is_some() { + *o = output; + } + } else { + self.push(Item::Tool { id, title: title.unwrap_or_default(), kind: kind.unwrap_or_default(), status, output }); + } + } + Event::Plan(entries) => { + // A plan is replaced whole: update this turn's plan where it is. + let turn = self.items.iter().rposition(|i| matches!(i, Item::User { .. })).unwrap_or(0); + if let Some(Item::Plan(p)) = self.items.iter_mut().skip(turn).rev().find(|i| matches!(i, Item::Plan(_))) { + *p = entries; + } else { + self.push(Item::Plan(entries)); + } + } + Event::Permission { key, title, options } => return Some(Outcome::Permission { key, title, options }), + Event::Modes { current, available } => { + if !available.is_empty() { + self.modes = available; + } + self.mode = current; + } + Event::AuthRequired { methods } => { + self.auth = methods; + self.state = Some(State::NeedsAuth); + self.picking = Some(false); + // What was asked goes again once the login is done. + if self.queued.is_none() { + self.queued = self.last.take(); + } + } + Event::AuthStatus(s) => self.auth_status = Some(s), + Event::TurnEnded { reason } => { + if self.state() == State::Busy { + self.state = Some(State::Ready); + } + if !matches!(reason.as_str(), "" | "auth" | "error") { + self.notice(format!("({reason})"), false); + } + return Some(Outcome::TurnEnd); + } + Event::Error(e) => { + self.notice(e, true); + if !self.state().running() || self.state() == State::Starting { + self.state = Some(State::Exited); + } + } + Event::Exited { tail } => { + self.state = Some(State::Exited); + let last: Vec = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); + let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) }; + self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true); + self.stop_backend(false); + return Some(Outcome::TurnEnd); + } + } + None + } +} + +/// What an event asks of the app. +enum Outcome { + /// A message was answered (or the agent stopped): the turn's bookkeeping ends. + TurnEnd, + /// The agent asks before running something of its own: answered by the mode, or asked. + Permission { key: u64, title: String, options: Vec }, +} + +/// The AI features are on (the master switch in Settings → AI) and this is the desktop app. +pub fn shown(app: &SolveApp) -> bool { + app.preferences.ai_features && cfg!(not(target_arch = "wasm32")) +} + +/// The AI button at the toolbar row's right end is shown. +pub fn toolbar_button_shown(app: &SolveApp) -> bool { + shown(app) && app.preferences.ai_toolbar_button +} + +/// Whether a permission question is about one of SolidCraft's own tools (which SolidCraft's own +/// gate checks): the agents name MCP tools after their server. +fn solidcraft_tool(title: &str) -> bool { + title.to_ascii_lowercase().contains("solidcraft") +} + +/// `solidcraft-cli` next to this program, else on the search path. +#[cfg(not(target_arch = "wasm32"))] +fn find_cli() -> Option { + let name = if cfg!(windows) { "solidcraft-cli.exe" } else { "solidcraft-cli" }; + if let Some(p) = solidcraft_engine::geom::env::var("CLI").map(std::path::PathBuf::from).filter(|p| p.is_file()) { + return Some(p); + } + std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(|d| d.join(name))) + .filter(|p| p.is_file()) + .or_else(|| discover::find_in("solidcraft-cli", &discover::search_dirs())) +} + +/// The agent's working folder: an empty folder of SolidCraft's own, so an agent's own file tools +/// (where it has them) see nothing of the user's. +fn working_dir() -> std::path::PathBuf { + let d = solidcraft_engine::recovery::data_dir() + .map(|d| d.join("agent-chat").join("work")) + .unwrap_or_else(|| std::env::temp_dir().join("solidcraft-agent-chat")); + #[cfg(not(target_arch = "wasm32"))] + let _ = std::fs::create_dir_all(&d); + d +} + +/// Start the agent: the chat's control endpoint for its MCP bridge if needed, the process. +#[cfg(not(target_arch = "wasm32"))] +pub fn connect(app: &mut SolveApp, ctx: &egui::Context) { + disconnect(app); + let Some(entry) = agents::current(app) else { + app.agent_chat.notice(tr("ui.agent_chat_none_found"), true); + return; + }; + // An API agent's key comes from the keychain for this connection only. + let key = if entry.kind == agents::Kind::Api && entry.key { + match app.services.secrets.as_ref().map(|s| s.get(&entry.id)) { + Some(Ok(k)) => k, + Some(Err(e)) => { + app.agent_chat.notice(trf("ui.ai_keychain_error", &[("error", &e)]), true); + return; + } + None => None, + } + } else { + None + }; + let http = app.services.agent_http.clone(); + let chat = &mut app.agent_chat; + if chat.tools_port.is_none() + && let Some(start) = chat.control_starter.as_mut() + { + let (tx, rx) = std::sync::mpsc::channel(); + if let Some(port) = start(tx.clone()) { + chat.tools_port = Some(port); + chat.tools_rx = Some(rx); + chat.tools_tx = Some(tx); + } + } + let cli = find_cli(); + if chat.tools_port.is_none() { + chat.notice(tr("ui.agent_chat_no_control"), true); + } else if cli.is_none() { + chat.notice(tr("ui.agent_chat_no_cli"), true); + } + let bridge = cli.zip(chat.tools_port).map(|(cli, port)| acp::McpBridge { cli, port }); + let wake = ctx.clone(); + let start = backend::StartContext { cwd: working_dir(), bridge, tools: chat.tools_tx.clone(), wake: Box::new(move || wake.request_repaint()) }; + let started: Result, String> = match entry.kind { + agents::Kind::Acp if entry.argv.is_empty() => Err(tr("ui.agent_chat_none_found").to_string()), + agents::Kind::Acp => acp_client::AcpBackend::start(&entry.argv, start).map(|b| Box::new(b) as Box), + agents::Kind::Api => match http { + Some(http) => api_client::ApiBackend::start( + api_client::ApiConfig { name: entry.name.clone(), base_url: entry.base_url.clone(), model: entry.model.clone(), key, http }, + start, + ) + .map(|b| Box::new(b) as Box), + None => Err(tr("ui.ai_no_http").to_string()), + }, + }; + match started { + Ok(b) => { + chat.backend = Some(b); + chat.argv = entry.argv.clone(); + chat.running_id = entry.id.clone(); + chat.modes.clear(); + chat.mode.clear(); + chat.state = Some(State::Starting); + chat.auth.clear(); + chat.auth_status = None; + chat.agent.clear(); + } + Err(e) => { + chat.notice(trf("ui.agent_chat_failed_start", &[("error", &e)]), true); + chat.state = Some(State::Exited); + } + } +} + +/// Stop the agent (and everything it started); the conversation stays. +pub fn disconnect(app: &mut SolveApp) { + end_turn(app); + let chat = &mut app.agent_chat; + chat.stop_backend(false); + if chat.state.is_some() { + chat.state = Some(State::Off); + } +} + +/// The app is quitting: end the agent and its helpers before the window goes. +pub fn shutdown(app: &mut SolveApp) { + end_turn(app); + app.agent_chat.stop_backend(true); +} + +/// Send what the user typed: now if the agent is ready, else once it is (starting it if needed). +pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) { + let text: String = text.chars().take(MAX_PROMPT).collect(); + if text.trim().is_empty() { + return; + } + let image = app.agent_chat.attach_view && app.agent_chat.images; + app.agent_chat.push(Item::User { text: text.clone(), image }); + match app.agent_chat.state() { + State::Ready => start_turn(app, text, image), + State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false), + s => { + app.agent_chat.queued = Some((text, image)); + #[cfg(not(target_arch = "wasm32"))] + if !s.running() { + connect(app, ctx); + } + #[cfg(target_arch = "wasm32")] + let _ = (s, ctx); + } + } +} + +fn start_turn(app: &mut SolveApp, text: String, image: bool) { + let mut parts = vec![PromptPart::Text(text.clone())]; + if image && let Some(png) = crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) { + parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }); + } + let label: String = text.lines().next().unwrap_or("").chars().take(48).collect(); + app.agent_chat.turn = + Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label }); + app.agent_chat.last = Some((text, image)); + app.agent_chat.state = Some(State::Busy); + if let Some(b) = app.agent_chat.backend.as_mut() { + b.prompt(&parts); + } +} + +/// Ask the agent to stop the message it is on; requests waiting for approval are declined. +pub fn cancel(app: &mut SolveApp) { + decline_all(app, "the user stopped the message"); + if let Some(b) = app.agent_chat.backend.as_mut() { + b.cancel(); + } + for i in &mut app.agent_chat.items { + if let Item::Permission { answer: a @ None, .. } = i { + *a = Some(tr("ui.agent_chat_cancel").to_string()); + } + } +} + +fn decline_all(app: &mut SolveApp, why: &str) { + let ids: Vec = app.agent_chat.held.iter().map(|h| h.id).collect(); + for id in ids { + approve_with(app, id, false, why); + } +} + +/// Allow or deny a held request. +pub fn approve(app: &mut SolveApp, id: u64, allow: bool) { + approve_with(app, id, allow, "the user declined it"); +} + +/// Allow a held request and remember to allow its tool for this agent from now on. +pub fn approve_always(app: &mut SolveApp, id: u64) { + let tool = app.agent_chat.items.iter().find_map(|i| match i { + Item::Approval { id: x, tool, .. } if *x == id => Some(tool.clone()), + _ => None, + }); + if let Some(tool) = tool { + let agent = app.agent_chat.running_id.clone(); + agents::remember(&mut app.preferences, &agent, &tool); + } + approve(app, id, true); +} + +/// Answer the agent's own permission question at `index` with one of its options (`None`: +/// cancelled); an "always allow" answer is remembered for this agent and tool. +pub fn answer_permission(app: &mut SolveApp, index: usize, option: Option<&str>) { + if let (Some(o), Some(Item::Permission { title, options, answer: None, .. })) = (option, app.agent_chat.items.get(index)) + && options.iter().any(|x| x.id == o && x.kind == "allow_always") + { + let (agent, title) = (app.agent_chat.running_id.clone(), title.clone()); + agents::remember(&mut app.preferences, &agent, &title); + } + app.agent_chat.answer(index, option); +} + +/// The agent asks before running something of its own. SolidCraft's own tools are checked by +/// SolidCraft's gate, so they're allowed here (no double question); otherwise the mode or an +/// earlier "always allow" may answer, and the chat says so; otherwise the user is asked. +fn on_permission(app: &mut SolveApp, key: u64, title: String, options: Vec) { + let agent = app.agent_chat.running_id.clone(); + let mode = agents::mode_for(&app.preferences, &agent); + let remembered = agents::always_allowed(&app.preferences, &agent, &title); + let why = if solidcraft_tool(&title) { + Some("ui.ai_auto_ours") + } else if remembered { + Some("ui.ai_auto_remembered") + } else if mode == agents::Mode::All { + Some("ui.ai_auto_mode") + } else { + None + }; + let once = options.iter().find(|o| o.kind == "allow_once"); + let always = options.iter().find(|o| o.kind == "allow_always"); + let pick = if remembered { always.or(once) } else { once.or(always) }.map(|o| o.id.clone()); + match (why, pick) { + (Some(why), Some(option)) => { + if let Some(b) = app.agent_chat.backend.as_mut() { + b.answer_permission(key, Some(&option)); + } + app.agent_chat.push(Item::Notice { text: trf("ui.ai_auto_allowed", &[("title", &title), ("why", tr(why))]), error: false }); + } + _ => app.agent_chat.push(Item::Permission { key, title, options, answer: None }), + } +} + +fn approve_with(app: &mut SolveApp, id: u64, allow: bool, why: &str) { + let chat = &mut app.agent_chat; + let Some(i) = chat.held.iter().position(|h| h.id == id) else { return }; + let h = chat.held.remove(i); + for item in &mut chat.items { + if let Item::Approval { id: x, answer, .. } = item + && *x == id + { + *answer = Some(allow); + } + } + if allow { + app.control_held.push_back(h.req); + } else { + let _ = h.req.reply.send(json!({"ok": false, "error": format!("not run: {why}")})); + } +} + +/// The message is answered: its changes become one undo step, and what still waits is declined. +fn end_turn(app: &mut SolveApp) { + decline_all(app, "the message ended"); + let Some(turn) = app.agent_chat.turn.take() else { return }; + let steps = merge_undo(&mut app.session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)])); + if steps > 0 { + let doc = app.session.doc.clone(); + app.agent_chat.push(Item::Changes { steps, doc, undone: false }); + } +} + +/// Merge the undo steps made since `start_doc` (at depth `start_undo`) into one, labelled `label`; +/// how many steps it took in (0: none were made, or the history no longer reaches that far). +fn merge_undo( + undo: &mut Vec, + start_doc: &Arc, + start_undo: usize, + label: &str, +) -> usize { + // The first change of the turn saved the design as it was when the turn began. When the + // history is full its oldest steps go, so look for it at or below where it was put. + let Some(first) = (0..undo.len().min(start_undo + 1)).rev().find(|&i| undo.get(i).is_some_and(|s| Arc::ptr_eq(&s.doc, start_doc))) else { + return 0; + }; + let steps = undo.len() - first; + undo.truncate(first + 1); + if let Some(s) = undo.get_mut(first) { + s.label = label.to_string(); + } + steps +} + +/// Undo a message's changes from the conversation (only while they are the last thing done). +pub fn undo_changes(app: &mut SolveApp, index: usize) { + let Some(Item::Changes { doc, undone, .. }) = app.agent_chat.items.get(index) else { return }; + if *undone || !Arc::ptr_eq(doc, &app.session.doc) { + return; + } + if app.run("edit.undo", json!({})).is_ok() + && let Some(Item::Changes { undone, .. }) = app.agent_chat.items.get_mut(index) + { + *undone = true; + } +} + +/// The requests the agent's tools sent through the chat's endpoint: each one passes the gate. +fn pump_tools(app: &mut SolveApp) { + let Some(rx) = app.agent_chat.tools_rx.take() else { return }; + let limit = app.preferences.agent_chat_turn_limit.clamp(1, 1000); + for mut req in rx.try_iter().take(64) { + let refuse = |req: ControlRequest, e: String| { + let _ = req.reply.send(json!({"ok": false, "error": e})); + }; + let undo_len = app.session.undo.len(); + let Some(turn) = app.agent_chat.turn.as_mut() else { + refuse(req, "the agent chat has no message in progress".into()); + continue; + }; + turn.requests += 1; + if turn.requests > gate::MAX_REQUESTS_PER_TURN { + refuse(req, format!("too many requests in one message (at most {})", gate::MAX_REQUESTS_PER_TURN)); + continue; + } + // Requests without folders of their own stay inside the chat's working folder. + if let Some(o) = req.params.as_object_mut() + && !o.contains_key("allow_paths") + { + o.insert("allow_paths".into(), json!([working_dir().to_string_lossy()])); + } + let undo_left = undo_len.saturating_sub(turn.start_undo); + // The permission mode (the agent's own, else the global one) and "always allow" answers. + let mode = agents::mode_for(&app.preferences, &app.agent_chat.running_id); + let tool = gate::tool_key(&req.method, &req.params); + let always = agents::always_allowed(&app.preferences, &app.agent_chat.running_id, &tool); + let verdict = match gate::request(&req.method, &req.params, undo_left) { + gate::Verdict::Ask(_) if mode == agents::Mode::All || always => gate::Verdict::Run { change: true }, + gate::Verdict::Run { change: true } if mode == agents::Mode::Ask && !always => { + gate::Verdict::Ask(gate::describe(&req.method, &req.params)) + } + v => v, + }; + match verdict { + gate::Verdict::Run { change } => { + if change { + if turn.changes >= limit { + let first = !turn.limit_told; + turn.limit_told = true; + refuse(req, format!("the limit of {limit} changes for one message is reached: tell the user what is left to do")); + if first { + app.agent_chat.notice(trf("ui.agent_chat_limit", &[("limit", &limit.to_string())]), false); + } + continue; + } + turn.changes += 1; + } + app.control_held.push_back(req); + } + gate::Verdict::Ask(title) => { + let chat = &mut app.agent_chat; + chat.next_approval += 1; + let id = chat.next_approval; + chat.push(Item::Approval { id, title, tool, answer: None }); + chat.held.push(Held { id, req, since: crate::now_ms() }); + } + gate::Verdict::Refuse(e) => { + app.agent_chat.notice(trf("ui.agent_chat_refused", &[("reason", &e)]), false); + refuse(req, e); + } + } + } + app.agent_chat.tools_rx = Some(rx); + // Questions nobody answered: the agent stopped waiting long ago. + let now = crate::now_ms(); + let stale: Vec = app.agent_chat.held.iter().filter(|h| now - h.since > APPROVAL_TIMEOUT_MS).map(|h| h.id).collect(); + for id in stale { + approve_with(app, id, false, "nobody answered in time"); + } +} + +/// Read what the agent sent since the last frame, its tool requests, and the agent search. +pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(rx) = &app.agent_chat.detecting + && let Ok(d) = rx.try_recv() + { + app.agent_chat.detection = Some(d); + app.agent_chat.detecting = None; + } + // AI features turned off: nothing keeps running, nothing is looked for. + if !shown(app) { + if app.agent_chat.backend.is_some() || app.agent_chat.open { + disconnect(app); + app.agent_chat.open = false; + } + return; + } + let events = app.agent_chat.backend.as_mut().map(|b| b.poll()).unwrap_or_default(); + for e in events { + let ready = matches!(e, Event::Ready { .. }); + match app.agent_chat.on_event(e) { + Some(Outcome::TurnEnd) => end_turn(app), + Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options), + None => {} + } + if ready && let Some((text, image)) = app.agent_chat.queued.take() { + start_turn(app, text, image); + } + } + pump_tools(app); + if !app.agent_chat.held.is_empty() || app.agent_chat.state() == State::Busy { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } +} + +/// Look for installed agents in the background (cached; Refresh looks again). +#[cfg(not(target_arch = "wasm32"))] +pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) { + if app.agent_chat.detecting.is_some() { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(discover::detect()); + ctx.request_repaint(); + }); + app.agent_chat.detecting = Some(rx); +} + +/// Switch the agent to one of its own modes (the header's mode picker). +pub fn set_mode(app: &mut SolveApp, mode: &str) { + if app.agent_chat.modes.iter().any(|(id, _)| id == mode) + && let Some(b) = app.agent_chat.backend.as_mut() + { + b.set_mode(mode); + app.agent_chat.mode = mode.to_string(); + } +} + +/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, `open`, +/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `send`, `attach_view`, +/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held +/// request), `cancel`, `undo` (the newest message's changes); returns the panel's state. +pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result { + if let Some(e) = p.get("features").and_then(Value::as_bool) { + app.preferences.ai_features = e; + } + if let Some(b) = p.get("toolbar_button").and_then(Value::as_bool) { + app.preferences.ai_toolbar_button = b; + } + if let Some(m) = p.get("permissions") { + let Some(mode) = m.as_str().and_then(agents::Mode::parse) else { return Err("permissions must be ask, edits or all".into()) }; + app.preferences.ai_permissions = mode; + } + // Add an agent (as the Add agent dialog does), for tests and screenshots: {id?, name, kind, command | base_url + model}. + if let Some(a) = p.get("add_agent") { + let mut c: agents::AgentConfig = serde_json::from_value(a.clone()).map_err(|e| format!("add_agent: {e}"))?; + if c.id.is_empty() { + c.id = agents::new_id(&app.preferences, if c.kind == agents::Kind::Api { "api" } else { "custom" }); + } + c.key = false; + app.preferences.agents.retain(|x| x.id != c.id); + app.preferences.agents.push(c); + } + if let Some(id) = p.get("agent").and_then(Value::as_str) { + if !agents::entries(app).iter().any(|e| e.id == id) { + return Err(format!("no agent `{id}`")); + } + if app.agent_chat.agent_id.as_deref() != Some(id) { + disconnect(app); + app.agent_chat.agent_id = Some(id.to_string()); + } + } + // The Add agent dialog: open on a kind (`installed`, `custom`, `api`), or `false` to close. + if let Some(d) = p.get("add_dialog") { + match d.as_str() { + Some(k) => { + let kind = match k { + "installed" => settings::AddKind::Installed, + "custom" => settings::AddKind::Custom, + "api" => settings::AddKind::Api, + _ => return Err("add_dialog must be installed, custom, api or false".into()), + }; + settings::open_add(app); + if let Some(a) = app.agent_chat.settings.add.as_mut() { + a.kind = kind; + if kind != settings::AddKind::Api { + a.name.clear(); + } + } + } + None => app.agent_chat.settings.add = None, + } + } + if let Some(id) = p.get("default_agent").and_then(Value::as_str) { + app.preferences.agent_default = id.to_string(); + } + if let Some(m) = p.get("mode").and_then(Value::as_str) { + set_mode(app, m); + } + if !shown(app) { + return Ok(status(app)); + } + if let Some(o) = p.get("open").and_then(Value::as_bool) { + app.agent_chat.open = o; + } + if let Some(s) = p.get("side").and_then(Value::as_str) { + if !matches!(s, "left" | "right") { + return Err("side must be left or right".into()); + } + app.preferences.agent_chat_side = s.into(); + } + if let Some(m) = p.get("minimized").and_then(Value::as_bool) { + app.preferences.agent_chat_minimized = m; + } + if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) { + app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH); + panel::forget_width(ctx); + } + if let Some(a) = p.get("attach_view").and_then(Value::as_bool) { + app.agent_chat.attach_view = a; + } + if let Some(pick) = p.get("picker").and_then(Value::as_bool) { + app.agent_chat.picking = Some(pick); + } + #[cfg(not(target_arch = "wasm32"))] + { + if p.get("refresh").and_then(Value::as_bool) == Some(true) { + refresh(app, ctx); + } + if p.get("stop").and_then(Value::as_bool) == Some(true) { + disconnect(app); + } + if p.get("connect").and_then(Value::as_bool) == Some(true) { + connect(app, ctx); + } + } + if let Some(text) = p.get("send").and_then(Value::as_str) { + send(app, ctx, text.to_string()); + } + if let Some(o) = p.get("permission").and_then(Value::as_str) { + let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Permission { answer: None, .. })) else { + return Err("no permission request is waiting".into()); + }; + answer_permission(app, n, (o != "cancel").then_some(o)); + } + if let Some(a) = p.get("approve") { + let Some(id) = app.agent_chat.held.first().map(|h| h.id) else { return Err("no request is waiting for approval".into()) }; + match (a.as_bool(), a.as_str()) { + (Some(allow), _) => approve(app, id, allow), + (_, Some("always")) => approve_always(app, id), + _ => return Err("approve must be true, false or \"always\"".into()), + } + } + if p.get("cancel").and_then(Value::as_bool) == Some(true) { + cancel(app); + } + if p.get("undo").and_then(Value::as_bool) == Some(true) { + let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) }; + undo_changes(app, n); + } + Ok(status(app)) +} + +pub fn status(app: &SolveApp) -> Value { + let c = &app.agent_chat; + let items: Vec = c + .items + .iter() + .map(|i| match i { + Item::User { text, image } => json!({"user": text, "image": image}), + Item::Agent { thought, text } => json!({"agent": text, "thought": thought}), + Item::Tool { id, title, kind, status, output } => { + json!({"tool": id, "title": title, "kind": kind, "status": status.map(|s| format!("{s:?}")), "output": output}) + } + Item::Plan(p) => json!({"plan": p}), + Item::Permission { title, options, answer, .. } => { + json!({"permission": title, "options": options.iter().map(|o| json!({"id": o.id, "name": o.name, "kind": o.kind})).collect::>(), "answer": answer}) + } + Item::Approval { title, answer, .. } => json!({"approval": title, "answer": answer}), + Item::Changes { steps, undone, .. } => json!({"changes": steps, "undone": undone}), + Item::Notice { text, error } => json!({"notice": text, "error": error}), + }) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + let detection = c.detection.as_ref().map(|d| { + json!({ + "found": d.found.iter().map(|f| json!({"id": f.id, "name": f.name, "argv": f.argv, "version": f.version, "login": f.login.id()})).collect::>(), + "missing": d.missing.iter().map(|m| json!({"id": m.id, "install": m.install})).collect::>(), + "ms": d.took_ms, + }) + }); + #[cfg(target_arch = "wasm32")] + let detection: Option = None; + let pid = c.backend.as_ref().and_then(|b| b.pid()); + json!({ + "features": app.preferences.ai_features, + "toolbarButton": app.preferences.ai_toolbar_button, + "permissions": app.preferences.ai_permissions.id(), + "agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::>(), + "currentAgent": agents::current(app).map(|e| e.id), + "modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), + "mode": c.mode, + "open": c.open, + "side": app.preferences.agent_chat_side, + "minimized": app.preferences.agent_chat_minimized, + "width": app.preferences.agent_chat_width, + "state": c.state().id(), + "agent": c.agent, + "images": c.images, + "authStatus": c.auth_status, + "authMethods": c.auth.iter().map(|m| json!({"id": m.id, "name": m.name, "kind": match &m.kind { acp::AuthKind::Agent => "agent", acp::AuthKind::Terminal { .. } => "terminal", acp::AuthKind::EnvVar { .. } => "env_var" }})).collect::>(), + "toolsPort": c.tools_port, + "pid": pid, + "waiting": c.waiting(), + "turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})), + "detection": detection, + "items": items, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs new file mode 100644 index 00000000..44f0ecf2 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -0,0 +1,761 @@ +//! The agent chat panel: docked left or right (switched by dragging its header to the other side, +//! or in Preferences), resizable, folded to a thin strip with Minimise, closed with ×. Its side, +//! width and fold are preferences, so they come back next time. + +use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2}; + +use super::*; +use crate::theme::Tokens; + +pub const MIN_WIDTH: f32 = 280.0; +pub const MAX_WIDTH: f32 = 760.0; +/// The folded panel's width. +const STRIP: f32 = 30.0; +/// The keyboard shortcut that shows and hides the panel (also from its own text field). +pub const SHORTCUT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::A); + +fn panel_id(left: bool) -> Id { + Id::new(if left { "sc_agent_chat_left" } else { "sc_agent_chat_right" }) +} + +/// Forget the panel's own idea of its width, so the preference applies on the next frame. +pub fn forget_width(ctx: &egui::Context) { + ctx.data_mut(|d| { + for left in [true, false] { + d.remove::(panel_id(left)); + } + }); +} + +/// Show or hide the panel (a folded panel unfolds first). +pub fn toggle(app: &mut SolveApp) { + let c = &mut app.agent_chat; + if !c.open { + c.open = true; + app.preferences.agent_chat_minimized = false; + } else if app.preferences.agent_chat_minimized { + app.preferences.agent_chat_minimized = false; + } else { + c.open = false; + } +} + +/// The tooltip of the chat's buttons: its name and shortcut. +pub fn tip() -> String { + let key = if cfg!(target_os = "macos") { "Cmd+Shift+A" } else { "Ctrl+Shift+A" }; + format!("{} ({key})", tr("ui.agent_chat")) +} + +/// The panel, when the chat is turned on and open. Call before the central viewport is laid out. +pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + poll(app, &ctx); + // With AI features off nothing of the chat is drawn, and the shortcut does nothing. + if !shown(app) { + return; + } + super::settings::dialog(app, &ctx); + if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) { + toggle(app); + } + if !app.agent_chat.open { + return; + } + // Look for installed agents the first time the panel opens (in the background). + #[cfg(not(target_arch = "wasm32"))] + if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { + refresh(app, &ctx); + } + let t = Tokens::get(); + let left = app.preferences.agent_chat_side == "left"; + let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) }; + if app.preferences.agent_chat_minimized { + side(Id::new("sc_agent_chat_strip")).exact_size(STRIP).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| strip(app, ui)); + return; + } + let width = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH); + let r = side(panel_id(left)) + .default_size(width) + .size_range(MIN_WIDTH..=MAX_WIDTH) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6))) + .show(ui, |ui| body(app, ui, left)); + crate::scenario::publish_rect("agent_chat:panel", r.response.rect); + let w = r.response.rect.width(); + if w.is_finite() && (MIN_WIDTH..=MAX_WIDTH).contains(&w) { + app.preferences.agent_chat_width = w; + } +} + +/// A small drawn button: `kind` is `minimize`, `close` or `expand`. +fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response { + let t = Tokens::get(); + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + let c = r.center(); + let s = Stroke::new(1.4, t.text); + match kind { + "minimize" => { + ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s); + } + "close" => { + ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s); + ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s); + } + _ => {} + } + crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center()); + resp.on_hover_text(tip) +} + +fn state_color(state: State) -> Color32 { + let t = Tokens::get(); + match state { + State::Ready | State::Busy => t.accent, + State::NeedsAuth | State::Exited => t.warning, + _ => t.text_dim, + } +} + +/// The folded panel: the chat icon (unfolds it), the agent's state, and a mark while something +/// waits for the user. +fn strip(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let r = ui.max_rect(); + let button = Rect::from_center_size(pos2(r.center().x, r.top() + 22.0), vec2(26.0, 26.0)); + let resp = ui.interact(button, Id::new("sc_agent_chat_unfold"), Sense::click()); + crate::scenario::publish_handle("agent_chat:unfold", button.center()); + if resp.hovered() { + ui.painter().rect_filled(button, 4.0, t.hover); + } + crate::icons::paint(ui.painter(), button.shrink(4.0), "agent_chat", t.icon, t.icon_fill, t.accent); + let state = app.agent_chat.state(); + ui.painter().circle_filled(pos2(r.center().x, button.bottom() + 10.0), 3.5, state_color(state)); + let waiting = app.agent_chat.waiting(); + if waiting > 0 { + let at = button.right_top() + vec2(-3.0, 3.0); + ui.painter().circle_filled(at, 6.5, t.warning); + ui.painter().text(at, Align2::CENTER_CENTER, waiting.min(9).to_string(), egui::FontId::proportional(9.5), t.panel); + } + ui.painter().line_segment( + if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] }, + Stroke::new(1.0, t.border), + ); + let tip = format!("{} · {}", tip(), state.label()); + if resp.on_hover_text(tip).clicked() { + app.preferences.agent_chat_minimized = false; + } +} + +/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop, +/// Minimise and Close. +fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { + let t = Tokens::get(); + let state = app.agent_chat.state(); + ui.horizontal(|ui| { + let title = { + let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover()); + for row in 0..3 { + for col in 0..2 { + ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim); + } + } + let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0)); + grip.union(l.rect) + }; + let mut s = state.label().to_string(); + if let Some(turn) = &app.agent_chat.turn + && state == State::Busy + { + let limit = app.preferences.agent_chat_turn_limit; + s = format!("{s} {}", trf("ui.agent_chat_changes_of", &[("n", &turn.changes.to_string()), ("limit", &limit.to_string())])); + } + ui.label(RichText::new(s).color(state_color(state)).small()); + // Drag the header across the window to dock on the other side. + let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag()); + crate::scenario::publish_handle("agent_chat:grip", title.center()); + dock_drag(app, ui, &drag, left); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() { + app.agent_chat.open = false; + } + if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() { + app.preferences.agent_chat_minimized = true; + } + #[cfg(not(target_arch = "wasm32"))] + { + let b = if state.running() { + ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip")) + } else { + ui.button(tr("ui.agent_chat_connect")) + }; + crate::scenario::publish_handle("agent_chat:connect", b.rect.center()); + if b.clicked() { + if state.running() { + disconnect(app); + } else { + connect(app, ui.ctx()); + } + } + } + }); + }); +} + +/// While the header is dragged: the cursor grabs and the side it would dock on lights up; on +/// release over the other half of the window the panel moves there. +fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: bool) { + if drag.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + let screen = ui.ctx().content_rect(); + let target = |x: f32| x < screen.center().x; + if drag.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let to_left = target(p.x); + let w = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH); + let top = screen.top() + crate::toolbar::APP_BAR_HEIGHT; + let zone = if to_left { + Rect::from_min_max(pos2(screen.left(), top), pos2(screen.left() + w, screen.bottom())) + } else { + Rect::from_min_max(pos2(screen.right() - w, top), pos2(screen.right(), screen.bottom())) + }; + let t = Tokens::get(); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("sc_agent_chat_dock_hint"))); + painter.rect(zone.shrink(4.0), 8.0, t.accent.gamma_multiply(0.18), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + painter.text( + zone.center(), + Align2::CENTER_CENTER, + tr(if to_left { "ui.agent_chat_dock_left" } else { "ui.agent_chat_dock_right" }), + egui::FontId::proportional(14.0), + t.text, + ); + } + if drag.drag_stopped() + && let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos()) + && target(p.x) != left + { + app.preferences.agent_chat_side = if left { "right" } else { "left" }.into(); + } +} + +fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { + header(app, ui, left); + #[cfg(not(target_arch = "wasm32"))] + switcher(app, ui); + #[cfg(target_arch = "wasm32")] + { + ui.label(tr("ui.agent_chat_desktop_only")); + } + #[cfg(not(target_arch = "wasm32"))] + desktop_body(app, ui); +} + +#[cfg(not(target_arch = "wasm32"))] +fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { + if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { + refresh(app, ui.ctx()); + } + let state = app.agent_chat.state(); + picker(app, ui); + if state == State::NeedsAuth { + auth_box(app, ui); + } + ui.separator(); + // The input sits at the bottom; the conversation takes the rest. + let input_h = 92.0; + let avail = (ui.available_height() - input_h).max(80.0); + egui::ScrollArea::vertical().id_salt("sc_agent_chat_scroll").max_height(avail).auto_shrink([false, false]).stick_to_bottom(true).show(ui, |ui| { + conversation(app, ui); + }); + ui.separator(); + let busy = state == State::Busy; + let edit = egui::TextEdit::multiline(&mut app.agent_chat.input) + .hint_text(tr("ui.agent_chat_placeholder")) + .desired_rows(2) + .desired_width(f32::INFINITY) + .id_salt("sc_agent_chat_input"); + let r = ui.add(edit); + crate::scenario::publish_rect("agent_chat:input", r.rect); + let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift); + ui.horizontal(|ui| { + let send_b = ui.add_enabled(!busy, egui::Button::new(tr("ui.agent_chat_send"))); + crate::scenario::publish_handle("agent_chat:send", send_b.rect.center()); + if (send_b.clicked() || (enter && !busy)) && !app.agent_chat.input.trim().is_empty() { + let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string(); + send(app, ui.ctx(), text); + } + if busy && ui.button(tr("ui.agent_chat_cancel")).clicked() { + cancel(app); + } + // A picture of the view goes with the message to agents that take pictures. + let images = app.agent_chat.images || !state.running(); + let attach = ui + .add_enabled(images, egui::Checkbox::new(&mut app.agent_chat.attach_view, tr("ui.agent_chat_attach_view"))) + .on_hover_text(tr("ui.agent_chat_attach_view_tip")) + .on_disabled_hover_text(tr("ui.agent_chat_no_images")); + crate::scenario::publish_handle("agent_chat:attach", attach.rect.center()); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn login_chip(ui: &mut egui::Ui, login: &str) { + let t = Tokens::get(); + let (text, color) = match login { + "signed_in" => (tr("ui.agent_chat_signed_in"), t.accent), + "key" => (tr("ui.agent_chat_key_in_env"), t.accent), + "signed_out" => (tr("ui.agent_chat_not_signed_in"), t.warning), + _ => return, + }; + egui::Frame::NONE.stroke(Stroke::new(1.0, color)).corner_radius(8.0).inner_margin(egui::Margin::symmetric(6, 1)).show(ui, |ui| { + ui.label(RichText::new(text).color(color).size(11.0)); + }); +} + +/// A small mark for an agent's kind: a terminal prompt for a program, a cloud for an endpoint. +pub(super) fn kind_icon(ui: &mut egui::Ui, kind: agents::Kind) { + let t = Tokens::get(); + let (r, _) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::hover()); + let p = ui.painter(); + let s = Stroke::new(1.3, t.text_dim); + match kind { + agents::Kind::Acp => { + p.rect_stroke(r.shrink(1.0), 2.0, s, egui::StrokeKind::Inside); + p.line_segment([r.left_top() + vec2(4.0, 4.0), r.left_top() + vec2(7.0, 7.0)], s); + p.line_segment([r.left_top() + vec2(7.0, 7.0), r.left_top() + vec2(4.0, 10.0)], s); + p.line_segment([r.left_top() + vec2(8.5, 10.0), r.left_top() + vec2(12.0, 10.0)], s); + } + agents::Kind::Api => { + let c = r.center(); + p.circle_stroke(c + vec2(-3.0, 1.5), 3.2, s); + p.circle_stroke(c + vec2(1.0, -1.0), 4.2, s); + p.circle_stroke(c + vec2(4.0, 2.0), 2.8, s); + } + } +} + +/// The header's second row: the agent this conversation uses (a compact switcher), the agent's +/// own mode when it has modes, and the state of its login. +#[cfg(not(target_arch = "wasm32"))] +fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let all = agents::entries(app); + let current = agents::current(app); + ui.horizontal(|ui| { + let label = current.as_ref().map_or_else( + || tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(), + |e| e.name.clone(), + ); + let w = (ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 120.0 }).clamp(120.0, 260.0); + let mut picked: Option = None; + let mut add = false; + let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| { + for e in &all { + let sel = current.as_ref().is_some_and(|c| c.id == e.id); + let r = ui.horizontal(|ui| { + kind_icon(ui, e.kind); + let r = ui.selectable_label(sel, &e.name); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + login_chip(ui, e.login); + r + }); + crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.inner.rect.center()); + if r.inner.clicked() { + picked = Some(e.id.clone()); + } + } + ui.separator(); + if ui.selectable_label(false, tr("ui.ai_add_agent")).clicked() { + add = true; + } + if ui.selectable_label(false, tr("ui.ai_settings")).clicked() { + crate::prefs::open_at(app, crate::prefs::PAGE_AI); + } + }); + crate::scenario::publish_handle("agent_chat:switcher", combo.response.rect.center()); + combo.response.on_hover_text(tr("ui.ai_switch_tip")); + if add { + super::settings::open_add(app); + } + if let Some(id) = picked + && current.as_ref().is_none_or(|c| c.id != id) + { + disconnect(app); + app.agent_chat.agent_id = Some(id); + } + // The agent's own modes (ACP `modes`): its own tools follow them. + if !app.agent_chat.modes.is_empty() { + let modes = app.agent_chat.modes.clone(); + let cur = modes.iter().find(|(id, _)| *id == app.agent_chat.mode).map_or_else(|| app.agent_chat.mode.clone(), |(_, n)| n.clone()); + let mut pick: Option = None; + let r = egui::ComboBox::from_id_salt("sc_agent_mode").width(100.0).selected_text(cur).show_ui(ui, |ui| { + for (id, name) in &modes { + if ui.selectable_label(*id == app.agent_chat.mode, name).clicked() { + pick = Some(id.clone()); + } + } + }); + crate::scenario::publish_handle("agent_chat:mode", r.response.rect.center()); + r.response.on_hover_text(tr("ui.ai_agent_mode_tip")); + if let Some(m) = pick { + set_mode(app, &m); + } + } + }); + if let Some(a) = &app.agent_chat.auth_status { + ui.label(RichText::new(a).color(t.text_dim).size(11.0)); + } +} + +/// Before the first connection: what's installed, how to install the rest, and Add agent. +#[cfg(not(target_arch = "wasm32"))] +fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let open = app.agent_chat.picking.unwrap_or(!app.agent_chat.state().running() && app.agent_chat.items.is_empty()); + if !open { + return; + } + let all = agents::entries(app); + let current = agents::current(app); + ui.horizontal(|ui| { + ui.label(RichText::new(tr("ui.agent_chat_pick")).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let refreshing = app.agent_chat.detecting.is_some(); + if ui.add_enabled(!refreshing, egui::Button::new(tr("ui.agent_chat_refresh")).small()).clicked() { + refresh(app, ui.ctx()); + } + if ui.small_button(tr("ui.ai_add_agent")).clicked() { + super::settings::open_add(app); + } + }); + }); + if app.agent_chat.detecting.is_some() { + ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim)); + } + for e in &all { + let selected = current.as_ref().is_some_and(|c| c.id == e.id); + let fill = if selected { t.accent_soft } else { Color32::TRANSPARENT }; + let r = egui::Frame::NONE.fill(fill).corner_radius(5.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + kind_icon(ui, e.kind); + ui.label(RichText::new(&e.name).strong().color(t.text)); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| login_chip(ui, e.login)); + }); + }); + let resp = ui.interact(r.response.rect, Id::new(("sc_agent_pick", &e.id)), Sense::click()); + crate::scenario::publish_handle(&format!("agent_chat:pick:{}", e.id), resp.rect.center()); + if resp.clicked() { + app.agent_chat.agent_id = Some(e.id.clone()); + } + } + let missing: Vec = app.agent_chat.detection.as_ref().map(|d| d.missing.clone()).unwrap_or_default(); + if !missing.is_empty() { + let heading = if all.is_empty() { tr("ui.agent_chat_none_found") } else { tr("ui.agent_chat_not_installed") }; + egui::CollapsingHeader::new(RichText::new(heading).color(t.text_dim)).id_salt("sc_agent_missing").default_open(all.is_empty()).show( + ui, + |ui| { + for m in missing { + ui.horizontal(|ui| { + ui.label(RichText::new(m.name).strong()); + if ui.small_button(tr("ui.agent_chat_copy")).on_hover_text(m.install).clicked() { + ui.ctx().copy_text(m.install.to_string()); + } + }); + ui.add(egui::Label::new(RichText::new(m.install).monospace().size(11.0).color(t.text_dim)).selectable(true).wrap()); + } + }, + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.warning)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(tr("ui.agent_chat_auth_title")).strong()); + ui.label(RichText::new(tr("ui.agent_chat_auth_needed")).color(t.text_dim)); + let methods = app.agent_chat.auth.clone(); + let base = app.agent_chat.argv.iter().map(|a| quote(a)).collect::>().join(" "); + if methods.is_empty() { + ui.label(tr("ui.agent_chat_auth_none")); + } + for m in methods { + ui.add_space(4.0); + let desc = m.description.trim(); + ui.label(RichText::new(if desc.is_empty() || desc == m.name { m.name.clone() } else { format!("{} — {desc}", m.name) }).strong()); + match &m.kind { + acp::AuthKind::Terminal { args, .. } => { + ui.label(tr("ui.agent_chat_auth_terminal")); + let cmd = format!("{base} {}", args.iter().map(|a| quote(a)).collect::>().join(" ")); + ui.horizontal_wrapped(|ui| { + egui::Frame::NONE.fill(t.toolbar).corner_radius(4.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(&cmd).monospace().size(11.0)).selectable(true).wrap()); + }); + if ui.small_button(tr("ui.agent_chat_copy")).clicked() { + ui.ctx().copy_text(cmd.clone()); + } + }); + } + acp::AuthKind::Agent => { + let b = ui.button(trf("ui.agent_chat_auth_agent", &[("name", &m.name)])); + crate::scenario::publish_handle(&format!("agent_chat:auth:{}", m.id), b.rect.center()); + if b.clicked() { + app.agent_chat.authenticate(&m.id); + } + } + acp::AuthKind::EnvVar { vars } => { + ui.label(trf("ui.agent_chat_auth_env", &[("vars", &vars.join(", "))])); + } + } + } + ui.add_space(4.0); + if ui.button(tr("ui.agent_chat_auth_retry")).clicked() { + connect(app, ui.ctx()); + } + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let mut answer: Option<(usize, Option)> = None; + // Allow (true), Deny (false), Always allow (None). + let mut approval: Option<(u64, Option)> = None; + let mut undo: Option = None; + let current_doc = app.session.doc.clone(); + for (n, item) in app.agent_chat.items.iter().enumerate() { + match item { + Item::User { text, image } => { + ui.add_space(6.0); + egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(text).color(t.text)).selectable(true).wrap()); + if *image { + ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0)); + } + }); + } + Item::Agent { thought, text } => { + let rt = if *thought { RichText::new(text).italics().color(t.text_dim) } else { RichText::new(text).color(t.text) }; + ui.add(egui::Label::new(rt).selectable(true).wrap()); + } + Item::Tool { title, kind, status, output, .. } => { + tool_card(ui, n, title, kind, *status, output.as_deref()); + } + Item::Plan(entries) => { + for (c, s) in entries { + let (color, filled) = match s.as_str() { + "completed" => (t.accent, true), + "in_progress" => (t.warning, true), + _ => (t.text_dim, false), + }; + ui.horizontal(|ui| { + status_dot(ui, color, filled); + ui.label(RichText::new(c).color(t.text_dim)); + }); + } + } + Item::Permission { title, options, answer: given, .. } => { + egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.accent)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(trf("ui.agent_chat_permission", &[("title", title)])).strong()); + match given { + Some(a) => { + ui.label(RichText::new(trf("ui.agent_chat_answered", &[("option", a)])).color(t.text_dim)); + } + None => { + ui.horizontal_wrapped(|ui| { + for o in options { + let b = egui::Button::new(&o.name); + let b = if o.kind.starts_with("allow") { b.fill(t.accent_soft) } else { b }; + let r = ui.add(b); + crate::scenario::publish_handle(&format!("agent_chat:permission:{}", o.id), r.rect.center()); + if r.clicked() { + answer = Some((n, Some(o.id.clone()))); + } + } + }); + } + } + }); + } + Item::Approval { id, title, tool, answer: given } => { + egui::Frame::group(ui.style()).stroke(Stroke::new(1.5, t.warning)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new(tr("ui.agent_chat_approval")).strong()); + ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5)).selectable(true).wrap()); + match given { + Some(true) => { + ui.label(RichText::new(tr("ui.agent_chat_allowed")).color(t.text_dim)); + } + Some(false) => { + ui.label(RichText::new(tr("ui.agent_chat_denied")).color(t.text_dim)); + } + None => { + ui.horizontal(|ui| { + let allow = ui.add(egui::Button::new(tr("ui.agent_chat_allow")).fill(t.accent_soft)); + let always = ui.button(tr("ui.ai_always_allow")).on_hover_text(trf("ui.ai_always_allow_tip", &[("tool", tool)])); + let deny = ui.button(tr("ui.agent_chat_deny")); + crate::scenario::publish_handle("agent_chat:allow", allow.rect.center()); + crate::scenario::publish_handle("agent_chat:always", always.rect.center()); + crate::scenario::publish_handle("agent_chat:deny", deny.rect.center()); + if allow.clicked() { + approval = Some((*id, Some(true))); + } + if always.clicked() { + approval = Some((*id, None)); + } + if deny.clicked() { + approval = Some((*id, Some(false))); + } + }); + } + } + }); + } + Item::Changes { steps, doc, undone } => { + ui.horizontal(|ui| { + let text = trf("ui.agent_chat_changes", &[("n", &steps.to_string())]); + ui.label(RichText::new(text).color(t.text_dim).size(11.5)); + if *undone { + ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(11.5)); + } else if Arc::ptr_eq(doc, ¤t_doc) { + let b = ui.button(tr("ui.agent_chat_undo_changes")).on_hover_text(tr("ui.ai_undo_tip")); + crate::scenario::publish_handle("agent_chat:undo", b.rect.center()); + if b.clicked() { + undo = Some(n); + } + } + }); + } + Item::Notice { text, error } => { + ui.label(RichText::new(text).color(if *error { t.error } else { t.text_dim })); + } + } + ui.add_space(2.0); + } + if app.agent_chat.items.is_empty() { + ui.add_space(12.0); + ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim)); + ui.add_space(6.0); + ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0)); + } + if let Some((n, o)) = answer { + answer_permission(app, n, o.as_deref()); + } + match approval { + Some((id, Some(allow))) => approve(app, id, allow), + Some((id, None)) => approve_always(app, id), + None => {} + } + if let Some(n) = undo { + undo_changes(app, n); + } +} + +/// A tool call as a card: its state, name and kind on one line; a click unfolds what it returned. +#[cfg(not(target_arch = "wasm32"))] +fn tool_card(ui: &mut egui::Ui, n: usize, title: &str, kind: &str, status: Option, output: Option<&str>) { + let t = Tokens::get(); + let (color, filled, state) = match status { + Some(ToolStatus::Completed) => (t.accent, true, "ui.ai_tool_done"), + Some(ToolStatus::Failed) => (t.error, true, "ui.ai_tool_failed"), + Some(ToolStatus::InProgress) => (t.warning, true, "ui.ai_tool_running"), + _ => (t.text_dim, false, "ui.ai_tool_waiting"), + }; + egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(6, 3)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + let id = ui.make_persistent_id(("sc_agent_tool", n)); + let mut state_ = egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false); + let head = ui.horizontal(|ui| { + status_dot(ui, color, filled); + // The state and kind keep their room at the right; the title is cut short before them. + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label(RichText::new(tr(state)).size(10.5).color(color)); + if !kind.is_empty() && kind != "other" { + ui.label(RichText::new(kind).size(10.5).color(t.text_dim)); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5).color(t.text)).truncate()).on_hover_text(title); + }); + }); + }); + let click = ui.interact(head.response.rect, id.with("head"), Sense::click()); + crate::scenario::publish_handle(&format!("agent_chat:tool:{n}"), head.response.rect.center()); + if output.is_some() && click.clicked() { + state_.toggle(ui); + } + if output.is_some() { + click.on_hover_text(tr("ui.ai_tool_unfold")); + } + state_.show_body_unindented(ui, |ui| { + if let Some(o) = output { + ui.separator(); + egui::ScrollArea::vertical().id_salt(("sc_agent_tool_out", n)).max_height(180.0).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(o).monospace().size(11.0)).selectable(true).wrap()); + }); + } + }); + }, + ); +} + +/// A small status mark drawn as a shape (no glyph a system font might lack): filled when the +/// step is running or done, hollow while it waits. +#[cfg(not(target_arch = "wasm32"))] +fn status_dot(ui: &mut egui::Ui, color: Color32, filled: bool) { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + if filled { + ui.painter().circle_filled(r.center(), 4.0, color); + } else { + ui.painter().circle_stroke(r.center(), 3.5, Stroke::new(1.2, color)); + } +} + +/// A custom command as the picker shows it: the program's file name and its arguments, cut short. +pub(super) fn short_command(line: &str) -> String { + let argv = acp::split_command(line); + let mut parts: Vec = argv.iter().map(|a| a.rsplit(['/', '\\']).next().unwrap_or(a).to_string()).collect(); + if parts.is_empty() { + parts.push(line.to_string()); + } + let s = parts.join(" "); + if s.chars().count() > 40 { format!("{}…", s.chars().take(40).collect::()) } else { s } +} + +/// Quote an argument for the command field when it holds spaces. +pub(super) fn quote(a: &str) -> String { + if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') { format!("\"{}\"", a.replace('"', "\\\"")) } else { a.to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_commands_show_short_in_the_picker() { + assert_eq!(short_command("python3 -I /very/long/path/to/mock_agent.py"), "python3 -I mock_agent.py"); + assert!(short_command(&"x".repeat(100)).ends_with('…')); + } + + #[test] + fn the_shortcut_shows_folds_and_hides() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + toggle(&mut app); + assert!(app.agent_chat.open); + app.preferences.agent_chat_minimized = true; + toggle(&mut app); + assert!(app.agent_chat.open && !app.preferences.agent_chat_minimized, "a folded panel unfolds"); + toggle(&mut app); + assert!(!app.agent_chat.open); + } +} diff --git a/crates/ui-egui/src/agent_chat/process.rs b/crates/ui-egui/src/agent_chat/process.rs new file mode 100644 index 00000000..4fc64484 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/process.rs @@ -0,0 +1,318 @@ +//! The agent as a child process (desktop only): its stdout is read on a thread and handed to the +//! panel line by line, its stdin written on another so a slow agent never blocks a frame. +//! +//! Agents start helpers of their own (Node adapters start the CLI they wrap, which starts the MCP +//! servers it was given), so stopping only the program we started would leave those running. The +//! agent therefore gets a process group of its own on Unix (on Windows, `taskkill /T` walks its +//! tree), and [`AgentProcess::shutdown`] ends the whole group: stdin is closed first (an ACP agent +//! ends at end of input), then the group is asked to stop, then stopped. Everything is addressed +//! by the ids of the processes we started, never by name. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::{Duration, Instant}; + +/// Longest line read from the agent; a longer one is dropped. +const MAX_LINE: usize = 8 << 20; +/// Lines of the agent's stderr kept to explain a failed start. +const STDERR_LINES: usize = 40; +/// How long an agent gets to end by itself once its input is closed, and then once asked to stop. +const GRACE: Duration = Duration::from_millis(1200); + +pub enum FromAgent { + Line(String), + /// stdout closed: the agent ended. Its last stderr lines, for the panel. + Exited(Vec), +} + +pub struct AgentProcess { + child: ProcessHandle, + to_agent: Option>, + pub from_agent: Receiver, +} + +/// Ends the process from another thread than the one that talks to it. +#[derive(Clone)] +pub struct ProcessHandle(std::sync::Arc>>); + +impl ProcessHandle { + pub fn pid(&self) -> Option { + self.0.lock().ok().and_then(|c| c.as_ref().map(Child::id)) + } + /// End it and everything it started; `wait`: block until done. + pub fn shutdown(&self, wait: bool) { + let Some(child) = self.0.lock().ok().and_then(|mut c| c.take()) else { return }; + if wait { + end_tree(child); + } else { + std::thread::spawn(move || end_tree(child)); + } + } +} + +/// On Windows, npm installs `.cmd` shims, which only `cmd.exe` runs. +fn command_for(program: &str) -> Command { + let lower = program.to_ascii_lowercase(); + if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(program); + c + } else { + Command::new(program) + } +} + +/// Start the agent in a process group of its own (Unix) or without a console window (Windows). +fn detach_group(cmd: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW: no console flashes up for a console agent. + cmd.creation_flags(0x0800_0000); + } +} + +/// Signal the agent's whole process group (`signal`: `TERM` or `KILL`). The group's id is the +/// agent's own process id, as it was started with `process_group(0)`. +#[cfg(unix)] +fn signal_group(pgid: u32, signal: &str) { + if pgid <= 1 { + return; + } + let mut c = Command::new("kill"); + c.args(["-s", signal]); + // procps' kill reads `-123` as an option without `--`; BSD kill takes it as is. + if cfg!(target_os = "linux") { + c.arg("--"); + } + let _ = c.arg(format!("-{pgid}")).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status(); +} + +/// Windows: end the agent and every process it started. +#[cfg(windows)] +fn kill_tree(pid: u32) { + use std::os::windows::process::CommandExt; + let _ = Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .creation_flags(0x0800_0000) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Wait up to `limit` for the child to end; whether it did. +fn wait_for(child: &mut Child, limit: Duration) -> bool { + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => return false, + Err(_) => return true, + } + } +} + +/// End the agent and everything it started (see the module note). Blocks for at most about +/// three seconds. +fn end_tree(mut child: Child) { + let pid = child.id(); + // Its input is already closed: a well-behaved agent ends now and stops its own helpers. + let ended = wait_for(&mut child, GRACE); + #[cfg(unix)] + { + // Helpers can outlive the agent: the group is signalled whether or not it ended. The + // group id stays reserved while any member lives, so it can't name someone else's. + signal_group(pid, "TERM"); + if !ended { + wait_for(&mut child, GRACE); + } + signal_group(pid, "KILL"); + } + #[cfg(windows)] + { + if !ended { + kill_tree(pid); + } + } + #[cfg(not(any(unix, windows)))] + let _ = (pid, ended); + let _ = child.kill(); + let _ = child.wait(); +} + +impl AgentProcess { + /// Start `argv` (program first) in `cwd`; `wake` is called whenever a line arrives. + /// `env_remove`: variables the agent must not inherit. + pub fn spawn( + argv: &[String], + cwd: &std::path::Path, + env_remove: &[&str], + wake: impl Fn() + Send + Sync + 'static, + ) -> Result { + let Some((program, args)) = argv.split_first() else { return Err("no agent command".into()) }; + let mut cmd = command_for(program); + cmd.args(args).current_dir(cwd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + for v in env_remove { + cmd.env_remove(v); + } + detach_group(&mut cmd); + let mut child = cmd.spawn().map_err(|e| format!("{program}: {e}"))?; + let (Some(mut stdin), Some(stdout), Some(stderr)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else { + end_tree(child); + return Err("the agent's pipes could not be opened".into()); + }; + let (to_agent, rx) = channel::(); + std::thread::spawn(move || { + for line in rx { + if stdin.write_all(line.as_bytes()).and_then(|()| stdin.write_all(b"\n")).and_then(|()| stdin.flush()).is_err() { + break; + } + } + // The sender is gone: stdin closes here, which is how an ACP agent learns to end. + }); + let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::::new())); + let tail_w = tail.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if let Ok(mut t) = tail_w.lock() { + if t.len() >= STDERR_LINES { + t.pop_front(); + } + t.push_back(line.chars().take(400).collect()); + } + } + }); + let (tx, from_agent) = channel::(); + let wake = std::sync::Arc::new(wake); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut buf = Vec::new(); + match (&mut reader).take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf) { + Ok(0) | Err(_) => break, + Ok(_) if buf.len() > MAX_LINE => { + // Skip the rest of an oversized line. + let mut rest = Vec::new(); + if reader.read_until(b'\n', &mut rest).is_err() { + break; + } + } + Ok(_) => { + if tx.send(FromAgent::Line(String::from_utf8_lossy(&buf).into_owned())).is_err() { + return; + } + wake(); + } + } + } + // Give stderr a moment to deliver the agent's last words. + std::thread::sleep(Duration::from_millis(100)); + let lines = tail.lock().map(|t| t.iter().cloned().collect()).unwrap_or_default(); + let _ = tx.send(FromAgent::Exited(lines)); + wake(); + }); + Ok(AgentProcess { child: ProcessHandle(std::sync::Arc::new(std::sync::Mutex::new(Some(child)))), to_agent: Some(to_agent), from_agent }) + } + + pub fn send(&self, msg: &serde_json::Value) { + if let Some(t) = &self.to_agent { + let _ = t.send(msg.to_string()); + } + } + + pub fn pid(&self) -> Option { + self.child.pid() + } + + pub fn handle(&self) -> ProcessHandle { + self.child.clone() + } + + /// End the agent and its helpers. `wait`: block until they are gone (quitting the app); + /// otherwise the ending runs on a thread of its own and this returns at once. + pub fn shutdown(&mut self, wait: bool) { + self.to_agent = None; + self.child.shutdown(wait); + } +} + +impl Drop for AgentProcess { + fn drop(&mut self) { + self.shutdown(false); + } +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::*; + + fn alive(pid: u32) -> bool { + // `kill -0` checks that the process exists without touching it. A zombie still counts + // on Linux, so /proc's state is checked there too. + #[cfg(target_os = "linux")] + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + return !stat.rsplit(')').next().is_some_and(|s| s.trim_start().starts_with('Z')); + } + Command::new("kill").args(["-0", &pid.to_string()]).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) + } + + /// Start a fake agent that leaves helpers behind, read the helpers' ids from it, end it. + fn helpers_after_shutdown(script: &str) -> Vec { + let dir = std::env::temp_dir(); + let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; + let mut p = AgentProcess::spawn(&argv, &dir, &[], || {}).unwrap(); + let mut pids = vec![p.pid().unwrap()]; + while pids.len() < 3 { + match p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() { + FromAgent::Line(l) => pids.push(l.trim().parse().unwrap()), + FromAgent::Exited(tail) => panic!("the fake agent ended early: {tail:?}"), + } + } + assert!(pids.iter().all(|&pid| alive(pid)), "all running before the shutdown: {pids:?}"); + p.shutdown(true); + // Orphaned helpers are reaped by init a moment after they end. + let start = Instant::now(); + while pids.iter().any(|&pid| alive(pid)) && start.elapsed() < Duration::from_secs(5) { + std::thread::sleep(Duration::from_millis(50)); + } + pids.into_iter().filter(|&pid| alive(pid)).collect() + } + + /// No orphans: an agent that started helpers (as Node adapters start the CLI they wrap and + /// its MCP servers) is ended with all of them, whether it ends politely at end of input or + /// ignores both its input and the request to stop. + #[test] + fn stopping_the_agent_leaves_no_helpers_running() { + // A polite agent: ends when its input closes; its helpers would live on by themselves. + let polite = "sleep 300 & echo $!; sleep 300 & echo $!; cat >/dev/null"; + assert_eq!(helpers_after_shutdown(polite), Vec::::new(), "polite agent"); + // A stubborn one: ignores end of input and TERM (so do its helpers). + let stubborn = "trap '' TERM; (trap '' TERM; sleep 300) & echo $!; (trap '' TERM; sleep 300) & echo $!; while :; do sleep 1; done"; + assert_eq!(helpers_after_shutdown(stubborn), Vec::::new(), "stubborn agent"); + } + + /// Dropping the process (closing the panel's connection any other way) ends it too. + #[test] + fn dropping_the_process_ends_its_group() { + let argv = vec!["sh".to_string(), "-c".to_string(), "sleep 300 & echo $!; wait".to_string()]; + let p = AgentProcess::spawn(&argv, &std::env::temp_dir(), &[], || {}).unwrap(); + let leader = p.pid().unwrap(); + let FromAgent::Line(l) = p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() else { panic!("no helper id") }; + let helper: u32 = l.trim().parse().unwrap(); + drop(p); + let start = Instant::now(); + while (alive(helper) || alive(leader)) && start.elapsed() < Duration::from_secs(8) { + std::thread::sleep(Duration::from_millis(50)); + } + assert!(!alive(helper) && !alive(leader), "leader {leader} or helper {helper} still running"); + } +} diff --git a/crates/ui-egui/src/agent_chat/settings.rs b/crates/ui-egui/src/agent_chat/settings.rs new file mode 100644 index 00000000..4109a537 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/settings.rs @@ -0,0 +1,541 @@ +//! Settings → AI: the master switch, the agents (found and added, each with Test, Default and, +//! for added ones, Remove), permissions (global and per agent, and the "always allow" answers), +//! and the panel. Plus the Add agent dialog: an installed ACP agent, a custom command, or an +//! OpenAI-compatible endpoint (whose key goes to the system keychain). + +use egui::{RichText, Stroke}; + +use super::agents::{self, AgentConfig, Kind, Mode}; +use super::*; +use crate::theme::Tokens; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AddKind { + #[default] + Installed, + Custom, + Api, +} + +/// The Add agent dialog's fields. +#[derive(Default)] +pub struct AddState { + pub kind: AddKind, + pub name: String, + pub command: String, + pub installed: usize, + pub preset: usize, + pub base_url: String, + pub model: String, + pub key: String, + pub error: Option, + test: Option, +} + +/// A connection test running or done. +pub enum Test { + Running(std::sync::mpsc::Receiver>), + Done(Result), +} + +impl Test { + fn poll(&mut self) { + if let Test::Running(rx) = self + && let Ok(r) = rx.try_recv() + { + *self = Test::Done(r); + } + } +} + +/// Settings state that isn't preferences: the dialog and the tests. +#[derive(Default)] +pub struct State { + pub add: Option, + pub tests: std::collections::HashMap, +} + +/// Open the Add agent dialog. +pub fn open_add(app: &mut SolveApp) { + let preset = agents::API_PRESETS.first(); + app.agent_chat.settings.add = Some(AddState { + base_url: preset.map(|p| p.1.to_string()).unwrap_or_default(), + name: preset.map(|p| p.0.to_string()).unwrap_or_default(), + ..Default::default() + }); +} + +/// Run a connection test in the background (it starts the agent briefly, or asks the endpoint +/// for its models). +#[cfg(not(target_arch = "wasm32"))] +fn start_test(app: &SolveApp, e: &agents::Entry, key: Option) -> Test { + let (tx, rx) = std::sync::mpsc::channel(); + match e.kind { + Kind::Acp => { + let argv = e.argv.clone(); + std::thread::spawn(move || { + let _ = tx.send(super::acp_client::test_agent(&argv, &working_dir())); + }); + } + Kind::Api => { + let Some(http) = app.services.agent_http.clone() else { return Test::Done(Err(tr("ui.ai_no_http").to_string())) }; + let (url, model) = (e.base_url.clone(), e.model.clone()); + std::thread::spawn(move || { + let _ = tx.send(super::api_client::test_endpoint(&http, &url, key.as_deref(), &model)); + }); + } + } + Test::Running(rx) +} + +fn test_line(ui: &mut egui::Ui, t: &Option<&Test>) { + let tk = Tokens::get(); + match t { + Some(Test::Running(_)) => { + ui.label(RichText::new(tr("ui.ai_testing")).color(tk.text_dim).size(11.0)); + } + Some(Test::Done(Ok(m))) => { + ui.label(RichText::new(m.clone()).color(tk.accent).size(11.0)); + } + Some(Test::Done(Err(e))) => { + ui.add(egui::Label::new(RichText::new(e).color(tk.error).size(11.0)).wrap()); + } + None => {} + } +} + +fn section(ui: &mut egui::Ui, title: &str) { + ui.add_space(10.0); + ui.label(RichText::new(title).strong().size(13.0)); + ui.separator(); +} + +fn note(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(); + ui.add(egui::Label::new(RichText::new(text).size(11.0).color(t.text_dim)).wrap()); +} + +/// The AI page of Settings. +pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let r = ui.checkbox(&mut app.preferences.ai_features, RichText::new(tr("ui.ai_show_features")).strong()); + crate::scenario::publish_handle("prefs:ai_features", r.rect.center()); + note(ui, tr("ui.ai_show_features_note")); + if cfg!(target_arch = "wasm32") { + note(ui, tr("ui.agent_chat_desktop_only")); + return; + } + if !app.preferences.ai_features { + return; + } + ui.add_enabled_ui(true, |ui| { + let r = ui.checkbox(&mut app.preferences.ai_toolbar_button, tr("ui.ai_toolbar_button")); + crate::scenario::publish_handle("prefs:ai_toolbar_button", r.rect.center()); + }); + note(ui, tr("ui.ai_toolbar_button_note")); + #[cfg(not(target_arch = "wasm32"))] + { + if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { + refresh(app, &ui.ctx().clone()); + } + agents_section(app, ui); + } + section(ui, tr("ui.ai_permissions")); + for m in Mode::ALL { + let r = ui.radio_value(&mut app.preferences.ai_permissions, m, m.label()); + crate::scenario::publish_handle(&format!("prefs:ai_mode:{}", m.id()), r.rect.center()); + note( + ui, + tr(match m { + Mode::Ask => "ui.ai_mode_ask_note", + Mode::Edits => "ui.ai_mode_edits_note", + Mode::All => "ui.ai_mode_all_note", + }), + ); + } + if app.preferences.ai_permissions == Mode::All { + egui::Frame::NONE.stroke(Stroke::new(1.0, t.warning)).corner_radius(4.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(tr("ui.ai_mode_all_warning")).color(t.warning)).wrap()); + }); + } + note(ui, tr("ui.ai_modes_scope")); + // "Always allow" answers. + let remembered: Vec<(String, Vec)> = + app.preferences.agent_always_allow.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| (k.clone(), v.clone())).collect(); + ui.add_space(4.0); + ui.label(RichText::new(tr("ui.ai_always_allowed")).strong()); + if remembered.is_empty() { + note(ui, tr("ui.ai_always_allowed_none")); + } + let names: std::collections::HashMap = agents::entries(app).into_iter().map(|e| (e.id, e.name)).collect(); + for (agent, tools) in remembered { + ui.horizontal_wrapped(|ui| { + ui.label(names.get(&agent).cloned().unwrap_or_else(|| agent.clone())); + for tool in &tools { + ui.label(RichText::new(tool).monospace().size(11.0).color(t.text_dim)); + } + if ui.small_button(tr("ui.ai_reset")).clicked() { + app.preferences.agent_always_allow.remove(&agent); + } + }); + } + section(ui, tr("ui.ai_panel")); + egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| { + ui.label(tr("ui.agent_chat_side")); + ui.horizontal(|ui| { + let side = &mut app.preferences.agent_chat_side; + if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() { + *side = "left".into(); + } + if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() { + *side = "right".into(); + } + }); + ui.end_row(); + ui.label(tr("ui.agent_chat_turn_limit")); + ui.add(egui::DragValue::new(&mut app.preferences.agent_chat_turn_limit).range(1..=200)); + ui.end_row(); + }); + ui.add_space(6.0); + note(ui, tr("ui.agent_chat_privacy_note")); +} + +/// The agents: found and added, with Test, Default, a permission override and Remove. +#[cfg(not(target_arch = "wasm32"))] +fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + section(ui, tr("ui.ai_agents")); + for test in app.agent_chat.settings.tests.values_mut() { + test.poll(); + } + let all = agents::entries(app); + let d = &app.preferences.agent_default; + let default = if all.iter().any(|e| &e.id == d) { Some(d.clone()) } else { all.first().map(|e| e.id.clone()) }; + if app.agent_chat.detecting.is_some() { + ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim)); + } else if all.is_empty() { + note(ui, tr("ui.agent_chat_none_found")); + } + let mut remove: Option = None; + let mut test: Option = None; + for e in &all { + egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).corner_radius(5.0).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + super::panel::kind_icon(ui, e.kind); + ui.label(RichText::new(&e.name).strong()); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + super::panel::login_chip(ui, e.login); + if !e.found { + ui.label(RichText::new(tr("ui.ai_added")).color(t.text_dim).size(11.0)); + } + }); + ui.horizontal(|ui| { + let is_default = default.as_deref() == Some(e.id.as_str()); + if ui.radio(is_default, tr("ui.ai_default")).clicked() { + app.preferences.agent_default = e.id.clone(); + } + let b = ui.small_button(tr("ui.ai_test")); + crate::scenario::publish_handle(&format!("prefs:ai_test:{}", e.id), b.rect.center()); + if b.on_hover_text(tr(if e.kind == Kind::Acp { "ui.ai_test_acp_tip" } else { "ui.ai_test_api_tip" })).clicked() { + test = Some(e.clone()); + } + // Its own permission mode, or the global one. + let cur = app.preferences.agent_permissions.get(&e.id).copied(); + let shown = cur.map_or_else(|| tr("ui.ai_mode_global").to_string(), |m| m.label().to_string()); + let mut pick: Option> = None; + egui::ComboBox::from_id_salt(("sc_ai_mode", &e.id)).width(150.0).selected_text(shown).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), tr("ui.ai_mode_global")).clicked() { + pick = Some(None); + } + for m in Mode::ALL { + if ui.selectable_label(cur == Some(m), m.label()).clicked() { + pick = Some(Some(m)); + } + } + }); + match pick { + Some(Some(m)) => { + app.preferences.agent_permissions.insert(e.id.clone(), m); + } + Some(None) => { + app.preferences.agent_permissions.remove(&e.id); + } + None => {} + } + if !e.found && ui.small_button(tr("ui.ai_remove")).clicked() { + remove = Some(e.id.clone()); + } + }); + test_line(ui, &app.agent_chat.settings.tests.get(&e.id)); + }); + ui.add_space(3.0); + } + ui.horizontal(|ui| { + let b = ui.button(tr("ui.ai_add_agent")); + crate::scenario::publish_handle("prefs:ai_add", b.rect.center()); + if b.clicked() { + open_add(app); + } + if ui.add_enabled(app.agent_chat.detecting.is_none(), egui::Button::new(tr("ui.agent_chat_refresh"))).clicked() { + refresh(app, &ui.ctx().clone()); + } + }); + if let Some(e) = test { + let key = if e.key { app.services.secrets.as_ref().and_then(|s| s.get(&e.id).ok().flatten()) } else { None }; + let started = start_test(app, &e, key); + app.agent_chat.settings.tests.insert(e.id.clone(), started); + } + if let Some(id) = remove { + app.preferences.agents.retain(|a| a.id != id); + app.preferences.agent_permissions.remove(&id); + app.preferences.agent_always_allow.remove(&id); + if let Some(s) = &app.services.secrets { + let _ = s.delete(&id); + } + if app.agent_chat.agent_id.as_deref() == Some(id.as_str()) { + app.agent_chat.agent_id = None; + } + } + if app.agent_chat.settings.tests.values().any(|t| matches!(t, Test::Running(_))) { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); + } +} + +/// The Add agent dialog (drawn every frame while it is open). +pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { + if app.agent_chat.settings.add.is_none() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + let found: Vec = agents::entries(app).into_iter().filter(|e| e.found).collect(); + #[cfg(target_arch = "wasm32")] + let found: Vec = Vec::new(); + let has_keychain = app.services.secrets.is_some(); + let mut open = true; + let mut add = false; + let mut test = false; + let t = Tokens::get(); + egui::Window::new(tr("ui.ai_add_agent_title")) + .id(egui::Id::new("sc_ai_add")) + .collapsible(false) + .resizable(false) + .default_width(460.0) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .open(&mut open) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let Some(s) = app.agent_chat.settings.add.as_mut() else { return }; + if let Some(test) = s.test.as_mut() { + test.poll(); + } + ui.horizontal(|ui| { + for (k, label) in + [(AddKind::Installed, "ui.ai_add_installed"), (AddKind::Custom, "ui.ai_add_custom"), (AddKind::Api, "ui.ai_add_api")] + { + let r = ui.selectable_value(&mut s.kind, k, tr(label)); + crate::scenario::publish_handle(&format!("ai_add:{label}"), r.rect.center()); + if r.clicked() { + s.error = None; + s.test = None; + s.command.clear(); + s.name.clear(); + if k == AddKind::Api + && let Some((name, url, _)) = agents::API_PRESETS.get(s.preset) + { + s.name = name.to_string(); + s.base_url = url.to_string(); + } + } + } + }); + ui.separator(); + egui::Grid::new("sc_ai_add_grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| match s.kind { + AddKind::Installed => { + ui.label(tr("ui.agent_chat_pick")); + if found.is_empty() { + ui.label(RichText::new(tr("ui.agent_chat_none_found_short")).color(t.text_dim)); + } else { + let sel = found.get(s.installed).map(|e| e.name.clone()).unwrap_or_default(); + egui::ComboBox::from_id_salt("sc_ai_add_installed").width(260.0).selected_text(sel).show_ui(ui, |ui| { + for (i, e) in found.iter().enumerate() { + if ui.selectable_value(&mut s.installed, i, format!("{} {}", e.name, e.detail())).clicked() { + s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::>().join(" "); + s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix")); + } + } + }); + } + ui.end_row(); + if s.command.is_empty() + && let Some(e) = found.get(s.installed) + { + s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::>().join(" "); + s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix")); + } + ui.label(tr("ui.ai_name")); + ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.agent_chat_command")); + ui.add(egui::TextEdit::singleline(&mut s.command).desired_width(300.0)); + ui.end_row(); + ui.label(""); + ui.add(egui::Label::new(RichText::new(tr("ui.ai_add_installed_note")).size(11.0).color(t.text_dim)).wrap()); + ui.end_row(); + } + AddKind::Custom => { + ui.label(tr("ui.ai_name")); + ui.add(egui::TextEdit::singleline(&mut s.name).hint_text("My agent").desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.agent_chat_command")); + ui.add(egui::TextEdit::singleline(&mut s.command).hint_text("my-agent --acp").desired_width(300.0)); + ui.end_row(); + ui.label(""); + ui.add(egui::Label::new(RichText::new(tr("ui.agent_chat_custom_hint")).size(11.0).color(t.text_dim)).wrap()); + ui.end_row(); + } + AddKind::Api => { + ui.label(tr("ui.ai_preset")); + let presets: Vec = + agents::API_PRESETS.iter().map(|p| p.0.to_string()).chain(std::iter::once(tr("ui.ai_custom_url").to_string())).collect(); + let sel = presets.get(s.preset).cloned().unwrap_or_default(); + egui::ComboBox::from_id_salt("sc_ai_add_preset").width(200.0).selected_text(sel).show_ui(ui, |ui| { + for (i, p) in presets.iter().enumerate() { + if ui.selectable_value(&mut s.preset, i, p).clicked() { + if let Some((name, url, _)) = agents::API_PRESETS.get(i) { + s.base_url = url.to_string(); + s.name = name.to_string(); + } else { + s.base_url.clear(); + } + } + } + }); + ui.end_row(); + ui.label(tr("ui.ai_name")); + ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.ai_base_url")); + ui.add(egui::TextEdit::singleline(&mut s.base_url).hint_text("https://example.com/v1").desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.ai_model")); + ui.add(egui::TextEdit::singleline(&mut s.model).hint_text("qwen3:14b").desired_width(300.0)); + ui.end_row(); + ui.label(tr("ui.ai_key")); + ui.add_enabled( + has_keychain, + egui::TextEdit::singleline(&mut s.key).password(true).hint_text(tr("ui.ai_key_hint")).desired_width(300.0), + ); + ui.end_row(); + ui.label(""); + let note = if has_keychain { tr("ui.ai_key_note") } else { tr("ui.ai_no_keychain") }; + ui.add(egui::Label::new(RichText::new(note).size(11.0).color(t.text_dim)).wrap()); + ui.end_row(); + ui.label(""); + ui.horizontal(|ui| { + if ui.button(tr("ui.ai_test")).clicked() { + test = true; + } + test_line(ui, &s.test.as_ref()); + }); + ui.end_row(); + } + }); + if let Some(e) = &s.error { + ui.label(RichText::new(e).color(t.error)); + } + ui.separator(); + ui.horizontal(|ui| { + let b = ui.add(egui::Button::new(tr("ui.ai_add")).fill(t.accent_soft)); + crate::scenario::publish_handle("ai_add:add", b.rect.center()); + if b.clicked() { + add = true; + } + }); + }); + if test { + #[cfg(not(target_arch = "wasm32"))] + if let Some(s) = app.agent_chat.settings.add.as_ref() { + let url = agents::normalize_url(&s.base_url); + let e = agents::Entry { + id: String::new(), + name: s.name.clone(), + kind: Kind::Api, + argv: Vec::new(), + version: None, + login: "", + found: false, + base_url: url.clone().unwrap_or_default(), + model: s.model.trim().to_string(), + key: false, + }; + let key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()); + let result = match url { + Ok(_) => start_test(app, &e, key), + Err(err) => Test::Done(Err(err)), + }; + if let Some(s) = app.agent_chat.settings.add.as_mut() { + s.test = Some(result); + } + } + } + if add { + match add_agent(app) { + Ok(()) => open = false, + Err(e) => { + if let Some(s) = app.agent_chat.settings.add.as_mut() { + s.error = Some(e); + } + } + } + } + if !open { + app.agent_chat.settings.add = None; + } + if app.agent_chat.settings.add.as_ref().is_some_and(|s| matches!(s.test, Some(Test::Running(_)))) { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } +} + +/// Add what the dialog holds; its key goes to the keychain. +fn add_agent(app: &mut SolveApp) -> Result<(), String> { + let Some(s) = app.agent_chat.settings.add.as_ref() else { return Ok(()) }; + let name = s.name.trim().chars().take(80).collect::(); + let mut c = AgentConfig { name, ..Default::default() }; + let mut key: Option = None; + match s.kind { + AddKind::Installed | AddKind::Custom => { + let cmd = s.command.trim(); + if acp::split_command(cmd).is_empty() { + return Err(tr("ui.ai_need_command").to_string()); + } + c.kind = Kind::Acp; + c.command = cmd.chars().take(4096).collect(); + c.id = agents::new_id(&app.preferences, "custom"); + } + AddKind::Api => { + c.kind = Kind::Api; + c.base_url = agents::normalize_url(&s.base_url)?; + c.model = s.model.trim().to_string(); + if c.model.is_empty() { + return Err(tr("ui.ai_need_model").to_string()); + } + key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()); + if key.is_some() && !agents::key_safe_for(&c.base_url) { + return Err(tr("ui.ai_key_needs_https").to_string()); + } + c.id = agents::new_id(&app.preferences, "api"); + } + } + if c.name.is_empty() { + c.name = c.id.clone(); + } + if let Some(k) = key { + let Some(store) = &app.services.secrets else { return Err(tr("ui.ai_no_keychain").to_string()) }; + store.set(&c.id, &k).map_err(|e| trf("ui.ai_keychain_error", &[("error", &e)]))?; + c.key = true; + } + app.preferences.agents.push(c); + Ok(()) +} diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs new file mode 100644 index 00000000..a2208cc1 --- /dev/null +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -0,0 +1,305 @@ +use super::*; +use std::sync::mpsc::channel; + +fn chat() -> AgentChat { + let mut c = AgentChat::default(); + c.state = Some(State::Ready); + c +} + +#[test] +fn streamed_text_joins_and_tool_updates_change_their_call() { + let mut c = chat(); + for e in [ + Event::Text { thought: false, text: "Mak".into() }, + Event::Text { thought: false, text: "ing a box".into() }, + Event::Tool { id: "t".into(), title: Some("batch".into()), kind: Some("other".into()), status: Some(ToolStatus::Pending), output: None }, + Event::Tool { id: "t".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) }, + ] { + c.on_event(e); + } + assert_eq!(c.items.len(), 2); + assert!(matches!(&c.items[0], Item::Agent { text, .. } if text == "Making a box")); + assert!(matches!(&c.items[1], Item::Tool { title, status: Some(ToolStatus::Completed), output: Some(o), .. } if title == "batch" && o == "ok")); +} + +#[test] +fn a_sign_in_request_keeps_the_question_for_after_the_login() { + let mut c = chat(); + c.last = Some(("make a cube".into(), false)); + c.state = Some(State::Busy); + assert!(c.on_event(Event::TurnEnded { reason: "auth".into() }).is_some()); + c.on_event(Event::AuthRequired { methods: vec![] }); + assert_eq!(c.state(), State::NeedsAuth); + assert_eq!(c.queued, Some(("make a cube".into(), false))); +} + +#[test] +fn a_plan_update_replaces_the_turns_plan() { + let mut c = chat(); + c.push(Item::User { text: "go".into(), image: false }); + c.on_event(Event::Plan(vec![("a".into(), "pending".into())])); + c.on_event(Event::Text { thought: false, text: "working".into() }); + c.on_event(Event::Plan(vec![("a".into(), "completed".into())])); + let plans: Vec<_> = c.items.iter().filter(|i| matches!(i, Item::Plan(_))).collect(); + assert_eq!(plans.len(), 1); + assert!(matches!(plans[0], Item::Plan(p) if p[0].1 == "completed")); +} + +#[test] +fn a_permission_is_answered_once() { + let mut c = chat(); + let opts = vec![PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }]; + c.push(Item::Permission { key: 5, title: "batch".into(), options: opts, answer: None }); + assert_eq!(c.waiting(), 1); + c.answer(0, Some("allow")); + c.answer(0, None); + assert!(matches!(&c.items[0], Item::Permission { answer: Some(a), .. } if a == "Allow")); + assert_eq!(c.waiting(), 0); +} + +/// An app with the chat's endpoint wired to a channel the test feeds, and a message in progress. +fn app_in_a_turn() -> (SolveApp, Sender) { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + // The default mode: modelling runs, files ask. + assert_eq!(app.preferences.ai_permissions, agents::Mode::Edits); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + app.agent_chat.state = Some(State::Busy); + start_turn(&mut app, "two boxes".into(), false); + (app, tx) +} + +fn ask(tx: &Sender, method: &str, params: Value) -> Receiver { + let (req, rx) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + rx +} + +fn frame(app: &mut SolveApp) { + let ctx = egui::Context::default(); + poll(app, &ctx); + app.drain_control(&ctx); +} + +fn boxed(x: f64) -> Value { + json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}}) +} + +/// Everything the agent changed in one message undoes as one step, and the conversation offers +/// that undo while nothing else has happened since. +#[test] +fn a_messages_changes_undo_as_one_step() { + let (mut app, tx) = app_in_a_turn(); + let before = app.session.doc.clone(); + let depth = app.session.undo.len(); + let replies: Vec<_> = [0.0, 20.0, 40.0].into_iter().map(|x| ask(&tx, "engine.execute", boxed(x))).collect(); + frame(&mut app); + for r in replies { + assert_eq!(r.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + } + assert_eq!(app.session.undo.len(), depth + 3); + assert_eq!(app.agent_chat.turn.as_ref().map(|t| t.changes), Some(3)); + app.agent_chat.on_event(Event::TurnEnded { reason: String::new() }); + end_turn(&mut app); + assert_eq!(app.session.undo.len(), depth + 1, "one step"); + assert!(app.session.undo.last().is_some_and(|s| s.label.contains("two boxes"))); + let n = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 3, .. })).expect("the changes are listed"); + undo_changes(&mut app, n); + assert!(Arc::ptr_eq(&app.session.doc, &before), "back to the design before the message"); + assert!(matches!(app.agent_chat.items[n], Item::Changes { undone: true, .. })); +} + +/// Saving waits for Allow or Deny; Deny answers the agent without running it, Allow runs it. +#[test] +fn file_actions_wait_for_the_user() { + let (mut app, tx) = app_in_a_turn(); + let dir = std::env::temp_dir().join(format!("sc-agent-chat-approve-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.solidcraft"); + let save = json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]}); + let denied = ask(&tx, "engine.execute", save.clone()); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 1); + assert!(denied.try_recv().is_err(), "nothing answered before the user decides"); + let id = app.agent_chat.held[0].id; + approve(&mut app, id, false); + let r = denied.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + assert!(r["error"].as_str().is_some_and(|e| e.contains("declined")), "{r}"); + assert!(!path.exists()); + let allowed = ask(&tx, "engine.execute", save); + frame(&mut app); + let id = app.agent_chat.held[0].id; + approve(&mut app, id, true); + frame(&mut app); + let r = allowed.recv_timeout(std::time::Duration::from_secs(30)).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Crash reports and app methods never run for the agent, requests outside a message don't +/// either, and a message stops taking changes at the limit. +#[test] +fn the_gate_refuses_and_limits() { + let (mut app, tx) = app_in_a_turn(); + app.preferences.agent_chat_turn_limit = 2; + let crash = ask(&tx, "engine.execute", json!({"command": "diagnostics.crash_reports", "params": {}})); + let quit = ask(&tx, "app.quit", json!({"force": true})); + let ok: Vec<_> = (0..2).map(|i| ask(&tx, "engine.execute", boxed(f64::from(i) * 20.0))).collect(); + let over = ask(&tx, "engine.execute", boxed(80.0)); + let read = ask(&tx, "document.inspect", json!({})); + frame(&mut app); + let get = |r: Receiver| r.recv_timeout(std::time::Duration::from_secs(30)).unwrap(); + assert!(get(crash)["error"].as_str().is_some_and(|e| e.contains("crash reports"))); + assert!(get(quit)["error"].as_str().is_some_and(|e| e.contains("not available"))); + for r in ok { + assert_eq!(get(r)["ok"], true); + } + assert!(get(over)["error"].as_str().is_some_and(|e| e.contains("limit of 2"))); + assert_eq!(get(read)["ok"], true, "reads go on past the limit"); + end_turn(&mut app); + let late = ask(&tx, "engine.execute", boxed(100.0)); + frame(&mut app); + assert!(get(late)["error"].as_str().is_some_and(|e| e.contains("no message in progress"))); +} + +#[test] +fn merging_finds_the_turns_start_when_the_history_was_trimmed() { + // Distinct designs: what counts is which `Arc` a snapshot holds. + let doc = |_: usize| Arc::new(solidcraft_engine::doc::Document::default()); + let snap = |d: &Arc| solidcraft_engine::Snapshot { + label: String::new(), + doc: d.clone(), + active_sketch: None, + pending_moves: Default::default(), + }; + let docs: Vec<_> = (0..6).map(doc).collect(); + // The turn began at depth 3 with docs[3]; the history then lost its oldest step. + let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect(); + assert_eq!(merge_undo(&mut undo, &docs[3], 3, "Agent"), 3); + assert_eq!(undo.len(), 3); + assert!(Arc::ptr_eq(&undo[2].doc, &docs[3]) && undo[2].label == "Agent"); + // A start that is no longer in the history: nothing is merged. + let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect(); + assert_eq!(merge_undo(&mut undo, &doc(9), 3, "Agent"), 0); + assert_eq!(undo.len(), 5); +} + +/// People who don't want AI never see it: with "Show AI features" off there is no button, the +/// shortcut does nothing, no panel draws, nothing is looked for and nothing starts, even when +/// asked over the control channel. Turned on, the button is there; discovery waits for the panel. +#[test] +fn with_ai_features_off_nothing_of_the_chat_shows_or_runs() { + use crate::scenario::{handle_at, rect_of}; + let mut h = crate::scenario::Harness::new(); + let shortcut = json!({"key": "A", "shift": true, "cmd": true}); + h.app.preferences.ai_features = false; + h.frames(3); + assert!(handle_at("toolbar:ai").is_none(), "no toolbar button"); + h.call("ui.key", shortcut.clone()); + h.frames(3); + h.call("ui.agent_chat", json!({"open": true, "send": "hello", "connect": true})); + h.frames(3); + assert!(!h.app.agent_chat.open && rect_of("agent_chat:panel").is_none(), "no panel"); + assert!(h.app.agent_chat.detection.is_none() && h.app.agent_chat.detecting.is_none(), "nothing looked for"); + assert!(h.app.agent_chat.backend.is_none() && h.app.agent_chat.tools_port.is_none(), "nothing started"); + // The View settings menu has no chat entry either. + if let Some(view) = handle_at("nav:view") { + h.call("ui.click", json!({"x": view.x, "y": view.y})); + h.frames(3); + assert!(handle_at("view:agent_chat").is_none(), "no menu entry"); + h.call("ui.click", json!({"x": view.x, "y": view.y})); + } + // On (the default): the button shows; discovery waits until the panel opens. + h.app.preferences.ai_features = true; + h.frames(3); + assert!(handle_at("toolbar:ai").is_some(), "the toolbar button"); + assert!(h.app.agent_chat.detection.is_none() && h.app.agent_chat.detecting.is_none(), "nothing looked for yet"); + h.call("ui.key", shortcut.clone()); + h.frames(3); + assert!(h.app.agent_chat.open && rect_of("agent_chat:panel").is_some(), "Ctrl+Shift+A opens the panel"); + assert!(h.app.agent_chat.detection.is_some() || h.app.agent_chat.detecting.is_some(), "the panel looks for agents"); + assert!(h.app.agent_chat.backend.is_none(), "but starts none until a chat begins"); + // Without the toolbar button the shortcut still works. + h.app.preferences.ai_toolbar_button = false; + h.call("ui.key", shortcut); + h.frames(3); + assert!(handle_at("toolbar:ai").is_none() && !h.app.agent_chat.open); +} + +#[test] +fn ai_features_and_the_toolbar_button_are_on_by_default() { + let p = crate::prefs::Prefs::default(); + assert!(p.ai_features && p.ai_toolbar_button); +} + +/// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command +/// through from then on for this agent. "Allow everything": even a save runs without a question. +#[test] +fn permission_modes_decide_what_asks() { + let (mut app, tx) = app_in_a_turn(); + app.agent_chat.running_id = "claude".into(); + app.preferences.ai_permissions = agents::Mode::Ask; + let first = ask(&tx, "engine.execute", boxed(0.0)); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 1, "a modelling command asks"); + let id = app.agent_chat.held[0].id; + approve_always(&mut app, id); + frame(&mut app); + assert_eq!(first.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + assert!(agents::always_allowed(&app.preferences, "claude", "solid.box")); + let second = ask(&tx, "engine.execute", boxed(20.0)); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 0, "remembered"); + assert_eq!(second.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + // Another agent keeps asking. + app.agent_chat.running_id = "codex".into(); + let _third = ask(&tx, "engine.execute", boxed(40.0)); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 1); + decline_all(&mut app, "test"); + // Allow everything, set for this agent only. + app.preferences.agent_permissions.insert("codex".into(), agents::Mode::All); + let dir = std::env::temp_dir().join(format!("sc-agent-chat-all-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("b.solidcraft"); + let save = ask( + &tx, + "engine.execute", + json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]}), + ); + frame(&mut app); + frame(&mut app); + assert_eq!(app.agent_chat.waiting(), 0); + assert_eq!(save.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + // Crash reports stay off limits whatever the mode. + let crash = ask(&tx, "engine.execute", json!({"command": "diagnostics.crash_reports"})); + frame(&mut app); + assert_eq!(crash.recv_timeout(std::time::Duration::from_secs(5)).unwrap()["ok"], false); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The agent's own questions: SolidCraft's tools are allowed (its gate checks them), an earlier +/// "Always allow" answers by itself, and both leave a note; anything else is asked. +#[test] +fn the_agents_own_questions_are_answered_by_the_mode_or_asked() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.agent_chat.running_id = "claude".into(); + let opts = || { + vec![ + PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }, + PermissionOption { id: "always".into(), name: "Always".into(), kind: "allow_always".into() }, + PermissionOption { id: "no".into(), name: "Reject".into(), kind: "reject_once".into() }, + ] + }; + on_permission(&mut app, 1, "mcp__solidcraft__batch".into(), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { text, .. }) if text.contains("mcp__solidcraft__batch"))); + on_permission(&mut app, 2, "Run ls".into(), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Permission { answer: None, .. }))); + let n = app.agent_chat.items.len() - 1; + answer_permission(&mut app, n, Some("always")); + assert!(agents::always_allowed(&app.preferences, "claude", "Run ls")); + on_permission(&mut app, 3, "Run ls".into(), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered"); +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 6c396c19..e2e69568 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -859,6 +859,11 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { app.session.visibility.hidden_origin.retain(|h| h != k); } } + if let Some(p) = r.secondary + && icon == "plane" + { + crate::context_menu::open_for(app, p, Target::OriginPlane { name: k.into() }); + } if r.clicked && let Some(x) = sel { @@ -1155,7 +1160,7 @@ fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) { let r3 = ui.add(crate::prefs::length_drag(&mut w, &app.session.doc.units, app.preferences.length_decimals).range(0.01..=1e6)); ui.end_row(); ui.label(crate::i18n::tr_label("Angle")); - let r4 = ui.add(egui::DragValue::new(&mut ang).speed(1.0).suffix(" deg")); + let r4 = ui.add(egui::DragValue::new(&mut ang).speed(1.0).suffix(" deg").custom_parser(crate::prefs::parse_number)); ui.end_row(); ui.label(crate::i18n::tr_label("Opacity")); let r5 = ui.add(egui::Slider::new(&mut op, 0.0..=1.0)); @@ -1721,7 +1726,7 @@ fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) { for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() { ui.label(*axis); if let Some(v) = m.angles.get_mut(k) { - let response = ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg")); + let response = ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg").custom_parser(crate::prefs::parse_number)); crate::scenario::publish_handle(&format!("occurrence-move:angle:{k}"), response.rect.center()); } ui.end_row(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2cc38d3f..22641c89 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -249,8 +249,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj app.dialog = Some(d); return; } - if let Kind::Measure { result: Some(r), .. } = &d.kind { - measure_line(app, painter, proj, r); + if let Kind::Measure { result: Some(r), show, .. } = &d.kind { + measure_line(app, painter, proj, r, *show); app.dialog = Some(d); return; } @@ -527,17 +527,12 @@ fn drag_arrow( ) -> Option { let t = Tokens::get(); let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); - let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l }; - let ts = proj.to_screen(base + n * shown)?; - crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts); + let tip = |length: f64| { + let shown = if length.abs() < min { min * if length < 0.0 { -1.0 } else { 1.0 } } else { length }; + proj.to_screen(base + n * shown) + }; + let mut ts = tip(l)?; let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag()); - let hot = r.hovered() || r.dragged(); - let col = if hot { t.accent } else { t.manipulator }; - arrow(painter, bs, ts, col); - if hot { - painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col)); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } if r.dragged() && let Some(p) = r.interact_pointer_pos() && let Some(v) = along(proj, p, base, n) @@ -549,6 +544,17 @@ fn drag_arrow( v = v.max(if free { 1e-3 } else { step }); } *value = format_len(v); + ts = tip(v)?; + } + // Paint the value just read from this frame's pointer, including the value box anchor. + // Using the old distance here made both trail the drag by another frame. + crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts); + let hot = r.hovered() || r.dragged(); + let col = if hot { t.accent } else { t.manipulator }; + arrow(painter, bs, ts, col); + if hot { + painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col)); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } Some(ts) } @@ -758,14 +764,11 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain true } -/// The measured distance: a line between the closest points with its length. -fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { +/// The measured distance: a line between the ends of the shown measure (the centres for +/// centre to centre, the closest or farthest points) with its length. +fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value, show: Option) { let t = Tokens::get(); - let p = |k: &str| -> Option { - let a = r.get(k)?.as_array()?; - Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?)) - }; - let (Some(a), Some(b), Some(d)) = (p("from"), p("to"), r.get("distance_mm").and_then(|v| v.as_f64())) else { return }; + let Some((_, d, a, b)) = crate::dialogs::shown_distance(r, show) else { return }; let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { return }; painter.line_segment([sa, sb], Stroke::new(2.0, t.manipulator)); for s in [sa, sb] { diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs index 57915bee..e4fbc6df 100644 --- a/crates/ui-egui/src/colour_tokens_tests.rs +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -177,7 +177,6 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[ ("sketch_text.rs", 1, "white text on the selection chip"), ("titlebar.rs", 4, "the window close button's red hover (the system's convention), white hovers and ink"), ("toolbar.rs", 3, "white hover overlays and the version text"), - ("update.rs", 1, "text laid out only to measure its width, never drawn"), ]; const COLOUR_LITERALS: &[&str] = &[ diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 00eece9a..fa5f5c54 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -37,6 +37,10 @@ pub enum Target { Plane { id: u64, }, + /// An origin plane (XY, XZ or YZ). + OriginPlane { + name: String, + }, Component { id: u64, }, @@ -173,6 +177,8 @@ pub struct MenuState { pub props: Option<(String, Value)>, /// The viewport menu opened over something: it offers Select Other. pub over_geometry: bool, + /// The plane under the cursor when the viewport menu opened: it offers a sketch on it. + pub plane: Option, } #[derive(Clone, Debug, PartialEq)] @@ -259,6 +265,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Group { id } => crate::browser::group_bodies(app, *id), Target::Sketch { .. } | Target::Plane { .. } + | Target::OriginPlane { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin @@ -317,6 +324,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => body_items(app, std::slice::from_ref(name), true), Target::Sketch { id } => sketch_items(app, *id), Target::Plane { id } => plane_items(app, *id), + Target::OriginPlane { name } => origin_plane_items(app, name), Target::Component { id } => component_items(app, *id), Target::Occurrence { path } => occurrence_items(app, path), Target::Group { id } => crate::browser::group_items(app, *id), @@ -448,7 +456,11 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "solid.revolve", "Revolve")); } else { - // Empty space. + // Empty space, or a plane, which leads with a sketch on it. + if let Some(plane) = &app.menu.plane { + v.push(cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": plane }))); + v.push(Item::sep()); + } v.push(act("ui.nav", "Pan", "pan").with(json!({ "mode": "pan" }))); v.push(act("ui.nav", "Zoom", "zoom").with(json!({ "mode": "zoom" }))); v.push(act("ui.nav", "Orbit", "orbit").with(json!({ "mode": "orbit" }))); @@ -539,11 +551,13 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { ] } -/// A construction plane's row in the browser: edit, delete, rename, show or hide. +/// A construction plane's row in the browser: sketch on it, edit, delete, rename, show or hide. fn plane_items(app: &SolveApp, id: u64) -> Vec { let Some(f) = app.session.doc.feature(id) else { return Vec::new() }; let visible = !app.session.visibility.hidden_origin.contains(&f.name); vec![ + cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": f.name })), + Item::sep(), act("ui.editFeature", "Edit Feature", "plane").with(json!({ "feature": id })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), @@ -555,6 +569,16 @@ fn plane_items(app: &SolveApp, id: u64) -> Vec { ] } +/// An origin plane's row in the browser: sketch on it, show or hide. +fn origin_plane_items(app: &SolveApp, name: &str) -> Vec { + let visible = !app.session.visibility.hidden_origin.iter().any(|h| h == name); + vec![ + cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": name })), + Item::sep(), + act("ui.originPart", "Show/Hide", "eye").key("V").with(json!({ "keys": [name], "show": !visible })), + ] +} + /// An assembly joint: edit, drive, suppress, delete. fn joint_items(app: &SolveApp, id: u64) -> Vec { let Some(j) = app.session.doc.assembly.joints.iter().find(|j| j.id == id) else { return Vec::new() }; @@ -600,7 +624,7 @@ fn occurrence_items(app: &SolveApp, path: &[u64]) -> Vec { } "ui.moveOccurrence" => { item.params = Some(json!({"occurrence": placement.occurrence, "path": path})); - item.enabled = !placement.grounded; + item.enabled = !placement.grounded && app.session.held_by_rigid_group(placement.occurrence).is_none(); } "ui.properties" => item.params = Some(json!({"occurrence": placement.occurrence, "path": path})), "occurrence.copy" => item.params = Some(json!({"occurrence": placement.occurrence, "translate": [10,10,0]})), @@ -642,9 +666,18 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { let oid = occ.map(|o| o.id).unwrap_or(0); v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active)); v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded }))); - v.push(act("ui.moveOccurrence", "Move/Copy", "move").key("M").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some())); + v.push( + act("ui.moveOccurrence", "Move/Copy", "move") + .key("M") + .with(json!({ "occurrence": oid })) + .on(!grounded && occ.is_some() && app.session.held_by_rigid_group(oid).is_none()), + ); v.push(cmd(app, "component.capture_position", "Capture Position").on(!app.session.pending_moves.is_empty())); v.push(cmd(app, "component.revert_position", "Revert Position").on(!app.session.pending_moves.is_empty())); + // An imported assembly moves as one until its rigid group is released. + if !app.session.contents_joints(id).is_empty() { + v.push(cmd(app, "component.unlock", "Unlock Contents").with(json!({ "component": id }))); + } v.push(Item::sep()); } else { v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active)); @@ -723,6 +756,8 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle")); v.push(cmd(app, "sketch.circle.center", "Circle")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension")); + v.push(Item::sep()); + v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); return v; } let ents: Vec = curves.iter().chain(points.iter()).cloned().collect(); @@ -737,6 +772,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { v.push(cmd(app, "sketch.edit_text", "Edit Text")); } v.push(cmd(app, "sketch.move", "Move/Copy").key("M")); + v.push(act("ui.cut", "Cut", "").key("Ctrl+X")); + v.push(act("ui.copy", "Copy", "").key("Ctrl+C")); + v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } @@ -819,6 +857,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { "ui.delete" if item.params.is_some() => crate::delete::request(app, p), "ui.delete" => delete(app), "ui.finishSketch" => app.finish_sketch(), + "ui.copy" => drop(crate::edit_clipboard::copy(app)), + "ui.cut" => drop(crate::edit_clipboard::cut(app)), + "ui.paste" => drop(crate::edit_clipboard::paste(app, Some(at))), "ui.editSketchPattern" => { if let Some(id) = p.get("pattern").and_then(Value::as_str) { crate::sketch_modify_ui::edit_pattern(app, id); @@ -1110,6 +1151,13 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } } "ui.newGroup" | "ui.ungroup" | "ui.groupSelected" => crate::browser::group_action(app, &item.id, &p), + // A plane's Create Sketch is the toolbar's with that plane picked first: it replaces a + // dialog in progress, and the dialog starts the sketch at once, facing the plane. + "sketch.create" if item.params.is_some() => { + if app.run("select.set", json!({ "items": [{"type": "plane", "name": p.get("plane")}] })).is_ok() { + app.start("sketch.create"); + } + } id if item.params.is_some() => { let _ = app.run(id, p); } @@ -1271,6 +1319,11 @@ pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) { /// selects it first, so the menu is about what is under the cursor. pub fn open(app: &mut SolveApp, at: Pos2) { app.menu.over_geometry = app.viewport.hover.is_some(); + // A right-click leaves a plane unselected, but the menu offers a sketch on it. + app.menu.plane = app.viewport.hover.clone().and_then(|h| crate::viewport::candidate(app, &[h])).and_then(|(_, sel)| match sel { + Sel::Plane { name } => Some(name), + _ => None, + }); crate::select_other::close(app); if app.session.active_sketch.is_none() && let Some(h) = app.viewport.hover.clone() @@ -1352,9 +1405,9 @@ fn radial_center(at: Pos2, screen: Rect) -> Pos2 { pos2(x, y) } -/// Height of a list of items as [`list_row`] draws them. +/// Height of a list of items as [`list_row`] draws them in [`menu_frame`] (margins and border). fn list_height(items: &[Item]) -> f32 { - 8.0 + items + 10.0 + items .iter() .map(|i| { if i.is_sep() { @@ -1386,6 +1439,23 @@ fn list_place(c: Pos2, h: f32, screen: Rect) -> Pos2 { /// Width of list menus. pub const LIST_W: f32 = 250.0; +/// Where a menu of `size` opened at `at` goes: right of and below the point (`gap` away) when it +/// fits in `screen`, flipped to the left or above it when it would run past that edge, and +/// against the far edge when it fits on neither side. A flipped menu ends at the cursor, so it +/// stays in view even when the window reaches past the screen (#1354). +pub fn place(at: Pos2, gap: Vec2, size: Vec2, screen: Rect) -> Pos2 { + let axis = |at: f32, gap: f32, len: f32, lo: f32, hi: f32| { + if at + gap + len <= hi { + at + gap + } else if at - gap - len >= lo { + at - gap - len + } else { + (hi - len).max(lo) + } + }; + pos2(axis(at.x, gap.x, size.x, screen.left(), screen.right()), axis(at.y, gap.y, size.y, screen.top(), screen.bottom())) +} + /// The radial slot a pointer offset points at (none near the centre). fn sector(d: Vec2) -> Option { if d.length() < 22.0 { @@ -1534,7 +1604,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { inside |= resp.response.contains_pointer(); list_place(c, list_height(&list), screen) } else { - at + place(at, Vec2::ZERO, vec2(LIST_W, list_height(&list)), screen) }; // Up/Down choose a row of the list, Enter runs it. @@ -1845,6 +1915,18 @@ mod tests { } } + #[test] + fn list_menus_flip_to_stay_in_the_window() { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0)); + let size = vec2(LIST_W, 300.0); + assert_eq!(place(pos2(100.0, 100.0), Vec2::ZERO, size, screen), pos2(100.0, 100.0), "room right and below"); + assert_eq!(place(pos2(1500.0, 900.0), Vec2::ZERO, size, screen), pos2(1500.0 - LIST_W, 600.0), "ends at the cursor"); + assert_eq!(place(pos2(1500.0, 900.0), vec2(12.0, 12.0), size, screen), pos2(1488.0 - LIST_W, 588.0), "the gap both ways"); + let tall = vec2(LIST_W, 700.0); + assert_eq!(place(pos2(100.0, 500.0), Vec2::ZERO, tall, screen).y, 300.0, "room on neither side: against the bottom"); + assert_eq!(place(pos2(100.0, 500.0), Vec2::ZERO, vec2(LIST_W, 1200.0), screen).y, 0.0, "taller than the window: from the top"); + } + #[test] fn radial_menu_stays_on_screen() { let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fbbb2359..bf3ced9a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -172,6 +172,21 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result Option> { + let guides = + solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }; + let scene = solidcraft_engine::view::scene_with_palette_and_guides( + &app.session, + &app.cam, + crate::viewport::section_plane(app), + solidcraft_engine::view::world_curve_parts, + &crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision), + guides, + ); + render_png(&scene, &app.cam, w, h) +} + pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let scope = match request_scope(app, &req.params) { Ok(Some(scope)) => scope, @@ -245,6 +260,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> "file_picker_busy": app.file_picker_busy(), "file_dialog_open": app.file_picker_busy(), "build_ms": app.viewport.build_ms, + "display_lod": app.viewport.lod.lock().map(|l| serde_json::to_value(&l.stats).unwrap_or_default()).unwrap_or_default(), "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, "home": app.home.open, "recent_thumbnails_ready": app.home.ready_recents(), @@ -640,6 +656,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})), }, "ui.prefs" => wrap(crate::prefs::control(app, p)), + "ui.agent_chat" => wrap(crate::agent_chat::control(app, ctx, p)), "ui.help" => match s("item") { Some(i @ ("about" | "shortcuts" | "report" | "crashes")) => { crate::help::run(app, ctx, i); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ef774b66..47305032 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -193,6 +193,9 @@ pub enum Kind { Measure { result: Option, of: Vec, + /// The measure chosen in the panel (centre, min or max); `None` follows the result's + /// own default (centre where a round item gives one). + show: Option, }, /// Move bodies by a distance along X, Y and Z. /// Move/Copy (see `dialogs_move`): the move type, X/Y/Z distances (Point to Position: the @@ -528,9 +531,10 @@ impl Dialog { "inspect.section" => { Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } - "inspect.measure" => { - Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)]) - } + "inspect.measure" => Dialog::new( + Kind::Measure { result: None, of: Vec::new(), show: None }, + vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES | selection::CURVES | PROFILES, true)], + ), "solid.pattern.rectangular" => Dialog::new( Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() }, vec![ @@ -1634,7 +1638,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) { .stroke(stroke) .min_size(vec2(0.0, 22.0)) .sense(egui::Sense::CLICK); - let chip = ui.add(b); + let chip = crate::frame::add_outlined(ui, b, stroke.width); publish_row(&chip); let tooltip = if custom_axis { if let Kind::Revolve { axis: Some(axis), .. } = &d.kind { @@ -1745,7 +1749,7 @@ pub(crate) fn fill_cells(st: &solidcraft_engine::doc::ModelState, tools: &[Strin /// Measure again when the picked items changed. fn update_measure(app: &SolveApp, d: &mut Dialog) { let items: Vec = sels(d, 0).to_vec(); - if let Kind::Measure { result, of } = &mut d.kind + if let Kind::Measure { result, of, .. } = &mut d.kind && *of != items { *result = if items.is_empty() { None } else { Some(app.session.measure_items(&items)) }; @@ -1757,8 +1761,39 @@ fn fmt_mm(app: &SolveApp, v: &Value) -> String { v.as_f64().map(|x| crate::prefs::show_mm(app, x, 1)).unwrap_or_default() } +/// Which distance of a pair the Measure panel shows (and the canvas draws). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shown { + /// Centre to centre, or from a round item's axis. + Centre, + /// Closest points. + Min, + /// Farthest points. + Max, +} + +fn vec3_of(v: &Value) -> Option { + let a = v.as_array()?; + Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?)) +} + +/// The distance shown for a measured pair: which one, its value and its two ends. The chosen +/// one when the result has it, otherwise the result's default (centre, else the closest). +pub(crate) fn shown_distance(r: &Value, show: Option) -> Option<(Shown, f64, Vec3, Vec3)> { + let get = |which: Shown| -> Option<(Shown, f64, Vec3, Vec3)> { + let (d, from, to) = match which { + Shown::Centre => ("center_distance", "center_from", "center_to"), + Shown::Min => ("distance_mm", "from", "to"), + Shown::Max => ("max", "max_from", "max_to"), + }; + Some((which, r.get(d)?.as_f64()?, vec3_of(r.get(from)?)?, vec3_of(r.get(to)?)?)) + }; + let default = if r.get("measure").and_then(Value::as_str) == Some("center") { Shown::Centre } else { Shown::Min }; + show.and_then(get).or_else(|| get(default)).or_else(|| get(Shown::Min)) +} + /// The measurement in the dialog. -fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>) { +fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut Option) { let t = Tokens::get(); let Some(r) = r else { ui.label(""); @@ -1772,33 +1807,74 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>) { ui.end_row(); return; } + let mm = |v: &Value| fmt_mm(app, v); for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() { - let what = match it["type"].as_str() { - Some("edge") => format!("Length {}", fmt_mm(app, &it["length_mm"])), - Some("face") => format!("Area {}", crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2)), - Some("vertex") => { - let p = &it["position"]; - format!("At {}, {}, {}", fmt_mm(app, &p[0]), fmt_mm(app, &p[1]), fmt_mm(app, &p[2])) + // Round items: Ø first, then R, then the length (circumference, or the arc's length + // and angle). + let mut lines: Vec = Vec::new(); + if it.get("diameter").is_some() { + lines.push(format!("Ø {}", mm(&it["diameter"]))); + lines.push(format!("R {}", mm(&it["radius"]))); + if it.get("circumference").is_some() { + lines.push(format!("{} {}", tr("Circumference"), mm(&it["circumference"]))); + } else if let Some(a) = it["angle_deg"].as_f64() { + lines.push(format!("{} {}", tr("Arc length"), mm(&it["arc_length"]))); + lines.push(format!("{} {a:.2}°", crate::i18n::tr_label("Angle"))); } - _ => String::new(), - }; - ui.label(format!("Item {}", i + 1)); - ui.label(what); - ui.end_row(); - } - if r.get("distance_mm").is_some() { - ui.label(RichText::new(tr("Distance")).strong()); - ui.label(RichText::new(fmt_mm(app, &r["distance_mm"])).strong()); - ui.end_row(); - let d = &r["delta"]; - for (k, l) in ["ΔX", "ΔY", "ΔZ"].iter().enumerate() { - ui.label(*l); - ui.label(crate::prefs::show_mm(app, d[k].as_f64().unwrap_or(0.0).abs(), 1)); + } + if let Some(sl) = it.get("slot") { + lines.push(format!("{} {}", tr("Slot width"), mm(&sl["width"]))); + lines.push(format!("{} {}", tr("Centre to centre"), mm(&sl["center_distance"]))); + lines.push(format!("{} {}", tr("Overall length"), mm(&sl["overall_length"]))); + } + let round = it.get("diameter").is_some(); + match it["type"].as_str() { + Some("edge" | "sketch_curve") if !round => lines.push(format!("{} {}", tr("Length"), mm(&it["length_mm"]))), + Some("face" | "profile") => { + lines.push(format!("{} {}", tr("Area"), crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2))) + } + Some("vertex" | "sketch_point") => { + let p = &it["position"]; + lines.push(format!("At {}, {}, {}", mm(&p[0]), mm(&p[1]), mm(&p[2]))); + } + _ => {} + } + for (k, line) in lines.into_iter().enumerate() { + ui.label(if k == 0 { format!("Item {}", i + 1) } else { String::new() }); + ui.label(line); ui.end_row(); } } - if let Some(a) = r["angle_deg"].as_f64() { - ui.label(RichText::new(tr("Angle")).strong()); + if let Some((now, d, a, b)) = shown_distance(r, *show) { + if r.get("center_distance").is_some() { + // Centre, min and max all listed; the chosen one is drawn and broken into ΔX/Y/Z. + for (which, label, key) in + [(Shown::Centre, "Centre to centre", "center_distance"), (Shown::Min, "Min", "distance_mm"), (Shown::Max, "Max", "max")] + { + let Some(v) = r.get(key).filter(|v| v.is_number()) else { continue }; + if ui.radio(now == which, tr(label)).clicked() { + *show = Some(which); + } + let text = RichText::new(mm(v)); + ui.label(if now == which { text.strong() } else { text }); + ui.end_row(); + } + } else { + ui.label(RichText::new(crate::i18n::tr_label("Distance")).strong()); + ui.label(RichText::new(crate::prefs::show_mm(app, d, 1)).strong()); + ui.end_row(); + } + let delta = b - a; + for (l, v) in [("ΔX", delta.x), ("ΔY", delta.y), ("ΔZ", delta.z)] { + ui.label(l); + ui.label(crate::prefs::show_mm(app, v.abs(), 1)); + ui.end_row(); + } + } + if let Some(a) = r["angle_deg"].as_f64() + && r["items"].as_array().is_some_and(|v| v.len() == 2) + { + ui.label(RichText::new(crate::i18n::tr_label("Angle")).strong()); ui.label(RichText::new(format!("{a:.3}°")).strong()); ui.end_row(); } @@ -1859,7 +1935,10 @@ fn picked_side(app: &SolveApp, d: &Dialog, first: &Sel) -> Option { /// Sketch Palette keeps the right there, and the dialog takes the left so both stay on screen. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.viewport.dialog_rect = None; - let Some(mut d) = app.dialog.take() else { return }; + let Some(mut d) = app.dialog.take() else { + crate::frame::reset_dock_offset(ctx); + return; + }; // Edit Text lasts as long as its text: other design changes leave it open. if let Kind::Text(k) = &mut d.kind && !crate::sketch_text::refresh(app, k) @@ -1903,7 +1982,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } else { crate::frame::Side::Right }; - let anchor = egui::pos2(side.anchor_x(vp), top); + // Dragged by its title band, the dialog keeps that offset from its docked place while it + // is open (#1335), kept inside the viewport. + let moved = crate::frame::dock_offset(ctx, title(&d.kind)); + let docked = egui::pos2(side.anchor_x(vp), top); + let anchor = crate::frame::offset_anchor(docked, moved, vp, side); let mut keep = true; let mut ok = false; let mut applied = false; @@ -1944,7 +2027,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin); // Rows that end at the right edge line up with last frame's content width. let total = crate::frame::content_width(ui, wkey, min_w); - // Header: "−" folds the dialog, the command name, "»" at the right, on the title band. + // Header: "−" folds the dialog, the command name, "»" at the right, on the title band, + // which also moves the dialog. + let band_top = ui.cursor().min; + let band = egui::Rect::from_min_size(band_top, vec2(total, ui.spacing().interact_size.y + 2.0)); + crate::frame::drag_dock(ui, band, title(&d.kind), moved, anchor - docked); crate::frame::heading(ui, crate::frame::Level::Title, total, |ui| { let total = total - 2.0 * crate::frame::HEADING_MARGIN; let fold = if d.collapsed { "+" } else { "−" }; @@ -1955,7 +2042,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { { d.collapsed = !d.collapsed; } - ui.label(RichText::new(heading.as_str()).strong().size(12.5)); + // Not selectable: a drag on the title moves the dialog. + ui.add(egui::Label::new(RichText::new(heading.as_str()).strong().size(12.5)).selectable(false)); let chevron_width = ui.spacing().interact_size.x; crate::frame::right_aligned(ui, total, chevron_width, |ui| { let chevron = if d.collapsed { "«" } else { "»" }; @@ -2298,8 +2386,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { for (k, (t, r)) in mids.iter_mut().enumerate() { row_label(ui, "Mid Radius"); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(t).speed(0.01).range(0.01..=0.99).fixed_decimals(2)) - .on_hover_text(tr("Position along the edge, 0 at the start end, 1 at the other")); + ui.add( + egui::DragValue::new(t) + .speed(0.01) + .range(0.01..=0.99) + .fixed_decimals(2) + .custom_parser(crate::prefs::parse_number), + ) + .on_hover_text(tr("Position along the edge, 0 at the start end, 1 at the other")); enter |= field(ui, r); if ui.small_button("×").on_hover_text(tr("Remove this point")).clicked() { remove = Some(k); @@ -2373,7 +2467,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { d.extra.insert("combine".into(), json!(op == 1)); ui.end_row(); } - Kind::Measure { result, .. } => measure_rows(app, ui, result.as_ref()), + Kind::Measure { result, show, .. } => measure_rows(app, ui, result.as_ref(), show), Kind::OffsetPlane { offset } => { row_label(ui, "Distance"); enter |= field(ui, offset); diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index b07423cd..765e3329 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -390,7 +390,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel ("appearance.dielectric_ior", "appearance:ior", &mut ior, 1.0..=3.0), ] { row_label(ui, app.tr(label)); - let response = ui.add(egui::DragValue::new(value).range(range).speed(0.01)); + let response = ui.add(egui::DragValue::new(value).range(range).speed(0.01).custom_parser(crate::prefs::parse_number)); crate::scenario::publish_handle(handle, response.rect.center()); if response.changed() { k.remove_override = false; diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 5afce03d..5c6a7e15 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -878,8 +878,10 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result { let js = &s.doc.assembly.joints; let (Some(ja), Some(jb)) = (js.get(*a), js.get(*b)) else { return Err("needs two joints".into()) }; - let num = |e: &str| eval(e, ValueKind::Unitless); - cmd("joint.motion_link", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": num(ratio)?, "offset": num(offset)?})) + // The offset as typed: the engine reads it in Joint 2's units (deg or mm), and the + // ratio in Joint 2's units per Joint 1's. + let ratio = eval(ratio, ValueKind::Unitless)?; + cmd("joint.motion_link", json!({"a": ja.id, "b": jb.id, "ia": ia, "ib": ib, "ratio": ratio, "offset": offset.trim()})) } // The check runs from its Compute button; OK only closes. Asm::Interference { .. } => Vec::new(), diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 86f8e04d..07c1f0aa 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -808,7 +808,7 @@ fn bend_colors() -> (Color32, Color32) { /// The flat pattern view: the outline, cut-outs and bend lines (up and down in their colours) /// drawn flat over the viewport, fitted to it. True when the dialog is a flat pattern. -pub fn overlay(app: &SolveApp, painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { +pub fn overlay(app: &mut SolveApp, painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { let Kind::Sheet(Sm::FlatPattern { data, .. }) = &d.kind else { return false }; let t = Tokens::get(); let (up_col, down_col) = bend_colors(); @@ -842,9 +842,13 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, rect: Rect, d: &Dialog) if area.width() <= 0.0 || area.height() <= 0.0 { return true; } - let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1)); - let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale; - let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale; + if !app.flat_view.fitted { + app.flat_view.fit(rect, area, lo, hi); + } + let cam = app.flat_view.cam; + let scale = f64::from(rect.height()) / (2.0 * cam.half_height()); + let ox = f64::from(rect.center().x) - cam.target.x * scale; + let oy = f64::from(rect.center().y) + cam.target.y * scale; let to = |p: (f64, f64)| -> Pos2 { pos2((ox + p.0 * scale) as f32, (oy - p.1 * scale) as f32) }; for l in &loops { let pts: Vec = l.iter().map(|p| to(*p)).collect(); @@ -914,6 +918,56 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, rect: Rect, d: &Dialog) true } +/// The flat pattern's own 2D view: a top-down orthographic camera over the flat plane (x right, +/// y up, millimetres), so the shared navigation moves it like any other camera. The Design +/// camera is left alone while this has the viewport. +pub struct FlatView { + pub cam: solidcraft_engine::render::Camera, + /// The camera frames the pattern; cleared by Fit and Home, and whenever the dialog is closed. + pub fitted: bool, +} + +impl Default for FlatView { + fn default() -> Self { + let mut cam = solidcraft_engine::render::Camera::default(); + cam.set_view(solidcraft_engine::render::StandardView::Top); + cam.fov = 0.0; + cam.up = solidcraft_engine::render::UpAxis::Z; + FlatView { cam, fitted: false } + } +} + +impl FlatView { + /// Frame the pattern's bounds `lo..hi` in `area`, the part of `rect` the command panel leaves. + fn fit(&mut self, rect: Rect, area: Rect, lo: (f64, f64), hi: (f64, f64)) { + let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1)); + self.cam.target.x = (lo.0 + hi.0) * 0.5 - f64::from(area.center().x - rect.center().x) / scale; + self.cam.target.y = (lo.1 + hi.1) * 0.5 + f64::from(area.center().y - rect.center().y) / scale; + self.cam.target.z = 0.0; + self.cam.distance = f64::from(rect.height()) / (2.0 * scale) / 0.4; + self.fitted = true; + } +} + +/// The Create Flat Pattern dialog is open, so the viewport shows its 2D view. +pub(crate) fn flat_active(app: &SolveApp) -> bool { + matches!(app.dialog.as_ref().map(|d| &d.kind), Some(Kind::Sheet(Sm::FlatPattern { .. }))) +} + +/// Camera input for the flat pattern: the shared navigation without orbit, driving the flat view +/// instead of the Design camera. The result reports nothing under the pointer, so the model +/// beneath neither hovers, selects nor navigates. +pub(crate) fn navigate(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &egui::Response) -> crate::navigation::CameraInput { + use crate::navigation::{NavCaps, handle}; + let design = std::mem::replace(&mut app.cam, app.flat_view.cam); + let mut input = handle(app, ui, rect, resp, NavCaps { orbit: false, pick: false }); + app.flat_view.cam = std::mem::replace(&mut app.cam, design); + app.viewport.mouse = None; + input.inside = false; + input.hover = None; + input +} + fn pocket_label(app: &SolveApp, pocket: &Value) -> Option { let depth = pocket["depth_mm"].as_f64()?; if !depth.is_finite() || depth <= 0.0 { diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 6de6beaf..7ef429b2 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -147,7 +147,7 @@ fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() { let ctx = egui::Context::default(); let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test"))); - assert!(overlay(&app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + assert!(overlay(&mut app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); }); output.textures_delta.clear(); let texts: Vec<&str> = output @@ -200,7 +200,7 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() { ui.label("Flat size: 50 × 10 × 2.5 mm"); }); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit"))); - assert!(overlay(&app, &painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog)); + assert!(overlay(&mut app, &painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog)); }); output.textures_delta.clear(); } @@ -239,7 +239,7 @@ fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() { let ctx = egui::Context::default(); let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("coincident-pocket-test"))); - assert!(overlay(&app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + assert!(overlay(&mut app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); }); output.textures_delta.clear(); let labels: Vec<_> = output @@ -472,3 +472,208 @@ fn convert_takes_the_fixed_face_and_the_bend_edges() { assert_eq!(bends(&mut app), Some(4)); assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results); } + +// ---- flat pattern navigation ---- + +fn flat_app(preset: &str) -> SolveApp { + let mut app = plate(); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + app.start("sheet.flat_pattern"); + let Some(Kind::Sheet(Sm::FlatPattern { data, .. })) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + app.viewport.no_pixels = true; + app.preferences.nav = preset.into(); + app.preferences.zoom_to_cursor = true; + app +} + +/// Same view in the flat plane: the Design viewport may anchor a zoom on the model's surface +/// (a little out of the plane), the flat view anchors on its plane. +fn same_view(a: &solidcraft_engine::render::Camera, b: &solidcraft_engine::render::Camera) -> bool { + (a.target.x - b.target.x).abs() < 1e-3 + && (a.target.y - b.target.y).abs() < 1e-3 + && (a.distance - b.distance).abs() < 1e-9 + && a.yaw == b.yaw + && a.pitch == b.pitch +} + +fn frame(app: &mut SolveApp, ctx: &egui::Context, time: &mut f64, mods: egui::Modifiers, events: Vec) { + *time += 1.0 / 60.0; + let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(); + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(*time), + focused: true, + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| crate::viewport::show(app, ui)); + out.textures_delta.clear(); +} + +/// The same pan and zoom input moves the flat pattern's view exactly as it moves the Design +/// viewport's camera, under every navigation preset, with and without reverse zoom and zoom to the +/// cursor. The flat view never orbits. +#[test] +fn flat_pattern_pans_and_zooms_like_the_design_viewport() { + use egui::{Event, Modifiers, PointerButton}; + let none = Modifiers::NONE; + let chords = [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT]; + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + for (reverse, to_cursor) in [(false, true), (true, true), (false, false)] { + let (flat_ctx, design_ctx) = (egui::Context::default(), egui::Context::default()); + let mut flat = flat_app(preset); + let mut design = flat_app(preset); + design.dialog = None; + for app in [&mut flat, &mut design] { + app.preferences.zoom_reverse = reverse; + app.preferences.zoom_to_cursor = to_cursor; + } + let (mut ft, mut dt) = (0.0, 0.0); + frame(&mut flat, &flat_ctx, &mut ft, none, vec![]); + frame(&mut flat, &flat_ctx, &mut ft, none, vec![]); + assert!(flat.flat_view.fitted); + // The Design viewport starts from the flat view's camera: top-down, orthographic. + design.cam = flat.flat_view.cam; + design.ui.perspective = false; + let design_before = flat.cam; + let (mut panned, mut zoomed) = (0, 0); + let at = pos2(300.0, 250.0); + for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { + for mods in chords { + let mode = crate::prefs::nav_mode(&flat, button.into_nav(), mods); + if !matches!(mode, Some(crate::viewport::NavMode::Pan | crate::viewport::NavMode::Zoom)) { + continue; + } + let before = flat.flat_view.cam; + let mut both = |events: Vec, mods: Modifiers, flat: &mut SolveApp, design: &mut SolveApp| { + frame(flat, &flat_ctx, &mut ft, mods, events.clone()); + frame(design, &design_ctx, &mut dt, mods, events); + }; + both(vec![Event::PointerMoved(at)], mods, &mut flat, &mut design); + both(vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }], mods, &mut flat, &mut design); + let mut p = at; + for _ in 0..5 { + p += vec2(14.0, -9.0); + both(vec![Event::PointerMoved(p)], mods, &mut flat, &mut design); + } + both(vec![Event::PointerButton { pos: p, button, pressed: false, modifiers: mods }], mods, &mut flat, &mut design); + for _ in 0..10 { + both(vec![], none, &mut flat, &mut design); + } + assert!(same_view(&flat.flat_view.cam, &design.cam), "{preset}: {button:?} drag with {mods:?} ({mode:?})"); + assert_ne!(flat.flat_view.cam, before, "{preset}: {button:?} drag with {mods:?} moves the flat view"); + if mode == Some(crate::viewport::NavMode::Pan) { + panned += 1 + } else { + zoomed += 1 + } + flat.flat_view.cam = before; + design.cam = before; + } + } + assert!(panned + zoomed >= 1, "{preset}: the preset has a pan or zoom drag"); + // The wheel zooms (to the cursor when asked), identically. + let before = flat.flat_view.cam; + let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; + for (app, ctx, t) in [(&mut flat, &flat_ctx, &mut ft), (&mut design, &design_ctx, &mut dt)] { + frame(app, ctx, t, none, vec![Event::PointerMoved(at)]); + frame(app, ctx, t, none, vec![wheel.clone()]); + for _ in 0..30 { + frame(app, ctx, t, none, vec![]); + } + } + assert_ne!(flat.flat_view.cam.distance, before.distance, "{preset}: the wheel zooms the flat view"); + assert!(same_view(&flat.flat_view.cam, &design.cam), "{preset} reverse {reverse} cursor {to_cursor}: wheel zoom"); + assert_eq!(flat.flat_view.cam.distance < before.distance, !reverse, "{preset}: reverse zoom flips the wheel"); + if to_cursor { + assert!(flat.flat_view.cam.target.dist(before.target) > 1e-6, "{preset}: zoom to the cursor moves the target"); + } + assert_eq!(flat.flat_view.cam.pitch, before.pitch); + assert_eq!(flat.flat_view.cam.yaw, before.yaw); + assert_eq!(flat.cam, design_before, "{preset}: the Design camera is untouched"); + } + } +} + +trait IntoNav { + fn into_nav(self) -> crate::prefs::NavButton; +} +impl IntoNav for egui::PointerButton { + fn into_nav(self) -> crate::prefs::NavButton { + match self { + egui::PointerButton::Middle => crate::prefs::NavButton::Middle, + egui::PointerButton::Secondary => crate::prefs::NavButton::Right, + _ => crate::prefs::NavButton::Left, + } + } +} + +/// Input over the flat pattern, including orbit bindings, wheel and pinch, leaves the Design +/// camera and selection alone. +#[test] +fn design_camera_is_unchanged_while_the_flat_pattern_has_focus() { + use egui::{Event, Modifiers, PointerButton}; + let none = Modifiers::NONE; + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let ctx = egui::Context::default(); + let mut app = flat_app(preset); + let mut t = 0.0; + frame(&mut app, &ctx, &mut t, none, vec![]); + let cam = app.cam; + let at = pos2(300.0, 250.0); + for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { + for mods in [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] { + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }]); + for i in 1..6 { + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerMoved(at + vec2(14.0, -9.0) * i as f32)]); + } + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerButton { pos: at, button, pressed: false, modifiers: mods }]); + } + } + let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; + frame(&mut app, &ctx, &mut t, none, vec![wheel]); + frame(&mut app, &ctx, &mut t, none, vec![Event::Zoom(1.3)]); + for _ in 0..30 { + frame(&mut app, &ctx, &mut t, none, vec![]); + } + assert_eq!(app.cam, cam, "{preset}: the Design camera stays put under the flat pattern"); + assert!(app.session.selection.is_empty(), "{preset}: nothing underneath is selected"); + assert_eq!(app.flat_view.cam.pitch, app.flat_view.cam.pitch.clamp(1.5, 2.0), "{preset}: still looking straight down"); + } +} + +/// Fit and Home frame the flat pattern (not the model), and leave the Design camera alone. +#[test] +fn fit_and_home_frame_the_flat_pattern() { + let ctx = egui::Context::default(); + let mut app = flat_app("fusion"); + let mut t = 0.0; + let none = egui::Modifiers::NONE; + frame(&mut app, &ctx, &mut t, none, vec![]); + let fitted = app.flat_view.cam; + // The 100 x 60 pattern sits inside the view with room for the command panel. + let scale = 600.0 / (2.0 * fitted.half_height()); + assert!(100.0 * scale < 800.0 && 60.0 * scale < 600.0 && 100.0 * scale > 200.0, "fits: {scale}"); + let design = app.cam; + for view in ["fit", "home"] { + app.flat_view.cam.pan(120.0, -80.0, 600.0); + app.flat_view.cam.zoom_at(0.3, None); + assert_ne!(app.flat_view.cam, fitted); + app.animate_view(view); + frame(&mut app, &ctx, &mut t, none, vec![]); + assert!(app.flat_view.cam.target.dist(fitted.target) < 1e-9 && (app.flat_view.cam.distance - fitted.distance).abs() < 1e-9, "{view} refits"); + assert_eq!(app.cam, design, "{view} leaves the Design camera alone"); + } + app.flat_view.cam.pan(50.0, 50.0, 600.0); + app.fit_view(); + frame(&mut app, &ctx, &mut t, none, vec![]); + assert!(app.flat_view.cam.target.dist(fitted.target) < 1e-9, "fit_view refits"); + // Closing the dialog returns the viewport to the Design camera, and reopening refits. + app.flat_view.cam.pan(50.0, 50.0, 600.0); + app.dialog = None; + frame(&mut app, &ctx, &mut t, none, vec![]); + assert!(!app.flat_view.fitted); +} diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 11bcf39b..4c88a7eb 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -90,7 +90,8 @@ impl DimensionDraft { && app.session.pending_moves == self.pending } fn params(&self, expr: &str) -> serde_json::Value { - let mut params = json!({"entities": self.entities, "value": expr}); + // The first dimension of a sketch scales it rather than distorting it (#1365). + let mut params = json!({"entities": self.entities, "value": expr, "scale_sketch": true}); if let Some(at) = self.at { params["text_at"] = json!([at.x, at.y]); } @@ -408,7 +409,7 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool, units: &str, let plain = expr.is_none_or(|e| { let e = e.trim(); let e = ["mm", "cm", "in", "ft", "m", "deg", "rad", "°"].iter().find_map(|unit| e.strip_suffix(unit)).unwrap_or(e); - e.trim().parse::().is_ok() + crate::prefs::parse_number(e).is_some() }); let text = if plain { num } else { format!("fx: {num}") }; if driven { format!("({text})") } else { text } @@ -823,6 +824,8 @@ mod tests { let k = ConstraintKind::Length { l: 0, value: 25.0 }; assert_eq!(label(&k, Some("25 mm"), false, "mm", 2), "25.00"); assert_eq!(label(&k, Some("width * 2"), false, "mm", 2), "fx: 25.00"); + // A value typed with a decimal comma is a plain value too (#1347). + assert_eq!(label(&ConstraintKind::Length { l: 0, value: 25.5 }, Some("25,5 mm"), false, "mm", 2), "25.50"); assert_eq!(label(&k, None, true, "mm", 2), "(25.00)"); assert_eq!(label(&ConstraintKind::Radius { c: 0, value: 5.0 }, Some("5 mm"), false, "mm", 2), "R5.00"); assert_eq!(label(&ConstraintKind::Diameter { c: 0, value: 10.0 }, Some("10"), false, "mm", 2), "Ø10.00"); diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 809e7c61..7cfd1910 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -146,7 +146,8 @@ fn rectangle_side_and_fixed_origin_line_dimensions_preserve_other_constraints() h.step(&json!({"key":"Enter"})).unwrap(); let state = h.app.session.world_state(); let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); - assert!((solved.profiles[0].area - 240.).abs() < 1e-5, "side dimensions keep rectangle width: {}", solved.profiles[0].area); + // The sketch's first dimension scales it (#1365): 20 × 10 becomes 24 × 12. + assert!((solved.profiles[0].area - 288.).abs() < 1e-5, "the first dimension scales the rectangle: {}", solved.profiles[0].area); assert_eq!(h.app.session.undo.len(), undo + 1); h.app.run("edit.undo", json!({})).unwrap(); assert_eq!(h.app.session.doc.to_json(), baseline); @@ -342,9 +343,11 @@ fn placing_a_two_point_dimension_picks_horizontal_vertical_or_aligned() { let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap(); let spans = spans(a, b); assert!((spans[measured] - 10.0).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); - // A horizontal or vertical dimension leaves the other span as drawn. + // The sketch's first dimension scales it (#1365): the other span keeps its proportion. if measured < 2 { - assert!((spans[1 - measured] - [12.0, 16.0][1 - measured]).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); + let drawn = [12.0, 16.0]; + let other = drawn[1 - measured] * 10.0 / drawn[measured]; + assert!((spans[1 - measured] - other).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}"); } } } diff --git a/crates/ui-egui/src/display_lod.rs b/crates/ui-egui/src/display_lod.rs new file mode 100644 index 00000000..e62bf5c0 --- /dev/null +++ b/crates/ui-egui/src/display_lod.rs @@ -0,0 +1,503 @@ +//! View-dependent display refinement (#1320): bodies seen up close are drawn from finer display +//! plans, so curved faces, their edges and silhouettes stay smooth as the view zooms in. +//! +//! Each frame the viewport works out every shown body's screen-space error: the chord tolerance +//! of the chosen display quality (Preferences ▸ Graphics) in physical pixels, where the body is +//! nearest the eye. [`refinement_for`] turns that into a refinement step (each halves the chord), +//! with hysteresis. A step not made yet is planned off the UI thread and swapped in when it is +//! ready; until then the body keeps the step it has. Only bodies on screen and big enough get +//! refined, within a triangle budget (the bodies that need it most first) and a cache size. +//! +//! The model scene draws a refined body's faces from the refined plan (on the GPU, or its CPU +//! mesh), and its edges and highlights from that plan's mesh, so a highlight keeps lying on the +//! drawn triangles (#1064). Display only: the reference mesh the model reads, exports and the +//! B-rep never change. + +use std::collections::HashMap; +use std::sync::Arc; + +use solidcraft_engine::doc::ModelBody; +use solidcraft_engine::doc::display_quality::{DisplayQuality, MAX_REFINE, refinement_for}; +use solidcraft_engine::geom::tess::TessPlan; +use solidcraft_engine::geom::{Aabb3, Mesh, Vec3}; + +/// Most triangles all refined bodies may draw together (on top of the rest of the model). +pub(crate) const TRIANGLE_BUDGET: usize = 6_000_000; +/// Most bytes the refined plans and meshes kept may take; the least recently drawn go first. +pub(crate) const CACHE_BYTES: usize = 768 << 20; +/// A body smaller than this on screen (its box's longer side, physical pixels) is not refined. +pub(crate) const MIN_PX: f64 = 32.0; +/// Refinements planned at once, off the UI thread. +#[cfg(not(target_arch = "wasm32"))] +const MAX_JOBS: usize = 2; + +/// A refined plan and its CPU mesh (none: the body has no plan at that step). +pub(crate) type Refined = Option<(Arc, Arc)>; + +/// Whether the viewport refines at all: `SOLIDCRAFT_DISPLAY_REFINE=0` turns it off (to compare +/// pictures and frame times with and without it). +pub(crate) fn enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("SOLIDCRAFT_DISPLAY_REFINE").map_or(true, |v| v != "0")) +} + +/// A body's identity here: the address of its reference mesh, which is shared by every copy of +/// the same body across timeline snapshots and held by the cache so the address stays its own. +pub(crate) fn body_id(b: &ModelBody) -> (usize, Arc) { + let m = b.mesh(); + (Arc::as_ptr(&m) as usize, m) +} + +/// How the camera maps millimetres to pixels. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Lens { + pub eye: Vec3, + /// Unit direction from the target toward the eye. + pub back: Vec3, + /// Orthographic: pixels per millimetre everywhere. Perspective: pixels per millimetre at a + /// depth of 1 mm (divided by the depth). + pub scale: f64, + pub perspective: bool, + /// Nearest depth considered (mm), so a body around the eye does not ask for infinity. + pub near: f64, +} + +impl Lens { + /// The view of `cam` in a viewport `height_px` physical pixels high. + pub fn new(cam: &solidcraft_engine::render::Camera, height_px: f64) -> Lens { + let perspective = cam.fov > 0.0; + let scale = if perspective { height_px / (2.0 * (cam.fov * 0.5).tan()) } else { height_px / (2.0 * cam.half_height()) }; + Lens { eye: cam.eye(), back: cam.back(), scale, perspective, near: (cam.distance * 1e-2).max(1e-3) } + } + + /// Whether any of a box lies in front of the eye. + pub fn in_front(&self, b: &Aabb3) -> bool { + let toward = -self.back; + let far_corner = Vec3::new( + if toward.x > 0.0 { b.max.x } else { b.min.x }, + if toward.y > 0.0 { b.max.y } else { b.min.y }, + if toward.z > 0.0 { b.max.z } else { b.min.z }, + ); + !self.perspective || (far_corner - self.eye).dot(toward) > 0.0 + } + + /// Pixels per millimetre where a box comes nearest the eye. + pub fn px_per_mm(&self, b: &Aabb3) -> f64 { + if !self.perspective { + return self.scale; + } + // The box's nearest depth along the view: its support point toward the eye. + let toward = -self.back; + let near_corner = Vec3::new( + if toward.x > 0.0 { b.min.x } else { b.max.x }, + if toward.y > 0.0 { b.min.y } else { b.max.y }, + if toward.z > 0.0 { b.min.z } else { b.max.z }, + ); + let depth = (near_corner - self.eye).dot(toward); + self.scale / depth.max(self.near) + } +} + +/// The screen-space error (physical pixels) of drawing a body of `size` at `q` and its box's +/// projected extent (`screen_px`, physical pixels; `None` when the box is not on screen). +pub(crate) fn error_px(q: DisplayQuality, size: f64, bounds: &Aabb3, screen_px: Option, lens: &Lens) -> f64 { + match screen_px { + Some(px) if px >= MIN_PX => q.chord(size) * lens.px_per_mm(bounds), + _ => 0.0, + } +} + +/// One shown body as the refinement sees it this frame. +pub(crate) struct Candidate<'a> { + pub body: &'a ModelBody, + /// The chosen level's screen-space error ([`error_px`]). + pub err_px: f64, + /// Triangles at the chosen level (an estimate of the step-0 count). + pub triangles: usize, +} + +struct Entry { + /// Keeps the body's id address its own while the entry lives. + _holder: Arc, + refined: Refined, + bytes: usize, + used: u64, +} + +#[cfg(not(target_arch = "wasm32"))] +struct Job { + key: (usize, DisplayQuality, u8), + holder: Arc, + rx: std::sync::mpsc::Receiver, +} + +/// What the refinement holds now (`ui.inspect` → `display_lod`). +#[derive(Clone, Debug, Default, serde::Serialize)] +pub(crate) struct Stats { + /// Bodies drawn finer than the chosen level. + pub refined_bodies: usize, + /// Triangles those bodies draw. + pub refined_triangles: usize, + /// Refined plans and meshes kept, and their bytes. + pub cached: usize, + pub cache_bytes: usize, + /// Refinements being planned. + pub planning: usize, + /// Refinements planned so far, and the time they took (ms, off the UI thread). + pub planned: usize, + pub planning_ms: f64, + /// Time the last choice of steps took on the UI thread (ms). + pub choose_ms: f64, +} + +/// The refinement state of a viewport. +#[derive(Default)] +pub(crate) struct Lod { + /// The step each body is drawn at, by [`body_id`] (absent: 0, the chosen level). + shown: HashMap, + cache: HashMap<(usize, DisplayQuality, u8), Entry>, + #[cfg(not(target_arch = "wasm32"))] + jobs: Vec, + /// The web plans one refinement per frame: more are waiting. + #[cfg(target_arch = "wasm32")] + more: bool, + /// Bumped whenever a body's drawn step changes (part of the scene's key). + pub generation: u64, + clock: u64, + pub stats: Stats, +} + +impl Lod { + /// The refined plan and mesh `b` is drawn from, when it is drawn finer than the chosen level. + pub fn refined(&self, b: &ModelBody, q: DisplayQuality) -> Refined { + let (id, _) = body_id(b); + let k = *self.shown.get(&id)?; + self.cache.get(&(id, q, k))?.refined.clone() + } + + /// The step `b` is drawn at. + #[cfg(test)] + pub fn step(&self, b: &ModelBody) -> u8 { + self.shown.get(&body_id(b).0).copied().unwrap_or(0) + } + + /// Choose each candidate's step, start planning the steps not made yet, and swap in those + /// that are ready. `live` are the ids of every body in the model now (hidden ones too): the + /// cache drops the others. Returns whether any body's drawn step changed. + pub fn update(&mut self, q: DisplayQuality, candidates: Vec>, live: &std::collections::HashSet) -> bool { + self.clock += 1; + self.collect(); + let mut changed = false; + // The bodies that need it most first, within the triangle budget. + let mut order: Vec = (0..candidates.len()).collect(); + order.sort_by(|&a, &b| candidates[b].err_px.total_cmp(&candidates[a].err_px)); + let mut remaining = TRIANGLE_BUDGET; + let mut wanted: Vec<(usize, u8)> = Vec::new(); + let mut requests: Vec<(usize, u8)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for i in order { + let c = &candidates[i]; + let (id, _) = body_id(c.body); + if !seen.insert(id) { + continue; + } + let current = self.shown.get(&id).copied().unwrap_or(0); + let mut k = refinement_for(c.err_px, current, MAX_REFINE); + let estimate = |k: u8| self.cache.get(&(id, q, k)).and_then(|e| e.refined.as_ref()).map_or(c.triangles << k, |(_, m)| m.triangles.len()); + while k > 0 && estimate(k) > remaining { + k -= 1; + } + if k > 0 { + remaining -= estimate(k); + } + wanted.push((i, k)); + } + for (i, k) in wanted { + let (id, holder) = body_id(candidates[i].body); + let current = self.shown.get(&id).copied().unwrap_or(0); + if k == current { + continue; + } + if k == 0 { + self.shown.remove(&id); + changed = true; + continue; + } + match self.cache.get(&(id, q, k)) { + Some(e) if e.refined.is_some() => { + self.shown.insert(id, k); + changed = true; + } + // The planner refused this step: keep what is drawn. + Some(_) => {} + None => { + let _ = holder; + requests.push((i, k)); + } + } + } + self.start(q, &candidates, requests); + // Keep the steps drawn now from being evicted. + for (id, k) in &self.shown { + if let Some(e) = self.cache.get_mut(&(*id, q, *k)) { + e.used = self.clock; + } + } + self.shown.retain(|id, _| live.contains(id)); + self.evict(live); + self.refresh_stats(q); + if changed { + self.generation += 1; + } + changed + } + + /// Whether refinements are still being planned (the viewport repaints to collect them). + pub fn busy(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + !self.jobs.is_empty() + } + #[cfg(target_arch = "wasm32")] + { + self.more + } + } + + fn insert(&mut self, key: (usize, DisplayQuality, u8), holder: Arc, refined: Refined) { + let bytes = refined.as_ref().map_or(0, |(plan, mesh)| mesh_bytes(mesh) + plan.vertex_count() * 48 + mesh.triangles.len() * 12); + self.cache.insert(key, Entry { _holder: holder, refined, bytes, used: self.clock }); + } + + /// Collect finished refinements; whether any finished. They are swapped in by the next + /// choice of steps ([`Lod::update`]). + #[cfg(not(target_arch = "wasm32"))] + pub fn collect(&mut self) -> bool { + let mut done = Vec::new(); + self.jobs.retain(|j| match j.rx.try_recv() { + Ok(r) => { + done.push((j.key, j.holder.clone(), r)); + false + } + Err(std::sync::mpsc::TryRecvError::Empty) => true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + done.push((j.key, j.holder.clone(), None)); + false + } + }); + let any = !done.is_empty(); + for (key, holder, r) in done { + self.insert(key, holder, r); + } + any + } + + #[cfg(target_arch = "wasm32")] + pub fn collect(&mut self) -> bool { + self.more + } + + /// Plan the requested steps: off the UI thread, a few at a time, the most needed first (the + /// web has no threads: one per frame, right away). + fn start(&mut self, q: DisplayQuality, candidates: &[Candidate<'_>], mut requests: Vec<(usize, u8)>) { + requests.sort_by(|a, b| candidates[b.0].err_px.total_cmp(&candidates[a.0].err_px)); + #[cfg(not(target_arch = "wasm32"))] + for (i, k) in requests { + if self.jobs.len() >= MAX_JOBS { + break; + } + let body = candidates[i].body.clone(); + let (id, holder) = body_id(&body); + let key = (id, q, k); + if self.jobs.iter().any(|j| j.key.0 == id) { + continue; + } + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("display-refine".into()).spawn(move || { + let t0 = std::time::Instant::now(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body.refined_display(q, k))).ok().flatten(); + log::debug!("refined {} {k} steps in {:.1} ms", body.name, t0.elapsed().as_secs_f64() * 1e3); + PLANNING_US.fetch_add(t0.elapsed().as_micros() as u64, std::sync::atomic::Ordering::Relaxed); + let _ = tx.send(r); + }); + match spawned { + Ok(_) => self.jobs.push(Job { key, holder, rx }), + Err(e) => { + log::warn!("display refinement: {e}"); + break; + } + } + } + #[cfg(target_arch = "wasm32")] + { + self.more = requests.len() > 1; + } + #[cfg(target_arch = "wasm32")] + if let Some((i, k)) = requests.into_iter().next() { + let body = candidates[i].body; + let (id, holder) = body_id(body); + let r = body.refined_display(q, k); + self.insert((id, q, k), holder, r); + } + } + + /// Drop what belongs to bodies no longer in the model, then the least recently drawn steps + /// until the cache fits its budget. + fn evict(&mut self, live: &std::collections::HashSet) { + self.cache.retain(|(id, _, _), _| live.contains(id)); + let mut total: usize = self.cache.values().map(|e| e.bytes).sum(); + if total <= CACHE_BYTES { + return; + } + let mut by_age: Vec<((usize, DisplayQuality, u8), u64, usize)> = self.cache.iter().map(|(k, e)| (*k, e.used, e.bytes)).collect(); + by_age.sort_by_key(|(_, used, _)| *used); + for (key, used, bytes) in by_age { + if total <= CACHE_BYTES || used == self.clock { + break; + } + self.cache.remove(&key); + total -= bytes; + } + } + + fn refresh_stats(&mut self, q: DisplayQuality) { + let drawn: Vec<&Entry> = self.shown.iter().filter_map(|(id, k)| self.cache.get(&(*id, q, *k))).collect(); + self.stats.refined_bodies = drawn.len(); + self.stats.refined_triangles = drawn.iter().filter_map(|e| e.refined.as_ref()).map(|(_, m)| m.triangles.len()).sum(); + self.stats.cached = self.cache.len(); + self.stats.cache_bytes = self.cache.values().map(|e| e.bytes).sum(); + #[cfg(not(target_arch = "wasm32"))] + { + self.stats.planning = self.jobs.len(); + } + self.stats.planning_ms = PLANNING_US.load(std::sync::atomic::Ordering::Relaxed) as f64 / 1e3; + self.stats.planned = self.cache.len(); + } + + /// Plan a step right away and draw `b` at it (tests). + #[cfg(test)] + pub fn force(&mut self, b: &ModelBody, q: DisplayQuality, k: u8) -> bool { + let (id, holder) = body_id(b); + let r = b.refined_display(q, k); + let ok = r.is_some(); + self.insert((id, q, k), holder, r); + if ok { + self.shown.insert(id, k); + self.generation += 1; + } + ok + } +} + +/// Microseconds spent planning refinements (all threads). +static PLANNING_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn mesh_bytes(m: &Mesh) -> usize { + m.positions.len() * 24 + + m.normals.len() * 24 + + m.triangles.len() * 12 + + m.tri_face.len() * 4 + + m.edges.iter().map(|e| e.len() * 24).sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_engine::render::Camera; + + fn cylinder() -> ModelBody { + let body = solidcraft_engine::kernel::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap(); + ModelBody::new("Body1".into(), body, 1) + } + + /// Zooming in raises the error with the pixels per millimetre, perspective or not, and a + /// box nearer the eye counts more. + #[test] + fn the_error_follows_the_zoom() { + let b = Aabb3 { min: Vec3::new(-10.0, -10.0, 0.0), max: Vec3::new(10.0, 10.0, 20.0) }; + let q = DisplayQuality::Normal; + for fov in [0.0, 0.6] { + let mut cam = Camera { distance: 200.0, fov, ..Camera::default() }; + let far = error_px(q, 30.0, &b, Some(300.0), &Lens::new(&cam, 1000.0)); + cam.distance = 50.0; + let near = error_px(q, 30.0, &b, Some(1200.0), &Lens::new(&cam, 1000.0)); + assert!(near > far * 3.0, "fov {fov}: {far} -> {near}"); + assert_eq!(error_px(q, 30.0, &b, None, &Lens::new(&cam, 1000.0)), 0.0, "off screen"); + assert_eq!(error_px(q, 30.0, &b, Some(MIN_PX / 2.0), &Lens::new(&cam, 1000.0)), 0.0, "too small"); + } + // Orthographic: chord × pixels per mm. + let cam = Camera { distance: 100.0, fov: 0.0, ..Camera::default() }; + let e = error_px(q, 30.0, &b, Some(500.0), &Lens::new(&cam, 800.0)); + assert!((e - q.chord(30.0) * 800.0 / (2.0 * cam.half_height())).abs() < 1e-12); + } + + /// A close-up refines the body (planned off the thread, then swapped in), a step finer has + /// more triangles and stays within its chord, and zooming back out returns to the chosen level. + #[test] + fn a_close_up_refines_and_zooming_out_returns() { + let b = cylinder(); + let q = DisplayQuality::Normal; + let live: std::collections::HashSet = [body_id(&b).0].into(); + let base = b.plan_mesh_at(q).unwrap().triangles.len(); + let mut lod = Lod::default(); + let cand = |err: f64| vec![Candidate { body: &b, err_px: err, triangles: base }]; + assert!(!lod.update(q, cand(0.5), &live)); + assert_eq!(lod.step(&b), 0); + // 6 px of error: three steps. + let mut swapped = false; + for _ in 0..2000 { + swapped |= lod.update(q, cand(6.0), &live); + if lod.step(&b) == 3 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(swapped && lod.step(&b) == 3, "refined to step {}", lod.step(&b)); + let (_, mesh) = lod.refined(&b, q).unwrap(); + assert!(mesh.triangles.len() > base * 2, "{} vs {base}", mesh.triangles.len()); + let r = |v: Vec3| (v.x * v.x + v.y * v.y).sqrt(); + let sag = mesh + .triangles + .iter() + .map(|t| t.map(|i| mesh.positions[i as usize])) + .filter(|t| t.iter().all(|p| (r(*p) - 10.0).abs() < 1e-9)) + .map(|[a, b, c]| 10.0 - r((a + b + c) * (1.0 / 3.0))) + .fold(0.0, f64::max); + assert!(sag <= q.refined_chord(b.body.size(), 3) * 1.01, "sag {sag}"); + // The reference mesh never changes. + assert!(Arc::ptr_eq(&body_id(&b).1, &b.mesh())); + // Zoomed out a little: kept; far out: the chosen level. + assert!(!lod.update(q, cand(3.0), &live)); + assert_eq!(lod.step(&b), 3); + assert!(lod.update(q, cand(0.1), &live)); + assert_eq!(lod.step(&b), 0); + assert!(lod.refined(&b, q).is_none()); + // Back in: swapped from the cache at once. + assert!(lod.update(q, cand(6.0), &live)); + assert_eq!(lod.step(&b), 3); + // The body leaves the model: dropped. + lod.update(q, Vec::new(), &Default::default()); + assert_eq!(lod.stats.cached, 0); + assert_eq!(lod.step(&b), 0); + } + + /// The budget refines the bodies that need it most and leaves the rest at the chosen level. + #[test] + fn the_budget_goes_to_the_largest_errors() { + let (a, b) = (cylinder(), cylinder()); + let q = DisplayQuality::Normal; + let live: std::collections::HashSet = [body_id(&a).0, body_id(&b).0].into(); + let mut lod = Lod::default(); + // Each would want four steps at 3M triangles a step-0 count: only the first fits. + let cands = vec![ + Candidate { body: &a, err_px: 2.0, triangles: TRIANGLE_BUDGET / 3 }, + Candidate { body: &b, err_px: 12.0, triangles: TRIANGLE_BUDGET / 3 }, + ]; + // Distinct bodies (two ModelBody::new calls), so distinct ids. + assert_ne!(body_id(&a).0, body_id(&b).0); + lod.update(q, cands, &live); + assert_eq!(lod.stats.planning, 1, "one refinement fits the budget"); + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(lod.jobs[0].key.0, body_id(&b).0, "the larger error first"); + } +} diff --git a/crates/ui-egui/src/edit_clipboard.rs b/crates/ui-egui/src/edit_clipboard.rs new file mode 100644 index 00000000..3806474e --- /dev/null +++ b/crates/ui-egui/src/edit_clipboard.rs @@ -0,0 +1,183 @@ +//! Copy, Cut and Paste from the keyboard (Ctrl+C / Ctrl+X / Ctrl+V, ⌘C / ⌘X / ⌘V) and the context +//! menu (#1480). +//! +//! In a sketch they copy the selected sketch geometry (`sketch.copy`) and paste it at the cursor +//! (`sketch.paste` with `at`); Cut copies the sketch geometry and deletes it (`sketch.cut`), or offset from the original when the cursor is not over the +//! viewport. Outside a sketch they copy the selected timeline features and paste them at the +//! history marker (`timeline.copy` / `timeline.paste`). +//! +//! The window system turns Ctrl+C / Ctrl+V into clipboard events, not key presses, and only sends +//! the paste event while the system clipboard holds text. So a copy also puts a short line of +//! text on the system clipboard (a menu's Copy leaves it for the next frame, which has the +//! context to write it). + +use std::sync::Mutex; + +use egui::Pos2; +use serde_json::{Value, json}; +use solidcraft_engine::Sel; + +use crate::SolveApp; + +/// Text for the system clipboard, written on the next frame. +static PENDING_TEXT: Mutex> = Mutex::new(None); + +fn leave_text(text: String) { + if let Ok(mut pending) = PENDING_TEXT.lock() { + *pending = Some(text); + } +} + +/// The selected sketch geometry of the active sketch. +fn sketch_selection(app: &SolveApp) -> Vec { + app.session + .selection + .iter() + .filter_map(|s| match s { + Sel::SketchCurve { id } | Sel::SketchPoint { id } => Some(id.clone()), + _ => None, + }) + .collect() +} + +/// Is there something Copy would copy? +pub fn can_copy(app: &SolveApp) -> bool { + if app.session.active_sketch.is_some() { !sketch_selection(app).is_empty() } else { !crate::timeline::selected_feature_ids(app).is_empty() } +} + +/// Is there something Paste would paste here? +pub fn can_paste(app: &SolveApp) -> bool { + if app.session.active_sketch.is_some() { app.session.sketch_clipboard.is_some() } else { !app.session.clipboard.is_empty() } +} + +/// Copy the selection: sketch geometry in a sketch, otherwise timeline features. +pub fn copy(app: &mut SolveApp) -> bool { + if app.session.active_sketch.is_some() { + let entities = sketch_selection(app); + if entities.is_empty() { + app.set_status("Select sketch geometry to copy", true); + return false; + } + let Ok(out) = app.run("sketch.copy", json!({ "entities": entities })) else { return false }; + let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0); + let k = out.get("constraints").and_then(Value::as_u64).unwrap_or(0); + let text = format!("SolidCraft sketch geometry: {n} entities, {k} constraints and dimensions"); + app.set_status(format!("Copied {n} sketch entities with {k} constraints and dimensions"), false); + leave_text(text); + return true; + } + let features = crate::timeline::selected_feature_ids(app); + if features.is_empty() { + app.set_status("Select sketch geometry or timeline features to copy", true); + return false; + } + let Ok(out) = app.run("timeline.copy", json!({ "features": features })) else { return false }; + let names: Vec<&str> = out.get("copied").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + app.set_status(format!("Copied {}", names.join(", ")), false); + leave_text(format!("SolidCraft features: {}", names.join(", "))); + true +} + +/// Cut the selected sketch geometry: copy it, then delete it (one undo step). +pub fn cut(app: &mut SolveApp) -> bool { + if app.session.active_sketch.is_none() { + app.set_status("Cut works on sketch geometry: edit a sketch and select some", true); + return false; + } + let entities = sketch_selection(app); + if entities.is_empty() { + app.set_status("Select sketch geometry to cut", true); + return false; + } + let Ok(out) = app.run("sketch.cut", json!({ "entities": entities })) else { return false }; + let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0); + app.set_status(format!("Cut {n} sketch entities"), false); + leave_text(format!("SolidCraft sketch geometry: {n} entities")); + true +} + +/// The point of the active sketch under `at` (screen), when `at` is over the viewport. +fn sketch_point(app: &SolveApp, at: Option) -> Option<[f64; 2]> { + let at = at?; + let rect = app.viewport.rect?; + if !rect.contains(at) { + return None; + } + let proj = crate::viewport::projection(app, rect); + let (p, _) = crate::viewport::sketch_point_at(app, &proj, at)?; + Some([p.x, p.y]) +} + +/// Paste: in a sketch, the copied sketch geometry centred at `at` (or offset from the original +/// when `at` is not over the sketch); otherwise the copied features at the history marker. +pub fn paste(app: &mut SolveApp, at: Option) -> bool { + if app.session.active_sketch.is_some() { + if app.session.sketch_clipboard.is_none() { + app.set_status("Nothing to paste: copy sketch geometry first", true); + return false; + } + let params = match sketch_point(app, at) { + Some(p) => json!({ "at": p }), + None => json!({}), + }; + let Ok(out) = app.run("sketch.paste", params) else { return false }; + let n = out.get("curves").and_then(Value::as_array).map_or(0, Vec::len) + out.get("points").and_then(Value::as_array).map_or(0, Vec::len); + app.set_status(format!("Pasted {n} sketch entities (selected: drag them, or press M to move them)"), false); + return true; + } + if app.session.clipboard.is_empty() { + app.set_status("Nothing to paste: copy timeline features first", true); + return false; + } + app.run("timeline.paste", json!({})).is_ok() +} + +/// The frame's Copy, Cut and Paste events (and Ctrl+C / Ctrl+X / Ctrl+V sent as key presses), plus any +/// clipboard text a menu's Copy left. Called by the global shortcuts when no text field has the +/// keyboard. +pub fn handle(app: &mut SolveApp, ctx: &egui::Context) { + let (copies, cuts, pastes, pointer) = ctx.input(|i| { + let mut copies = 0; + let mut cuts = 0; + let mut pastes = 0; + for e in &i.events { + match e { + egui::Event::Copy => copies += 1, + egui::Event::Cut => cuts += 1, + egui::Event::Paste(_) => pastes += 1, + egui::Event::Key { key: egui::Key::C, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => { + copies += 1 + } + egui::Event::Key { key: egui::Key::X, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => { + cuts += 1 + } + egui::Event::Key { key: egui::Key::V, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => { + pastes += 1 + } + _ => {} + } + } + (copies, cuts, pastes, i.pointer.latest_pos()) + }); + if copies > 0 { + copy(app); + } + if cuts > 0 { + cut(app); + } + if pastes > 0 { + paste(app, pointer); + } + flush(ctx); +} + +/// Write clipboard text a copy left. +pub fn flush(ctx: &egui::Context) { + if let Some(text) = PENDING_TEXT.lock().ok().and_then(|mut p| p.take()) { + ctx.copy_text(text); + } +} + +#[cfg(test)] +#[path = "edit_clipboard_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/edit_clipboard_tests.rs b/crates/ui-egui/src/edit_clipboard_tests.rs new file mode 100644 index 00000000..b0967aaf --- /dev/null +++ b/crates/ui-egui/src/edit_clipboard_tests.rs @@ -0,0 +1,164 @@ +//! Copy and Paste from the keyboard and the context menu (#1480). + +use serde_json::{Value, json}; +use solidcraft_engine::Sel; +use solidcraft_engine::geom::Vec2; + +use crate::scenario::Harness; + +fn strs(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +/// A sketch on XY with a 20 × 10 rectangle (dimensioned, its corner on the origin), looked at +/// from the top, and the rectangle selected. +fn rectangle() -> (Harness, Vec) { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + let rect = strs(&h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})).unwrap()["curves"]); + h.app.run("sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"})).unwrap(); + h.app.run("sketch.dimension", json!({"entities": [rect[0]], "value": 20})).unwrap(); + h.app.run("sketch.dimension", json!({"entities": [rect[1]], "value": 10})).unwrap(); + h.call("ui.view", json!({"view": "top"})); + h.call("ui.camera", json!({"target": [20, 10, 0], "distance": 150})); + let items: Vec = rect.iter().map(|id| json!({"type": "sketch_curve", "id": id})).collect(); + h.app.run("select.set", json!({ "items": items })).unwrap(); + h.frames(4); + (h, rect) +} + +/// Send the window system's clipboard event (what Ctrl+C / Ctrl+V become), with the pointer at +/// `at` (sketch coordinates) or off the viewport. +fn clipboard_event(h: &mut Harness, event: egui::Event, at: Option<[f64; 2]>) { + match at { + Some(p) => h.step(&json!({"move": {"sketch": p}})).unwrap(), + None => drop(h.call("ui.move", json!({"x": 100, "y": 990}))), + } + h.app.synthetic.push(event); + h.settle(); +} + +fn curves(h: &Harness) -> Vec { + h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap().curves.iter().map(|c| c.id.clone()).collect() +} + +fn start(h: &Harness, id: &str) -> Vec2 { + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let c = &sk.curves[sk.curve_index(id).unwrap()]; + sk.point(c.kind.point_ids()[0]).unwrap() +} + +#[test] +fn ctrl_c_ctrl_v_pastes_the_sketch_selection_at_the_cursor_selected_in_one_undo_step() { + let (mut h, rect) = rectangle(); + let before = curves(&h); + let undo = h.app.session.undo.len(); + clipboard_event(&mut h, egui::Event::Copy, Some([10.0, 5.0])); + assert!(h.app.session.sketch_clipboard.is_some(), "{:?}", h.app.status); + // The window system only sends Paste while its clipboard holds text: a copy leaves some. + h.call("ui.inspect", json!({})); + clipboard_event(&mut h, egui::Event::Paste("x".into()), Some([60.0, 40.0])); + let after = curves(&h); + assert_eq!(after.len(), before.len() + 4, "{:?}", h.app.status); + let pasted: Vec = after.iter().filter(|c| !before.contains(c)).cloned().collect(); + // Centred at the cursor: the rectangle (centre 10, 5) goes to (60, 40), give or take the snap. + let corner = start(&h, &pasted[0]); + assert!(corner.dist(Vec2::new(50.0, 35.0)) < 1.0, "{corner:?}"); + // The originals stay put, with their constraints. + assert!(start(&h, &rect[0]).dist(Vec2::ZERO) < 1e-9); + // Selected, ready to move. + let selected: Vec = + h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect(); + assert_eq!(selected, pasted); + // One undo step. + assert_eq!(h.app.session.undo.len(), undo + 1); + h.step(&json!({"key": "Z", "cmd": true})).unwrap(); + assert_eq!(curves(&h), before); +} + +#[test] +fn copy_leaves_text_on_the_system_clipboard_so_paste_can_follow() { + let (mut h, _) = rectangle(); + h.step(&json!({"move": {"sketch": [10, 5]}})).unwrap(); + h.app.synthetic.push(egui::Event::Copy); + let mut copied = Vec::new(); + for _ in 0..6 { + h.frame(); + copied.extend(h.output.commands.iter().filter_map(|c| match c { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + })); + } + assert!(copied.iter().any(|t| t.starts_with("SolidCraft sketch geometry")), "{copied:?}"); +} + +#[test] +fn paste_away_from_the_viewport_offsets_from_the_original_and_cmd_keys_work_too() { + let (mut h, rect) = rectangle(); + let before = curves(&h); + // Ctrl+C / Ctrl+V as key presses (as the control channel and some platforms send them). + h.step(&json!({"move": {"sketch": [10, 5]}})).unwrap(); + h.step(&json!({"key": "C", "cmd": true})).unwrap(); + h.call("ui.move", json!({"x": 100, "y": 990})); + h.step(&json!({"key": "V", "cmd": true})).unwrap(); + let pasted: Vec = curves(&h).into_iter().filter(|c| !before.contains(c)).collect(); + assert_eq!(pasted.len(), 4, "{:?}", h.app.status); + // 20 mm wide: a 2 mm step down and to the right. + let d = start(&h, &pasted[0]) - start(&h, &rect[0]); + assert!(d.dist(Vec2::new(2.0, -2.0)) < 1e-6, "{d:?}"); +} + +#[test] +fn the_context_menu_copies_and_pastes_sketch_geometry_at_the_click() { + let (mut h, rect) = rectangle(); + let before = curves(&h); + h.step(&json!({"click": {"sketch": [10, 0]}, "button": "right"})).unwrap(); + let menu = h.call("ui.menu", json!({})); + let labels: Vec<&str> = menu["result"]["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).collect(); + assert!(labels.contains(&"Copy") && labels.contains(&"Paste"), "{labels:?}"); + h.call("ui.menuPick", json!({"item": "Copy"})); + assert!(h.app.session.sketch_clipboard.is_some()); + h.step(&json!({"click": {"sketch": [70, 30]}, "button": "right"})).unwrap(); + h.call("ui.menuPick", json!({"item": "Paste"})); + h.frames(3); + let pasted: Vec = curves(&h).into_iter().filter(|c| !before.contains(c)).collect(); + assert!(!pasted.is_empty(), "{:?}", h.app.status); + // Whatever the right-click selected was copied, and its copy is centred on the click. + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let pts: Vec = pasted.iter().flat_map(|c| sk.curves[sk.curve_index(c).unwrap()].kind.point_ids()).map(|i| sk.point(i).unwrap()).collect(); + let (lo, hi) = pts.iter().fold((Vec2::new(1e9, 1e9), Vec2::new(-1e9, -1e9)), |(lo, hi), p| { + (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) + }); + assert!(((lo + hi) * 0.5).dist(Vec2::new(70.0, 30.0)) < 1.0, "{lo:?} {hi:?}"); + assert!(start(&h, &rect[0]).dist(Vec2::ZERO) < 1e-9); +} + +#[test] +fn ctrl_c_ctrl_v_outside_a_sketch_copies_and_pastes_timeline_features() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + h.frames(3); + let id = h.app.session.doc.features[0].id; + h.app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})).unwrap(); + h.frames(2); + clipboard_event(&mut h, egui::Event::Copy, None); + clipboard_event(&mut h, egui::Event::Paste("x".into()), None); + assert_eq!(h.app.session.doc.features.len(), 2, "{:?}", h.app.status); +} + +#[test] +fn ctrl_x_cuts_the_sketch_selection_and_ctrl_v_brings_it_back() { + let (mut h, rect) = rectangle(); + let before = curves(&h); + clipboard_event(&mut h, egui::Event::Cut, Some([10.0, 5.0])); + let left = curves(&h); + assert!(rect.iter().all(|c| !left.contains(c)), "{:?}", h.app.status); + assert!(h.app.session.sketch_clipboard.is_some()); + clipboard_event(&mut h, egui::Event::Paste("x".into()), Some([10.0, 5.0])); + assert_eq!(curves(&h).len(), before.len(), "{:?}", h.app.status); + // The context menu offers Cut too. + h.step(&json!({"click": {"sketch": [10, 0]}, "button": "right"})).unwrap(); + let menu = h.call("ui.menu", json!({})); + let labels: Vec<&str> = menu["result"]["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).collect(); + assert!(labels.contains(&"Cut"), "{labels:?}"); +} diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index 39f4de18..5064292b 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -361,3 +361,36 @@ fn symmetric_through_all_handle_drag_switches_to_total_distance() { let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); assert!((bounds.min.z + 60.0).abs() < 1e-7 && (bounds.max.z - 60.0).abs() < 1e-7); } + +/// Inspect each held frame, not just the settled end of the drag: the displayed arrow must +/// be at the distance the pointer just wrote, rather than the previous frame's distance. +#[test] +fn extrusion_arrow_tracks_the_current_drag_frame() { + let mut h = make(json!({"distance":10})); + open(&mut h); + let at = h.at(&json!({"handle":"arrow"})).unwrap(); + let at = egui::pos2(at[0] as f32, at[1] as f32); + h.app.synthetic_mods.ctrl = true; + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.frame(); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: h.app.synthetic_mods, + }); + h.frame(); + for dy in [-20.0, -40.0, -60.0] { + h.app.synthetic.push(egui::Event::PointerMoved(at + egui::vec2(0.0, dy))); + h.frame(); + let d = h.app.dialog.as_ref().unwrap(); + let crate::dialogs::Kind::Extrude { distance, .. } = &d.kind else { panic!("extrude") }; + let distance = h.app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length).unwrap(); + assert!(distance > 10.0); + let (base, normal) = crate::canvas::anchor(&h.app, d).unwrap(); + let world = base + normal.unwrap() * distance; + let expected = h.at(&json!({"world":[world.x, world.y, world.z]})).unwrap(); + let drawn = h.at(&json!({"handle":"arrow"})).unwrap(); + assert!((drawn[0] - expected[0]).hypot(drawn[1] - expected[1]) < 0.1, "arrow {drawn:?} trails current value {expected:?}"); + } +} diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 7f88cd2a..6c37a472 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -95,6 +95,41 @@ struct Running { partial_rx: Option>, /// The picture shown is of the first this many features (`None`: the saved preview). built: Option, + /// A STEP file read a piece per frame on this thread (the web app), instead of on a worker. + staged: Option, +} + +/// A STEP import done a piece per frame, for the web app: it has one thread, which must keep +/// drawing (and offer Cancel) while a large file comes in. The file is parsed in one frame, +/// then its solids are converted a few at a time, each frame for about [`STAGED_FRAME_MS`]; +/// the import is then cached, and the command that inserts or opens the file finds it read. +struct Staged { + tx: Sender, + session: Option, + command: &'static str, + params: Value, + reading: Option, + /// The command's result once it has run; then the bodies' meshes are made, a few a frame, + /// so the first frame that draws the design doesn't make them all at once. + ran: Option, + /// Bodies meshed so far, and their triangles. + warmed: usize, + triangles: usize, + /// The overlay has been drawn once (the first frame does no work, so it shows at once). + shown: bool, +} + +/// Designs up to this many triangles get a thumbnail (a picture of more takes a while). +const STAGED_THUMBNAIL_TRIANGLES: usize = 1_000_000; + +/// How long a frame spends on a staged STEP import. +const STAGED_FRAME_MS: f64 = 30.0; + +/// Files read in stages: STEP (not IGES, which is first restated as STEP) to insert or open. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn staged_kind(request: &Request) -> bool { + let p = &request.path; + matches!(request.kind, Kind::Insert | Kind::Open) && solidcraft_engine::io::is_step_path(p) && !solidcraft_engine::io::is_iges_path(p) } impl Running { @@ -147,6 +182,11 @@ pub fn begin(app: &mut SolveApp, path: &str, insert: bool) { pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> { #[cfg(target_arch = "wasm32")] { + if staged_kind(&request) { + app.file_load.queued.push_back(request); + start_staged(app); + return Ok(()); + } let r = match &request.kind { Kind::Insert => { app.insert_path(&request.path); @@ -332,6 +372,7 @@ fn start(app: &mut SolveApp) { preview: None, partial_rx, built: None, + staged: None, }) } Err(e) => { @@ -342,6 +383,139 @@ fn start(app: &mut SolveApp) { } } +/// Start the next queued STEP file as a [`Staged`] import (see [`step_staged`]). +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn start_staged(app: &mut SolveApp) { + if app.file_load.running.is_some() { + return; + } + let Some(request) = app.file_load.queued.pop_front() else { return }; + let insert = request.insert(); + let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() }; + let mut session = if insert { app.session.scratch() } else { Session::default() }; + if insert { + session.selection = app.session.selection.clone(); + } + let (command, params) = request.command(); + let (tx, rx) = std::sync::mpsc::channel(); + let progress = Arc::new(Progress::default()); + #[cfg(test)] + if let Some(i) = app.file_load.hold_at.take() { + progress.hold_at(i); + } + app.file_load.running = Some(Running { + base: app.session.doc.clone(), + base_revision: app.session.revision, + before, + rx, + phase: Arc::new(AtomicU8::new(0)), + progress, + preview_rx: None, + preview: None, + partial_rx: None, + built: None, + staged: Some(Staged { tx, session: Some(session), command, params, reading: None, ran: None, warmed: 0, triangles: 0, shown: false }), + request, + }); +} + +/// One frame of a staged STEP import: parse the file, or convert solids for a frame's worth of +/// time, or, once every solid is in, run the command (which finds the file read) and hand its +/// result to [`tick`] as a worker would. +fn step_staged(running: &mut Running) { + let Some(staged) = running.staged.as_mut() else { return }; + let progress = running.progress.clone(); + let scope = running.request.scope.clone(); + let path = running.request.path.clone(); + let send = |staged: &mut Staged, job: Job| { + let _ = staged.tx.send(job); + staged.session = None; + staged.reading = None; + }; + if staged.session.is_none() { + return; + } + if progress.is_cancelled() { + send(staged, Err("file operation cancelled".into())); + return; + } + if !staged.shown { + staged.shown = true; + return; + } + let began = web_time::Instant::now(); + let in_budget = || began.elapsed().as_secs_f64() * 1000.0 < STAGED_FRAME_MS; + if staged.ran.is_some() { + let Some(session) = staged.session.as_ref() else { return }; + let world = session.world_state(); + while staged.warmed < world.bodies.len() && in_budget() { + if let Some(body) = world.bodies.get(staged.warmed) { + staged.triangles += body.mesh().triangles.len(); + } + staged.warmed += 1; + } + if staged.warmed < world.bodies.len() { + return; + } + let (Some(session), Some(result)) = (staged.session.take(), staged.ran.take()) else { return }; + let thumbnail = staged.command == "doc.open" && staged.triangles <= STAGED_THUMBNAIL_TRIANGLES; + let image = if thumbnail { crate::home::remember(&session) } else { None }; + send(staged, Ok(Made { session, result, image })); + return; + } + let Some(reading) = staged.reading.as_mut() else { + // Parse and walk the file. The reading keeps the watch: its steps report progress and + // stop on Cancel. + let read = solidcraft_engine::io::vfs::scoped(scope, || { + let bytes = solidcraft_engine::io::vfs::read(&path, solidcraft_engine::io::MAX_STEP_BYTES).map_err(|e| format!("{path}: {e}"))?; + // The same text the import command makes of the bytes, so it finds this reading. + let text = String::from_utf8_lossy(&bytes).into_owned(); + solidcraft_engine::doc::progress::watch(progress.clone(), || solidcraft_engine::kernel::StepReading::new(&text)) + .map_err(|e| format!("{path}: {e}")) + }); + match read { + Ok(reading) => staged.reading = Some(reading), + Err(e) => send(staged, Err(e)), + } + return; + }; + while in_budget() { + if !reading.step() { + break; + } + } + let (done, total) = reading.progress(); + if done < total && !progress.is_cancelled() { + return; + } + let Some(reading) = staged.reading.take() else { return }; + // Held until the command has read the file from the cache. + let import = reading.finish_shared(); + let import = match import { + _ if progress.is_cancelled() => Err("file operation cancelled".to_string()), + Ok(import) => Ok(import), + Err(e) => Err(format!("{path}: {e}")), + }; + let (Some(mut session), Ok(import)) = (staged.session.take(), import.clone()) else { + send(staged, Err(import.err().unwrap_or_else(|| "file operation cancelled".into()))); + return; + }; + let (command, params) = (staged.command, staged.params.clone()); + let result = solidcraft_engine::io::vfs::scoped(running.request.scope.clone(), || { + solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, ¶ms)) + }); + drop(import); + match result { + Ok(result) => { + // Next frames: the meshes ("Preparing the view"). + running.phase.store(1, Ordering::Relaxed); + staged.session = Some(session); + staged.ran = Some(result); + } + Err(e) => send(staged, Err(e.to_string())), + } +} + /// A small picture of the bodies built so far (their display meshes, made here and kept for /// the finished model), or `None` when there is nothing to show yet, too much to draw, or the /// load ended meanwhile (`stop`). @@ -520,6 +694,9 @@ fn compute( } pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { + if let Some(running) = &mut app.file_load.running { + step_staged(running); + } if let Some(running) = &mut app.file_load.running { if !running.cancelled() && running.matches(&app.session) { if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { @@ -563,7 +740,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { app.fit_view(); // A caller that said which way is up has been answered: no offer to stand the part up. if (request.kind == Kind::Open || request.insert()) && !request.options.contains_key("up") { - crate::stand_up::offer(app, &request.path, &running.before); + crate::stand_up::offer(app, &request.path, &running.before, Some(&made.result)); } let warnings = made.result.get("warnings").and_then(Value::as_array); if let Kind::Recover { .. } = request.kind { @@ -598,6 +775,8 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { } #[cfg(not(target_arch = "wasm32"))] start(app); + #[cfg(target_arch = "wasm32")] + start_staged(app); if app.file_load.busy() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } @@ -746,6 +925,7 @@ mod tests { preview: None, partial_rx: None, built: None, + staged: None, }); if change_document { app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap(); @@ -810,6 +990,7 @@ mod tests { preview: None, partial_rx: None, built: None, + staged: None, }); tick(&mut app, &egui::Context::default()); assert!(Arc::ptr_eq(&app.session.doc, &old)); @@ -835,6 +1016,7 @@ mod tests { preview: None, partial_rx: None, built: None, + staged: None, }); assert!(!app.file_load.busy()); assert!(app.file_load.running.is_some()); @@ -845,4 +1027,61 @@ mod tests { assert!(app.file_load.running.is_none()); assert!(Arc::ptr_eq(&old, &app.session.doc)); } + + /// #1376: the web app reads a STEP file a piece per frame. The first frame only shows the + /// overlay, the next parses, and the solids follow, frame by frame, until the insert + /// lands as a worker's would; Cancel between frames leaves the design as it was. + #[test] + fn a_staged_step_import_lands_over_frames_and_cancels_between_them() { + let dir = std::env::temp_dir().join(format!("solidcraft-staged-step-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("boxes.step").to_string_lossy().into_owned(); + let mut part = Session::default(); + for i in 0..6 { + part.execute("solid.box", &serde_json::json!({"length": 10, "width": 10, "height": 10, "corner": [i * 20, 0, 0]})).unwrap(); + } + part.execute("file.export", &serde_json::json!({"path": path})).unwrap(); + let ctx = egui::Context::default(); + let mut app = SolveApp::new(Session::default(), Default::default()); + let bodies = |app: &SolveApp| app.session.world_state().bodies.len(); + assert!(staged_kind(&Request::new(&path, Kind::Insert)) && staged_kind(&Request::new(&path, Kind::Open))); + assert!(!staged_kind(&Request::new("part.igs", Kind::Insert)) && !staged_kind(&Request::new("part.solidcraft", Kind::Open))); + app.file_load.queued.push_back(Request::new(&path, Kind::Insert)); + start_staged(&mut app); + tick(&mut app, &ctx); + assert!(app.file_load.busy() && bodies(&app) == 0, "the first frame only shows the overlay"); + tick(&mut app, &ctx); + let reading = app.file_load.running.as_ref().and_then(|r| r.staged.as_ref()).is_some_and(|s| s.reading.is_some()); + assert!(reading, "the next frame parses the file"); + let mut frames = 2; + while app.file_load.running.is_some() && frames < 1000 { + tick(&mut app, &ctx); + frames += 1; + } + assert_eq!(bodies(&app), 6, "{:?}", app.status); + assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Insert done")), "{:?}", app.status); + // Cancelled after the parse: nothing is inserted, and nothing waits. + let before = app.session.doc.clone(); + app.file_load.queued.push_back(Request::new(&path, Kind::Insert)); + start_staged(&mut app); + tick(&mut app, &ctx); + tick(&mut app, &ctx); + app.file_load.cancel(); + tick(&mut app, &ctx); + assert!(app.file_load.running.is_none() && !app.file_load.busy()); + assert!(Arc::ptr_eq(&before, &app.session.doc)); + assert_eq!(bodies(&app), 6); + // A file that isn't STEP fails like the worker's would, and the design stays. + let junk = dir.join("junk.step").to_string_lossy().into_owned(); + std::fs::write(&junk, "not a step file").unwrap(); + app.file_load.queued.push_back(Request::new(&junk, Kind::Insert)); + start_staged(&mut app); + for _ in 0..4 { + tick(&mut app, &ctx); + } + assert!(app.file_load.running.is_none()); + assert!(app.status.as_ref().is_some_and(|s| s.2 && s.0.contains("junk.step")), "{:?}", app.status); + assert!(Arc::ptr_eq(&before, &app.session.doc)); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index 3e323a2c..87f4d838 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -92,6 +92,63 @@ pub fn docked_on<'a>(title: impl Into, id: egui::Id, anchor: Pos2, m .max_height(max_height.max(120.0)) } +/// How far the open docked dialog titled `title` was dragged from its docked place (#1335); +/// zero for a dialog that was not dragged, or once another dialog opens. +pub fn dock_offset(ctx: &egui::Context, title: &str) -> egui::Vec2 { + ctx.data(|d| d.get_temp::<(String, egui::Vec2)>(egui::Id::new("sc_dock_offset"))) + .filter(|(t, _)| t == title) + .map(|(_, v)| v) + .unwrap_or(egui::Vec2::ZERO) +} + +/// Forget a dragged dialog's place (it closed): the next one opens docked. +pub fn reset_dock_offset(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::<(String, egui::Vec2)>(egui::Id::new("sc_dock_offset"))); +} + +/// A docked dialog's anchor moved by `offset`, kept so its docked corner stays in `vp` with +/// room for its title band. +pub fn offset_anchor(docked: Pos2, offset: egui::Vec2, vp: egui::Rect, side: Side) -> Pos2 { + let p = docked + offset; + let (lo, hi) = match side { + Side::Right => (vp.left() + DIALOG_MIN_W, vp.right()), + Side::Left => (vp.left(), vp.right() - DIALOG_MIN_W), + }; + let x = if lo <= hi { p.x.clamp(lo, hi) } else { docked.x }; + let y = if vp.top() <= vp.bottom() - 40.0 { p.y.clamp(vp.top(), vp.bottom() - 40.0) } else { docked.y }; + egui::pos2(x, y) +} + +/// Let `band` (a docked dialog's title band) drag the dialog: the drag adds to its offset +/// `moved` (as last frame left it; `applied` of it is where the dialog is now, after keeping +/// it on screen). Called before the band's buttons are added, so they keep their clicks. +pub fn drag_dock(ui: &Ui, band: egui::Rect, title: &str, moved: egui::Vec2, applied: egui::Vec2) { + let response = ui.interact(band, ui.id().with("sc_dock_drag"), egui::Sense::drag()); + crate::scenario::publish_handle("dialog:title", band.center()); + // The drag runs from where the press started, so no movement is lost while egui decides + // it is a drag, nor on the frame it ends. + let base = egui::Id::new("sc_dock_drag_base"); + if response.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + { + ui.ctx().data_mut(|d| d.insert_temp(base, (applied, origin))); + } + if response.dragged() || response.drag_stopped() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let start = ui.ctx().data(|d| d.get_temp::<(egui::Vec2, Pos2)>(base)); + let to = match (start, ui.input(|i| i.pointer.latest_pos())) { + (Some((start, from)), Some(now)) => start + (now - from), + _ => applied + response.drag_delta(), + }; + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_dock_offset"), (title.to_string(), to))); + } else if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else if moved != applied { + // Kept on screen: remember where it is, not where it was dragged off to. + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_dock_offset"), (title.to_string(), applied))); + } +} + /// A floating window, centred on the screen unless the caller places it, shrink-wrapped to its /// content up to its width limit. pub fn window<'a>(ctx: &egui::Context, title: impl Into, width: Width) -> egui::Window<'a> { @@ -108,6 +165,22 @@ pub fn window<'a>(ctx: &egui::Context, title: impl Into, width: Widt .max_width(max) } +/// Add a button drawn with its own outline (`Button::stroke`) of `width`, keeping the same size +/// in every state (#1335). egui sizes a button's padding as the spacing minus the *theme's* +/// outline width for the state (none while idle, 1 px hovered), which keeps a themed button's +/// size steady, but a button with its own outline then lost a pixel of padding each side on +/// hover: it shrank, and the panel around it with it. +pub fn add_outlined(ui: &mut Ui, button: egui::Button<'_>, width: f32) -> egui::Response { + ui.scope(|ui| { + let w = &mut ui.visuals_mut().widgets; + for state in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + state.bg_stroke.width = width; + } + ui.add(button) + }) + .inner +} + /// Floating model controls share the report's read-only drawing boundary with windows. pub fn area(id: egui::Id) -> egui::Area { egui::Area::new(id).enabled(!crate::issue_report::cad_input_blocked()) @@ -213,6 +286,43 @@ mod tests { assert!(h.app.dialog.is_none()); } } + /// #1335: a dialog keeps its size while its Select chip is hovered, and its title band + /// drags it; it opens docked again after it closes. + #[test] + fn a_docked_dialog_keeps_its_size_under_the_pointer_and_moves_by_its_title() { + use crate::scenario::Harness; + use serde_json::json; + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400.0,"height":900.0})); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.frames(6); + let rest = h.app.viewport.dialog_rect.unwrap(); + let chip = crate::scenario::handle_at("dialog:Plane").unwrap(); + h.call("ui.move", json!({"x": chip.x, "y": chip.y})); + h.frames(6); + let hovered = h.app.viewport.dialog_rect.unwrap(); + assert!((hovered.size() - rest.size()).length() < 0.01, "hovering resized the dialog: {rest:?} -> {hovered:?}"); + // Dragged by its title text. + let title = h.widget("CREATE SKETCH").map(|[x, y]| egui::pos2(x as f32, y as f32)).unwrap(); + h.call("ui.drag", json!({"x0": title.x, "y0": title.y, "x1": title.x - 300.0, "y1": title.y + 120.0, "steps": 8})); + h.frames(4); + let moved = h.app.viewport.dialog_rect.unwrap(); + assert!((moved.min - rest.min - egui::vec2(-300.0, 120.0)).length() < 2.0, "{rest:?} -> {moved:?}"); + assert!(h.app.dialog.is_some(), "dragging the title keeps the dialog open"); + // Dragged far off, it stays on screen. + let title = crate::scenario::handle_at("dialog:title").unwrap(); + h.call("ui.drag", json!({"x0": title.x, "y0": title.y, "x1": title.x + 3000.0, "y1": title.y + 3000.0, "steps": 8})); + h.frames(4); + let viewport = h.app.viewport.rect.unwrap(); + assert!(viewport.contains(h.app.viewport.dialog_rect.unwrap().min)); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(2); + assert!(h.app.dialog.is_none()); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.frames(6); + assert!((h.app.viewport.dialog_rect.unwrap().min - rest.min).length() < 0.01, "a new dialog opens docked"); + } + /// Windows are made in this file only, so none can skip the width limits. #[test] fn windows_come_from_the_frame_helper() { diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 531cb3b0..12ec2b88 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -244,7 +244,8 @@ pub struct ViewportCallback { /// and the depth bias. pub eye: [f32; 4], /// Supersampling (#1288): draw the frame this many times the viewport's pixels in each - /// direction and filter it down (1: off). + /// direction and filter it down (1: off). Under 1 the frame is drawn smaller and filtered up + /// (the product preview past its pixel budget, #1464). pub ssaa: f32, } @@ -295,6 +296,12 @@ fn joined<'a>(blocks: impl Iterator>, own: Option Vec { + // Chunks end where a body's own plan says (or when full), so a body whose plan changes + // (an edit, or a refinement as the view zooms in, #1320) re-evaluates only its own + // chunk, not the whole scene. let mut chunks: Vec> = Vec::new(); let mut filled = 0usize; + let mut open = false; for b in bodies { let n = b.plan.vertex_count(); match chunks.last_mut() { - Some(c) if filled + n <= solidcraft_gpu::MAX_BATCH => c.push(b), + Some(c) if open && filled + n <= solidcraft_gpu::MAX_BATCH => c.push(b), _ => { chunks.push(vec![b]); filled = 0; } } filled += n; + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&(Arc::as_ptr(&b.plan) as usize), &mut h); + open = !std::hash::Hasher::finish(&h).is_multiple_of(CHUNK_SPLIT) && chunks.last().is_some_and(|c| c.len() < MAX_CHUNK_BODIES); } let key = |c: &[&PlannedBody]| c.iter().map(|b| Arc::as_ptr(&b.plan) as usize).collect::>(); self.resident.retain(|k, _| chunks.iter().any(|c| key(c) == *k)); @@ -509,9 +523,12 @@ const SS_BUDGET: u64 = 384 << 20; /// The size (px) to draw a `size` viewport supersampled `factor` times in each direction: /// within the device's largest texture side (#869) and the memory budget (at `samples` per pixel, /// four bytes of colour and four of depth each). `None` when that leaves under 1.25×: the frame -/// is drawn as usual. +/// is drawn as usual. A `factor` under 1 draws it that much smaller, to be filtered up. pub(crate) fn supersampled_size(size: [f32; 2], factor: f32, max_side: u32, samples: u32) -> Option<[u32; 2]> { let [w, h] = size; + if w >= 1.0 && h >= 1.0 && factor > 0.0 && factor < 1.0 { + return Some([((w * factor).floor() as u32).clamp(1, max_side.max(1)), ((h * factor).floor() as u32).clamp(1, max_side.max(1))]); + } if !(w >= 1.0 && h >= 1.0 && factor.is_finite() && factor > 1.0) { return None; } @@ -1126,7 +1143,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.quads.push((img.id, b)); } } - res.ss_ready = self.ssaa > 1.0 && res.supersample(device, encoder, self); + res.ss_ready = self.ssaa != 1.0 && res.supersample(device, encoder, self); Vec::new() } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index e3858ea9..b2b1d237 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -362,7 +362,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ui.add_space(16.0); ui.horizontal(|ui| { let big = |ui: &mut egui::Ui, icon: &str, label: &str, primary: bool| -> bool { - let (r, resp) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::click()); + // The label sets the width: a translation longer than the English one widens + // the button instead of running past its edge. + let ink_text = if primary { Color32::WHITE } else { t.text }; + let galley = ui.painter().layout_no_wrap(label.to_owned(), egui::FontId::proportional(15.0), ink_text); + let width = (56.0 + galley.size().x + 20.0).max(170.0); + let (r, resp) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click()); let fill = if primary { t.accent } else if resp.hovered() { @@ -380,20 +385,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { t.icon_fill, ink, ); - ui.painter().text( - r.left_center() + vec2(56.0, 0.0), - egui::Align2::LEFT_CENTER, - label, - egui::FontId::proportional(15.0), - if primary { Color32::WHITE } else { t.text }, - ); + ui.painter().galley(r.left_center() + vec2(56.0, -galley.size().y / 2.0), galley, ink_text); resp.clicked() }; - if big(ui, "new", "New Design", true) { + if big(ui, "new", app.tr("ui.new_design"), true) { action = Some(Box::new(|app: &mut SolveApp| crate::documents::new_design(app))); } ui.add_space(10.0); - if big(ui, "open", "Open…", false) { + if big(ui, "open", app.tr("home.open"), false) { action = Some(Box::new(|app: &mut SolveApp| { app.pick_open(|app, path| { if let Some(path) = path { @@ -406,19 +405,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }); ui.add_space(24.0); ui.horizontal_wrapped(|ui| { - ui.hyperlink_to("Documentation", crate::help::DOCS_URL); - ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solidcraft/issues/new"); - ui.hyperlink_to("What's new", "https://github.com/bherbruck/solidcraft/releases/latest"); - if ui.link("Keyboard Shortcuts").clicked() { + ui.hyperlink_to(app.tr("home.documentation"), crate::help::DOCS_URL); + ui.hyperlink_to(app.tr("home.report_issue"), "https://github.com/bherbruck/solidcraft/issues/new"); + ui.hyperlink_to(app.tr("home.whats_new"), "https://github.com/bherbruck/solidcraft/releases/latest"); + if ui.link(app.tr("ui.keyboard_shortcuts")).clicked() { app.keymap.open = true; app.keymap.read_only = true; } - if ui.link("Preferences").clicked() { + if ui.link(app.tr("ui.preferences")).clicked() { crate::prefs::open_at(app, 0); } }); ui.add_space(16.0); - section(ui, "Recent"); + section(ui, app.tr("home.recent")); if recent.is_empty() { ui.label(RichText::new(crate::i18n::tr_label("Designs you open or save appear here.")).color(t.text_dim)); } else { @@ -428,26 +427,27 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()); let missing = !solidcraft_engine::io::vfs::exists(p); let mut sub = if missing { - "missing".to_string() + app.tr("home.missing").to_string() } else if p.starts_with("/opfs/") { // The web build keeps designs in the browser's own storage. - "in this browser".to_string() + app.tr("home.in_this_browser").to_string() } else { p.clone() }; if let Some(time) = app.home.opened_at.get(p) { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time); let seconds = now.saturating_sub(*time); - let ago = if seconds < 60 { - "just now".to_string() + let opened = if seconds < 60 { + app.tr("home.opened_just_now").to_string() } else if seconds < 3600 { - format!("{} min ago", seconds / 60) + crate::i18n::format("home.opened_minutes_ago", &[("count", &(seconds / 60).to_string())]) } else if seconds < 86400 { - format!("{} h ago", seconds / 3600) + crate::i18n::format("home.opened_hours_ago", &[("count", &(seconds / 3600).to_string())]) } else { - format!("{} days ago", seconds / 86400) + crate::i18n::format("home.opened_days_ago", &[("count", &(seconds / 86400).to_string())]) }; - sub.push_str(&format!("\nOpened {ago}")); + sub.push('\n'); + sub.push_str(&opened); } let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing) .on_hover_text(p.as_str()); @@ -472,17 +472,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }); } ui.add_space(24.0); - section(ui, "Samples"); + section(ui, app.tr("home.samples")); grid(ui, samples.len(), |ui, i| { let Some(s) = samples.get(i) else { return }; let key = format!("sample:{i}"); let busy = app.home.wanted == Some(i); - let sub = if busy { "opening…" } else { s.about }; + // The engine's sample table holds English text; the opened design keeps it as its name. + let about = app.tr_label(s.about); + let sub = if busy { app.tr("home.opening") } else { about }; let tex = match app.home.thumbs.get(&key) { Some(t) => Pic::Ready(t.as_ref()), None => Pic::Icon("box"), }; - if card(ui, tex, s.name, sub, false).on_hover_text(s.about).clicked() { + if card(ui, tex, app.tr_label(s.name), sub, false).on_hover_text(about).clicked() { action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i))); } }); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 28c548c0..8d802745 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -267,6 +267,17 @@ mod tests { ); } + /// The start page shows each built-in sample's name and description through `label()`, so a + /// sample added to the engine without English catalog entries would stay English everywhere. + #[test] + fn every_sample_name_and_description_is_in_the_english_catalog() { + let texts = english_texts(); + let samples = solidcraft_engine::sample::samples(); + let missing: Vec<&str> = samples.iter().flat_map(|s| [s.name, s.about]).filter(|text| !texts.contains(text)).collect(); + assert!(missing.is_empty(), "sample texts absent from en.json: {missing:?}"); + assert_eq!(Locale::for_language("ja").label("Sample Plate"), "サンプル プレート"); + } + /// The whole engine registry now reaches the catalogs. Sample command labels from areas that /// had no entry at all before (sheet metal, inspect, joints, motion) must resolve to their /// translation rather than echo the English source text — `label()` returns its argument when @@ -317,6 +328,7 @@ mod tests { include_str!("browser.rs"), include_str!("sketch_palette.rs"), include_str!("design_properties.rs"), + include_str!("home.rs"), ] { for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] { for tail in source.split(call).skip(1) { diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs index 6a477010..039b1c5b 100644 --- a/crates/ui-egui/src/i18n_ui_tests.rs +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -84,6 +84,99 @@ fn window_chrome_renders_in_the_active_language() { } } +/// The start page's two big buttons had a fixed width, so a translation longer than the English +/// label ran past the edge ("Nouvelle conception"). Every registered language's label must stay +/// inside its button. +#[test] +fn home_page_buttons_fit_their_translated_labels() { + fn collect(shape: &egui::Shape, rects: &mut Vec, texts: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::Shape::Rect(r) => rects.push(r.rect), + egui::Shape::Text(t) => texts.push((t.galley.job.text.clone(), egui::Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, rects, texts)), + _ => {} + } + } + for catalog in crate::i18n::LOCALES { + let mut h = Harness::new(); + h.app.preferences.language = catalog.id.into(); + let rev = h.app.session.revision; + h.app.home.show(rev); + h.frames(2); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| h.app.ui(ui)); + out.textures_delta.clear(); + let (mut rects, mut texts) = (Vec::new(), Vec::new()); + out.shapes.iter().for_each(|shape| collect(&shape.shape, &mut rects, &mut texts)); + let locale = crate::i18n::Locale::for_language(catalog.id); + for id in ["ui.new_design", "home.open"] { + let label = locale.text(id); + let (_, text) = texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{}: {label:?} is not painted", catalog.id)); + let button = rects + .iter() + .filter(|r| (r.height() - 64.0).abs() < 1.0 && r.contains(text.left_center())) + .min_by(|a, b| a.area().total_cmp(&b.area())) + .unwrap_or_else(|| panic!("{}: no button around {label:?}", catalog.id)); + assert!(text.right() <= button.right(), "{}: {label:?} ends at {} past its button's edge {}", catalog.id, text.right(), button.right()); + } + } +} + +/// The start page is the first thing a user sees. Its buttons, links, headings and cards were +/// English literals, mostly painted rather than laid out as widgets, so check the text actually +/// painted in each complete language: a broken message id falls back to English silently. +#[test] +fn home_page_renders_in_the_active_language() { + fn painted(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| painted(s, out)), + _ => {} + } + } + let cases = [ + ( + "ja", + [ + "新規デザイン", + "開く…", + "ドキュメント", + "問題を報告", + "新機能", + "キーボードショートカット", + "環境設定", + "最近使用した項目", + "サンプル", + "サンプル プレート", + ], + "見つかりません\n2 時間前に開きました", + ), + ("zh", ["新建设计", "打开…", "文档", "报告问题", "新功能", "键盘快捷键", "首选项", "最近使用", "示例", "示例板"], "文件不存在\n2 小时前打开"), + ]; + for (lang, words, missing_recent) in cases { + let mut h = Harness::new(); + h.app.preferences.language = lang.into(); + let gone = h.dir.join("gone.solidcraft").to_string_lossy().into_owned(); + h.app.home.add_recent(&gone); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + h.app.home.opened_at.insert(gone, now - 2 * 3600); + let rev = h.app.session.revision; + h.app.home.show(rev); + h.frames(2); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| h.app.ui(ui)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + out.shapes.iter().for_each(|shape| painted(&shape.shape, &mut texts)); + for word in words.into_iter().chain([missing_recent]) { + assert!(texts.iter().any(|t| t == word), "{lang}: {word:?} must be painted, got {texts:?}"); + } + for english in ["New Design", "Open…", "Documentation", "Report an Issue", "What's new", "Recent", "Samples", "Sample Plate"] { + assert!(!texts.iter().any(|t| t == english), "{lang}: {english:?} is still painted in English"); + } + } +} + /// The command palette's hint embeds a JSON example, so its braces must not be mistaken for a /// placeholder name by the catalog's parser, and the example must survive interpolation whole. #[test] diff --git a/crates/ui-egui/src/install.rs b/crates/ui-egui/src/install.rs new file mode 100644 index 00000000..5314e3d1 --- /dev/null +++ b/crates/ui-egui/src/install.rs @@ -0,0 +1,210 @@ +//! Install as an app (the browser build). A browser that can install the web app (a PWA: its own +//! window and launcher icon, working offline) offers it to the page; while it does, the File menu +//! has Install app…, which shows the browser's own install prompt. An installed app, or the app +//! running installed, gets no offer, so the item is gone. The host keeps the offer +//! (`Services::install_app`); desktop builds have none. +//! +//! Safari installs web apps too (Add to Home Screen on iPhone and iPad, Add to Dock on a Mac) but +//! offers no prompt a page can show. There the item opens a window with the steps. + +use egui::RichText; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// How the browser installs the app. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Offer { + /// Its own install prompt (`InstallApp::prompt`). + Prompt, + /// No prompt a page can show: SolidCraft shows these steps (Safari). + Steps(Steps), +} + +/// Safari's steps for installing a web app. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Steps { + /// iPhone and iPad: Share ▸ Add to Home Screen. + HomeScreen, + /// A Mac (Safari 17 or later): File ▸ Add to Dock. + Dock, +} + +impl Steps { + /// The steps' message ids, in order, and the one that says what follows. + fn messages(self) -> (&'static [&'static str], &'static str) { + match self { + Steps::HomeScreen => (&["install.home_screen_1", "install.home_screen_2", "install.home_screen_3"], "install.home_screen_after"), + Steps::Dock => (&["install.dock_1", "install.dock_2"], "install.dock_after"), + } + } +} + +/// The browser's offer to install the app, kept by the host. +pub struct InstallApp { + /// What the browser offers now; None: nothing (installed, or it can't install). + pub offer: Box Option>, + /// Show the browser's install prompt (`Offer::Prompt`). Browsers only show it right after a + /// click. + pub prompt: Box, +} + +/// Safari's steps for this browser, from its `navigator.userAgent`, `navigator.vendor` and +/// `navigator.maxTouchPoints`; None: not Safari, or a Safari that can't install web apps. +/// Other iOS browsers are WebKit too (vendor Apple) but have no `Version/` and name themselves. +pub fn safari_steps(user_agent: &str, vendor: &str, touch_points: i32) -> Option { + let other = ["CriOS/", "FxiOS/", "EdgiOS/", "OPiOS/", "OPT/"]; + let safari = vendor.starts_with("Apple") && user_agent.contains("Safari/") && !other.iter().any(|o| user_agent.contains(o)); + if !safari { + return None; + } + let version = user_agent.split("Version/").nth(1)?; + // iPadOS Safari presents itself as a Mac; only its touch screen tells them apart. + let mobile = ["iPhone", "iPad", "iPod"].iter().any(|d| user_agent.contains(d)); + if mobile || (user_agent.contains("Macintosh") && touch_points > 1) { + return Some(Steps::HomeScreen); + } + let major: u32 = version.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()?; + (user_agent.contains("Macintosh") && major >= 17).then_some(Steps::Dock) +} + +/// What the browser offers now (None: nothing, so File has no Install app… item). +pub fn offered(app: &SolveApp) -> Option { + app.services.install_app.as_ref().and_then(|i| (i.offer)()) +} + +/// File ▸ Install app…: the browser's own prompt (browsers only show it right after a click), or +/// Safari's steps. +pub fn start(app: &mut SolveApp, offer: Offer) { + match offer { + Offer::Prompt => { + if let Some(install) = &app.services.install_app { + (install.prompt)(); + } + } + Offer::Steps(steps) => app.install_steps = Some(steps), + } +} + +/// The window with Safari's steps, while open. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(steps) = app.install_steps else { return }; + let t = Tokens::get(); + let (lines, after) = steps.messages(); + let mut open = true; + let mut done = false; + crate::frame::window(ctx, crate::i18n::tr("install.title"), crate::frame::Width::Normal) + .id(egui::Id::new("sc_install_steps")) + .open(&mut open) + .resizable(false) + .show(ctx, |ui| { + ui.label(crate::i18n::tr("install.steps_intro")); + ui.add_space(4.0); + for (i, id) in lines.iter().enumerate() { + ui.label(format!("{}. {}", i + 1, crate::i18n::tr(id))); + } + ui.add_space(4.0); + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(after)).color(t.text_dim)).wrap()); + ui.add_space(6.0); + if ui.button(crate::i18n::tr("install.done")).clicked() { + done = true; + } + }); + if !open || done { + app.install_steps = None; + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use serde_json::json; + + use super::*; + use crate::scenario::{Harness, handle_at}; + + const ITEM: &str = "install.menu"; + + fn host(h: &mut Harness, offer: Rc>>, prompts: Rc>) { + h.app.services.install_app = + Some(InstallApp { offer: Box::new(move || offer.get()), prompt: Box::new(move || prompts.set(prompts.get() + 1)) }); + } + + /// Open the File menu and look for Install app…. + fn file_menu_item(h: &mut Harness) -> bool { + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true)); + h.frames(2); + h.widget(crate::i18n::tr(ITEM)).is_some() + } + + #[test] + fn install_is_in_the_file_menu_only_while_offered_and_prompts_on_click() { + let offer = Rc::new(Cell::new(None)); + let prompts = Rc::new(Cell::new(0)); + let mut h = Harness::new(); + host(&mut h, offer.clone(), prompts.clone()); + assert!(!file_menu_item(&mut h), "no offer, no menu item"); + + offer.set(Some(Offer::Prompt)); + assert!(file_menu_item(&mut h), "offered: File has Install app…"); + assert_eq!(h.step(&json!({ "widget": crate::i18n::tr(ITEM) })), Ok(())); + assert_eq!(prompts.get(), 1, "a click shows the browser's prompt"); + assert_eq!(h.app.install_steps, None, "no steps window for a browser with a prompt"); + + offer.set(None); + assert!(!file_menu_item(&mut h), "installed: the item is gone"); + } + + #[test] + fn install_is_not_in_the_application_bar() { + let mut h = Harness::new(); + host(&mut h, Rc::new(Cell::new(Some(Offer::Prompt))), Rc::new(Cell::new(0))); + h.frames(2); + assert!(handle_at("titlebar:install").is_none()); + assert!(h.widget(crate::i18n::tr(ITEM)).is_none(), "only in the File menu"); + } + + #[test] + fn safari_shows_its_steps() { + let prompts = Rc::new(Cell::new(0)); + let mut h = Harness::new(); + host(&mut h, Rc::new(Cell::new(Some(Offer::Steps(Steps::Dock)))), prompts.clone()); + assert!(file_menu_item(&mut h)); + assert_eq!(h.step(&json!({ "widget": crate::i18n::tr(ITEM) })), Ok(())); + assert_eq!(h.app.install_steps, Some(Steps::Dock), "a click opens the steps"); + assert_eq!(prompts.get(), 0, "Safari has no prompt"); + h.frames(2); + assert_eq!(h.step(&json!({ "widget": crate::i18n::tr("install.done") })), Ok(())); + assert_eq!(h.app.install_steps, None, "Done closes the steps"); + } + + #[test] + fn no_install_item_without_the_host_service() { + let mut h = Harness::new(); + assert!(!file_menu_item(&mut h)); + } + + #[test] + fn safari_steps_by_device() { + const APPLE: &str = "Apple Computer, Inc."; + let iphone = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"; + let mac = + |v: &str| format!("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{v} Safari/605.1.15"); + assert_eq!(safari_steps(iphone, APPLE, 5), Some(Steps::HomeScreen)); + assert_eq!(safari_steps(&mac("18.5"), APPLE, 5), Some(Steps::HomeScreen), "an iPad presents itself as a Mac"); + assert_eq!(safari_steps(&mac("18.5"), APPLE, 0), Some(Steps::Dock)); + assert_eq!(safari_steps(&mac("17.0"), APPLE, 0), Some(Steps::Dock)); + assert_eq!(safari_steps(&mac("16.6"), APPLE, 0), None, "Add to Dock came with Safari 17"); + let chrome_ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.156 Mobile/15E148 Safari/604.1"; + assert_eq!(safari_steps(chrome_ios, APPLE, 5), None, "Chrome on iOS isn't Safari"); + let chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"; + assert_eq!(safari_steps(chrome, "Google Inc.", 0), None, "Chrome prompts instead"); + let firefox = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:144.0) Gecko/20100101 Firefox/144.0"; + assert_eq!(safari_steps(firefox, "", 0), None); + assert_eq!(safari_steps("", APPLE, 0), None, "hostile input is just not Safari"); + assert_eq!(safari_steps("Safari/ Version/99999999999999999999 Macintosh", APPLE, 0), None); + } +} diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index 30b6040c..2a6d70bb 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -598,13 +598,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.add(egui::Label::new(crate::i18n::tr_label("3. Click Submit new issue on GitHub to send your report.")).wrap()); ui.horizontal_wrapped(|ui| { let t = crate::theme::Tokens::get(); - if ui - .add( - egui::Button::new(RichText::new(crate::i18n::tr_label("Open GitHub draft")).strong().color(t.text)) - .fill(t.accent_soft) - .stroke(egui::Stroke::new(1.0, t.accent)), - ) - .clicked() + if crate::frame::add_outlined( + ui, + egui::Button::new(RichText::new(crate::i18n::tr_label("Open GitHub draft")).strong().color(t.text)) + .fill(t.accent_soft) + .stroke(egui::Stroke::new(1.0, t.accent)), + 1.0, + ) + .clicked() { if copy_needed { ctx.copy_text(draft.body.clone()); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index ef81b5ad..11dc230e 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -13,8 +13,25 @@ use crate::SolveApp; use crate::theme::Tokens; /// Keys the application handles before any command shortcut. -pub const RESERVED: &[&str] = - &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"]; +pub const RESERVED: &[&str] = &[ + "Escape", + "S", + "V", + "F2", + "F6", + "Delete", + "Backspace", + "Ctrl+Z", + "Ctrl+Y", + "Ctrl+S", + "Ctrl+N", + "Ctrl+O", + "Ctrl+Comma", + "Ctrl+Shift+A", + "Ctrl+C", + "Ctrl+X", + "Ctrl+V", +]; #[derive(Default)] pub struct Keymap { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6cea2e4d..b9eb20f9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] pub mod access; +pub mod agent_chat; pub mod agent_cursor; pub mod browser; pub mod canvas; @@ -38,6 +39,7 @@ pub mod dim_view; mod dimension_create_ui_tests; pub mod documents; pub mod drag_snap; +pub mod edit_clipboard; pub mod fonts; pub mod frame; pub mod gizmo; @@ -55,8 +57,10 @@ mod i18n_menu_tests; mod i18n_ui_tests; pub mod icons; pub mod inference; +pub mod install; pub mod issue_report; pub mod keymap; +mod material_card_review; #[cfg(test)] mod menu_tests; #[cfg(test)] @@ -90,10 +94,12 @@ mod file_picker; pub use file_picker::FilePickerFuture; #[cfg(test)] mod blend_arrow_tests; +pub(crate) mod display_lod; #[cfg(test)] mod file_load_ui_tests; #[cfg(test)] mod fillet_spline_handle_tests; +pub(crate) mod navigation; pub mod palette; pub mod params_dialog; pub mod prefs; @@ -243,6 +249,12 @@ pub struct Services { pub http_get: Option, /// HTTPS download to a file, with progress, for one-click updates (`update::install`). pub http_download: Option, + /// The browser's offer to install the web app (`install`); None: no Install button (desktop). + pub install_app: Option, + /// HTTP for the agent chat's API agents (OpenAI-compatible endpoints); None: no API agents. + pub agent_http: Option, + /// The system keychain, for API agents' keys; None: keys can't be stored. + pub secrets: Option>, } pub struct SolveApp { @@ -268,6 +280,7 @@ pub struct SolveApp { pub params: Option, /// The Design Properties window, when open. pub design_properties: Option, + material_card_review: Option, pub palette: palette::Palette, /// Context menus, rename box and Properties window. pub menu: context_menu::MenuState, @@ -285,6 +298,8 @@ pub struct SolveApp { pub crashes: crash_ui::CrashUi, /// The update check and its window (`update`). pub updater: update::Updater, + /// Safari's steps for installing the web app, while their window is open (`install`). + pub install_steps: Option, /// Toolbar buttons the user pinned, removed or reordered. pub toolbar_custom: toolbar_custom::ToolbarCustom, pub sketch_choices: sketch_families::Choices, @@ -311,6 +326,8 @@ pub struct SolveApp { pub recoverable: Vec, /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). pub stand_up: Option>, + /// The placement of the component that file came in as: the offer turns it. + pub stand_up_occurrence: Option, /// The anti-aliasing sample counts the graphics device supports (empty without a GPU). pub msaa_supported: Vec, /// Settings put back at start because they kept SolidCraft from starting, one line each, @@ -322,6 +339,9 @@ pub struct SolveApp { pub autosave_minutes: f64, /// The application bar is the window's title bar (Windows, Linux: no OS decorations). pub custom_titlebar: bool, + /// The OS hit test already offers the resize border ([`titlebar`]; the Windows desktop app), + /// so no egui resize zones are laid out. + pub native_resize_border: bool, /// macOS: the traffic lights sit over the application bar's left end. pub integrated_titlebar: bool, /// Build bodies' display meshes on the CPU even where the GPU could (safe mode). Read by @@ -336,12 +356,16 @@ pub struct SolveApp { control_held: std::collections::VecDeque, /// The agent cursor acting out control requests. pub agent: agent_cursor::AgentCursor, + /// The agent chat panel (docs/design/in-app-agent-chat.md). + pub agent_chat: agent_chat::AgentChat, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, queued_shots: Vec<(u64, f64, u32)>, shot_token: u64, styled: bool, fitted: bool, pending_fit: Option<(Camera, solidcraft_engine::geom::Aabb3, bool)>, + /// The sheet metal flat pattern's own pan and zoom. + pub flat_view: dialogs_sheet::FlatView, /// The start page. pub home: home::HomeState, pub file_load: file_load::FileLoad, @@ -379,6 +403,7 @@ impl SolveApp { dialog: None, params: None, design_properties: None, + material_card_review: None, palette: palette::Palette::default(), menu: context_menu::MenuState::default(), tree: browser::TreeState::default(), @@ -388,6 +413,7 @@ impl SolveApp { issue_report: issue_report::ReportState::default(), crashes: crash_ui::CrashUi::default(), updater: update::Updater::default(), + install_steps: None, toolbar_custom: toolbar_custom::ToolbarCustom::default(), sketch_choices: sketch_families::Choices::default(), preferences: prefs::Prefs::default(), @@ -403,17 +429,20 @@ impl SolveApp { autosave: None, recoverable: Vec::new(), stand_up: None, + stand_up_occurrence: None, msaa_supported: Vec::new(), settings_reset: Vec::new(), big_operation: false, autosave_minutes: 5.0, custom_titlebar: false, + native_resize_border: false, integrated_titlebar: false, cpu_meshing: false, frame_ms: 0.0, synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), control_rx: None, + agent_chat: Default::default(), control_held: Default::default(), agent: Default::default(), pending_shots: Vec::new(), @@ -422,6 +451,7 @@ impl SolveApp { styled: false, fitted: false, pending_fit: None, + flat_view: Default::default(), home: home::HomeState::default(), file_load: file_load::FileLoad::default(), docs: documents::Documents::default(), @@ -652,6 +682,11 @@ impl SolveApp { self.set_status(format!("{label}: {why}"), true); return; } + if id == "material.properties.validate" { + self.material_card_review = Some(material_card_review::ReviewWindow::default()); + self.home.open = false; + return; + } // Another command ends Edit Text (its dialog is not a feature edit to keep). sketch_text_move::cancel_drag(self); if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) { @@ -675,6 +710,21 @@ impl SolveApp { self.pick_open_command(id, json!({})); return; } + if id == "component.insert_design" { + // Another design becomes a linked component; a STEP or mesh file picked here is + // inserted the way Insert STEP or Insert Mesh would (#1240). + self.pick_open(|app, path| { + let Some(path) = path else { return }; + if is_design_path(&path) { + if app.run("component.insert_design", json!({ "path": path })).is_ok() { + app.fit_view(); + } + } else { + app.insert_path_async(&path); + } + }); + return; + } if matches!(id, "file.insert_step" | "file.insert_mesh") { self.pick_open(|app, path| { if let Some(path) = path { @@ -890,9 +940,9 @@ impl SolveApp { pub fn open_path(&mut self, path: &str) { self.home.open = false; documents::prepare_open(self); - if self.run("doc.open", json!({ "path": path })).is_ok() { + if let Ok(r) = self.run("doc.open", json!({ "path": path })) { self.fit_view(); - stand_up::offer(self, path, &[]); + stand_up::offer(self, path, &[], Some(&r)); } } @@ -910,7 +960,7 @@ impl SolveApp { let before: Vec = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect(); if let Ok(r) = self.run(cmd, json!({ "path": path })) { self.fit_view(); - stand_up::offer(self, path, &before); + stand_up::offer(self, path, &before, Some(&r)); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false); } @@ -940,6 +990,10 @@ impl SolveApp { } pub fn fit_view(&mut self) { + if dialogs_sheet::flat_active(self) { + self.flat_view.fitted = false; + return; + } if render_workspace::active(self) { self.cancel_view_animation(); match solidcraft_engine::product_scene::extract(&self.session) { @@ -1027,6 +1081,15 @@ impl SolveApp { /// Frame everything shown, keeping the orientation (animated; the selection is ignored). pub fn animate_fit_all(&mut self) { + if dialogs_sheet::flat_active(self) { + self.animate_view("fit"); + return; + } + if render_workspace::active(self) { + // The Render preview frames its product scene (see `fit_view`). + self.fit_view(); + return; + } self.pending_fit = None; let request = Some((self.cam, viewport::view_bounds(self), true)); let to = self.fitted(self.cam); @@ -1036,6 +1099,11 @@ impl SolveApp { /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { + // The flat pattern is a 2D view: Fit and Home frame it, and the Design camera stays put. + if dialogs_sheet::flat_active(self) && matches!(v, "fit" | "home") { + self.flat_view.fitted = false; + return; + } if render_workspace::active(self) && matches!(v, "fit" | "home") { if v == "home" { self.cam.set_view(StandardView::Iso); @@ -1214,6 +1282,11 @@ impl SolveApp { issue_report::CadInput::block(self, ui, &mut report_input); self.cad_ui(ui, &mut report_input); drop(report_input); + // After everything else, on every page (the start page too, which returns early from + // `cad_ui`): the window's edges resize it (#1381). + if self.custom_titlebar && !self.native_resize_border { + titlebar::resize_zones(ui); + } if self.file_picker.is_none() { issue_report::show(self, ui.ctx()); } @@ -1233,6 +1306,7 @@ impl SolveApp { file_load::show(self, ui.ctx()); documents::prompt(self, ui.ctx()); update::show(self, ui.ctx()); + install::show(self, ui.ctx()); if self.home.open { home::show(self, ui); if self.home.open { @@ -1260,6 +1334,7 @@ impl SolveApp { if render_workspace::active(self) { render_workspace::panel(self, ui); } + agent_chat::show(self, ui); preview::update(self, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { if render_workspace::active(self) { @@ -1274,14 +1349,12 @@ impl SolveApp { self.resolve_fit(ui.ctx()); params_dialog::show(self, ui.ctx()); design_properties::show(self, ui.ctx()); + material_card_review::show(self, ui.ctx()); recovery_ui::show(self, ui.ctx()); stand_up::show(self, ui.ctx()); prefs::show_reset_notice(self, ui.ctx()); context_menu::show(self, ui.ctx()); delete::show(self, ui.ctx()); - if self.custom_titlebar { - titlebar::resize_zones(ui); - } shortcut_box::show(self, ui.ctx()); prefs::show(self, ui.ctx()); toolbar_custom::window(self, ui.ctx()); @@ -1295,7 +1368,12 @@ impl SolveApp { /// Held requests beyond this are refused rather than kept. const MAX_HELD: usize = 256; - let Some(rx) = self.control_rx.take() else { return }; + // Held requests (and the agent chat's, which it hands over here) run even when the app + // has no control channel of its own. + if self.control_rx.is_none() && self.control_held.is_empty() { + return; + } + let rx = self.control_rx.take(); // While the agent cursor acts out a request, later ones wait their turn. for _ in 0..PER_FRAME { if self.agent.busy() { @@ -1303,7 +1381,7 @@ impl SolveApp { } // Held requests go first, in order, once the file is in. let held = if self.file_load.busy() || self.file_picker.is_some() { None } else { self.control_held.pop_front() }; - let Some(req) = held.or_else(|| rx.try_recv().ok()) else { break }; + let Some(req) = held.or_else(|| rx.as_ref().and_then(|rx| rx.try_recv().ok())) else { break }; // A file is loading: commands that change the design wait for it (the window, // inspection and screenshots still answer). if ((self.file_load.busy() || self.file_picker.is_some()) @@ -1340,7 +1418,7 @@ impl SolveApp { } ctx.request_repaint(); } - self.control_rx = Some(rx); + self.control_rx = rx; } fn issue_screenshots(&mut self, ctx: &egui::Context) { @@ -1413,3 +1491,10 @@ pub fn now_ms() -> f64 { #[cfg(test)] mod picker_test_support; + +/// A SolidCraft design file (`.solidcraft`, or a design saved as JSON), as opposed to a STEP or +/// mesh file. +fn is_design_path(path: &str) -> bool { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "solidcraft" | "solvecraft" | "json") +} diff --git a/crates/ui-egui/src/material_card_review.rs b/crates/ui-egui/src/material_card_review.rs new file mode 100644 index 00000000..c23f69f1 --- /dev/null +++ b/crates/ui-egui/src/material_card_review.rs @@ -0,0 +1,322 @@ +//! Candidate review through the existing validator, independent of assigned-card persistence. +use egui::RichText; +use serde_json::{Value, json}; +use solidcraft_engine::doc::physical_material::{Family, MaterialCard, PropertyData, admit_card}; + +use crate::{SolveApp, i18n::tr, theme::Tokens}; + +const MAX_BYTES: usize = 256 * 1024; +const KINDS: [&str; 9] = [ + "density", + "elasticity", + "tensile_strength", + "compressive_strength", + "yield_strength", + "shear_strength", + "thermal_conductivity", + "thermal_expansion", + "specific_heat", +]; + +#[derive(Default)] +pub struct ReviewWindow { + input: String, + reviewed: Option<(MaterialCard, Value)>, + error: Option, +} + +impl ReviewWindow { + fn review(&mut self, app: &mut SolveApp) { + self.reviewed = None; + let result = (|| { + if self.input.len() > MAX_BYTES { + return Err(tr("ui.material_review.limit").to_string()); + } + let value: Value = serde_json::from_str(&self.input).map_err(|e| e.to_string())?; + let report = app.run("material.properties.validate", json!({"card":value}))?; + let card = admit_card(&value).map_err(|e| e.to_string())?; + Ok((card, report)) + })(); + match result { + Ok(reviewed) => { + self.reviewed = Some(reviewed); + self.error = None; + } + Err(error) => self.error = Some(error), + } + } +} + +/// Open only through ui.start; executing the validator never opens a window. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut window) = app.material_card_review.take() else { return }; + let mut open = true; + let mut close = false; + crate::frame::window(ctx, tr("ui.material_review.title"), crate::frame::Width::Wide) + .id(egui::Id::new("sc_material_card_review")) + .open(&mut open) + .show(ctx, |ui| { + ui.label(tr("ui.material_review.scope")); + egui::ScrollArea::vertical().max_height((ctx.content_rect().height() - 180.0).max(100.0)).show(ui, |ui| { + ui.label(tr("ui.material_review.input")); + let response = ui.add( + egui::TextEdit::multiline(&mut window.input) + .id(egui::Id::new("sc_material_card_input")) + .desired_width(f32::INFINITY) + .desired_rows(5) + .char_limit(MAX_BYTES), + ); + if response.changed() { + window.reviewed = None; + window.error = None; + } + if ui.button(tr("ui.material_review.review")).clicked() { + window.review(app); + } + if let Some(error) = &window.error { + ui.colored_label(Tokens::get().error, error); + } + if let Some((card, report)) = &window.reviewed { + show_card(ui, card, report); + } + }); + ui.separator(); + close = ui.button(tr("ui.close")).clicked(); + }); + if open && !close { + app.material_card_review = Some(window); + } +} + +fn value(ui: &mut egui::Ui, field: &str, value: Option, unit: &str) { + ui.horizontal_wrapped(|ui| { + ui.monospace(field); + ui.label(value.unwrap_or_else(|| tr("ui.material_review.unknown").to_string())); + ui.label(unit); + }); +} + +fn numbers(ui: &mut egui::Ui, field: &str, values: &[f64], unit: &str) { + value(ui, field, Some(values.iter().map(|v| format!("{v:e}")).collect::>().join(", ")), unit); +} + +fn show_card(ui: &mut egui::Ui, card: &MaterialCard, report: &Value) { + ui.separator(); + ui.heading(&card.designation); + // Schema identifiers are inert metadata, not translated source claims or assigned body axes. + let family = serde_json::to_value(card.family).unwrap_or(Value::Null); + value(ui, "family", family.as_str().map(str::to_string), ""); + ui.strong(tr("ui.material_review.properties")); + for kind in KINDS { + let label = format!("ui.material_review.{kind}"); + let Some(property) = card.properties.iter().find(|p| p.data.kind() == kind) else { + ui.label(format!("{}: {}", tr(&label), tr("ui.material_review.unknown"))); + continue; + }; + egui::CollapsingHeader::new(tr(&label)).id_salt(kind).default_open(true).show(ui, |ui| { + show_data(ui, &property.data); + if let Some(axes) = serde_json::to_value(&property.data) + .ok() + .and_then(|d| d.get("axes").or_else(|| d.get("model").and_then(|m| m.get("axes"))).and_then(Value::as_str).map(str::to_string)) + { + let axes = if axes == "wood_lrt" { "wood_lrt (1=L, 2=R, 3=T)".to_string() } else { axes }; + value(ui, "axes", Some(axes), ""); + ui.label(tr("ui.material_review.axes")); + } + ui.strong(tr("ui.material_review.conditions")); + let c = &property.conditions; + value(ui, "temperature_k", c.temperature_k.map(|v| v.to_string()), "K"); + value(ui, "state", c.state.clone(), ""); + if card.family == Family::Wood || c.moisture_dry_basis_kg_kg.is_some() { + value(ui, "moisture_dry_basis_kg_kg", c.moisture_dry_basis_kg_kg.map(|v| v.to_string()), tr("ui.material_review.moisture_unit")); + } + if card.family == Family::Polymer || c.strain_rate_per_s.is_some() { + value(ui, "strain_rate_per_s", c.strain_rate_per_s.map(|v| v.to_string()), "s⁻¹"); + } + ui.strong(tr("ui.material_review.source")); + if let Some(source) = card.sources.iter().find(|s| s.id == property.source_id) { + ui.label(&source.title); + value(ui, "source_id", Some(source.id.clone()), ""); + value(ui, "kind", serde_json::to_value(source.kind).ok().and_then(|v| v.as_str().map(str::to_string)), ""); + value(ui, "license", source.license.clone(), ""); + value(ui, "reference", source.reference.clone(), ""); + } + }); + } + if let Some(moduli) = report.get("validation").and_then(|v| v.get("isotropic_moduli")).filter(|v| v.is_object()) { + ui.strong(tr("ui.material_review.derived")); + for field in ["shear_pa", "bulk_pa"] { + value(ui, field, moduli.get(field).and_then(Value::as_f64).map(|v| format!("{v:e}")), "Pa"); + } + } + ui.label(RichText::new(tr("ui.material_review.claims")).small().color(Tokens::get().text_dim)); +} + +fn show_data(ui: &mut egui::Ui, data: &PropertyData) { + match data { + PropertyData::Density { kg_m3 } => numbers(ui, "density", &[*kg_m3], "kg/m³"), + PropertyData::Elasticity { model } => match model { + solidcraft_engine::doc::physical_material::Elasticity::Isotropic { young_modulus_pa, poisson_ratio } => { + numbers(ui, "E", &[*young_modulus_pa], "Pa"); + numbers(ui, "ν", &[*poisson_ratio], ""); + } + solidcraft_engine::doc::physical_material::Elasticity::Orthotropic { young_moduli_pa, shear_moduli_pa, poisson_ratios, .. } => { + numbers(ui, "E1, E2, E3", young_moduli_pa, "Pa"); + numbers(ui, "G12, G23, G31", shear_moduli_pa, "Pa"); + numbers(ui, "ν12, ν23, ν31", poisson_ratios, ""); + } + }, + PropertyData::TensileStrength { pa } | PropertyData::CompressiveStrength { pa } | PropertyData::YieldStrength { pa } => { + numbers(ui, "σ", &[*pa], "Pa"); + } + PropertyData::ShearStrength { pa } => numbers(ui, "τ", &[*pa], "Pa"), + PropertyData::ThermalConductivity { w_m_k, .. } => numbers(ui, "k1, k2, k3", w_m_k, "W/(m·K)"), + PropertyData::ThermalExpansion { per_k, .. } => numbers(ui, "α1, α2, α3", per_k, "K⁻¹"), + PropertyData::SpecificHeat { j_kg_k } => numbers(ui, "c", &[*j_kg_k], "J/(kg·K)"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::Harness; + use std::sync::Arc; + + fn candidate() -> Value { + json!({"schema_version":1,"designation":"Original synthetic candidate","family":"wood", + "sources":[{"id":"example","title":"Original illustrative source","kind":"illustrative", + "license":"Unverified licence claim","reference":"https://invalid.example/not-fetched"}], + "properties":[{"source_id":"example","data":{"kind":"elasticity","model":{ + "symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}, + "conditions":{"temperature_k":293.15,"moisture_dry_basis_kg_kg":0.,"state":"synthetic specimen"}}]}) + } + + #[test] + fn clicked_candidate_review_and_close_preserve_design_history_and_unknowns() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + let doc = h.app.session.doc.clone(); + let before = ( + h.app.session.revision, + h.app.session.model.is_stale(), + h.app.session.undo.len(), + h.app.session.redo.len(), + h.app.session.selection.clone(), + ); + h.step(&json!({"start":"material.properties.validate"})).unwrap(); + h.app.material_card_review.as_mut().unwrap().input = candidate().to_string(); + h.frames(2); + h.step(&json!({"widget":"Review card"})).unwrap(); + let (card, report) = h.app.material_card_review.as_ref().unwrap().reviewed.as_ref().unwrap(); + assert_eq!(card.designation, "Original synthetic candidate"); + assert_eq!(card.properties.len(), 1, "missing values were not filled with zeros"); + assert_eq!(card.sources[0].title, "Original illustrative source"); + assert_eq!(card.properties[0].conditions.moisture_dry_basis_kg_kg, Some(0.)); + assert_eq!(report["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9})); + assert_eq!(report["validation"]["source_review_required"], json!(["example"])); + assert!(h.widget("Original synthetic candidate").is_some()); + assert!(h.widget("Density: Unknown").is_some()); + assert!(h.widget("Pa").is_some()); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("sc_material_card_input"))); + h.step(&json!({"text":" "})).unwrap(); + assert!(h.app.material_card_review.as_ref().unwrap().reviewed.is_none(), "editing invalidates the old report"); + h.step(&json!({"widget":"Close"})).unwrap(); + assert!(h.app.material_card_review.is_none()); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!( + before, + ( + h.app.session.revision, + h.app.session.model.is_stale(), + h.app.session.undo.len(), + h.app.session.redo.len(), + h.app.session.selection.clone() + ) + ); + } + + #[test] + fn invalid_and_oversized_candidates_never_keep_a_previous_review() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let doc = app.session.doc.clone(); + let mut window = ReviewWindow { input: candidate().to_string(), ..Default::default() }; + window.review(&mut app); + assert!(window.reviewed.is_some()); + for input in ["[".into(), "{\"schema_version\":2}".into(), "é".repeat(MAX_BYTES / 2 + 1)] { + window.input = input; + window.review(&mut app); + assert!(window.reviewed.is_none()); + assert!(window.error.is_some()); + } + assert!(window.error.as_deref().unwrap().contains("256 KiB")); + assert!(Arc::ptr_eq(&doc, &app.session.doc)); + assert!(app.session.undo.is_empty() && app.session.redo.is_empty()); + assert!(!app.session.is_dirty()); + } + + #[test] + fn every_property_variant_renders_its_declared_si_unit_and_inert_source() { + let fixtures = [ + (json!({"kind":"density","kg_m3":600.}), "kg/m³"), + ( + json!({"kind":"elasticity","model":{"symmetry":"orthotropic","axes":"wood_lrt", + "young_moduli_pa":[12e9,3e9,1e9],"shear_moduli_pa":[2e9,1e9,0.5e9],"poisson_ratios":[0.,0.,0.]}}), + "Pa", + ), + (json!({"kind":"tensile_strength","pa":1e8}), "Pa"), + (json!({"kind":"compressive_strength","pa":1e8}), "Pa"), + (json!({"kind":"yield_strength","pa":1e8}), "Pa"), + (json!({"kind":"shear_strength","pa":1e8}), "Pa"), + (json!({"kind":"thermal_conductivity","w_m_k":[1.,2.,3.],"axes":"material123"}), "W/(m·K)"), + (json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}), "K⁻¹"), + (json!({"kind":"specific_heat","j_kg_k":500.}), "J/(kg·K)"), + ]; + for (data, unit) in fixtures { + let mut h = Harness::new(); + h.app.start("material.properties.validate"); + let mut card = candidate(); + card["properties"][0]["data"] = data; + h.app.material_card_review.as_mut().unwrap().input = card.to_string(); + h.frames(2); + h.step(&json!({"widget":"Review card"})).unwrap(); + assert!(h.app.material_card_review.as_ref().unwrap().error.is_none(), "{card}"); + assert!(h.widget(unit).is_some(), "expected {unit} for {card}"); + assert!(h.widget("Original illustrative source").is_some(), "source is displayed as literal text"); + } + } + + #[test] + fn executing_json_validation_does_not_open_the_inspector() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.run("material.properties.validate", json!({"card":candidate()})).unwrap(); + assert!(app.material_card_review.is_none()); + } + + #[test] + fn reviewer_messages_exist_in_every_locale() { + let keys = [ + "title", + "scope", + "input", + "review", + "limit", + "unknown", + "properties", + "conditions", + "source", + "axes", + "derived", + "claims", + "moisture_unit", + ]; + for catalog in crate::i18n::LOCALES { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/locales").join(format!("{}.json", catalog.id)); + let messages: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + for key in keys.into_iter().chain(KINDS) { + let key = format!("ui.material_review.{key}"); + assert!(messages.get(&key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()), "{}: {key}", catalog.id); + } + } + } +} diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index e83cc047..405c3157 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -687,7 +687,7 @@ fn construction_plane_row_opens_its_menu() { assert_eq!(menu["open"], true, "{menu}"); assert_eq!(menu["target"], json!({"type": "plane", "id": id}), "{menu}"); let labels: Vec<&str> = menu["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).filter(|l| !l.is_empty()).collect(); - assert_eq!(labels, ["Edit Feature", "Delete", "Rename", "Show/Hide", "Find in Timeline"]); + assert_eq!(labels, ["Create Sketch", "Edit Feature", "Delete", "Rename", "Show/Hide", "Find in Timeline"]); assert_eq!(h.app.session.selection, vec![solidcraft_engine::Sel::Plane { name: "Cut".into() }], "the right-click picks the plane"); context_menu::close(&mut h.app); @@ -707,3 +707,96 @@ fn construction_plane_row_opens_its_menu() { act(&mut h.app, &t, "Delete"); assert!(h.app.session.doc.feature(id).is_none(), "nothing depends on the plane: it goes at once"); } + +/// A right-click on a plane offers a sketch on it (#1354): an origin plane in the viewport (it +/// stays unselected), an origin plane's browser row and a construction plane's row. Picking it +/// starts the sketch on that plane. +#[test] +fn right_click_on_a_plane_offers_a_sketch_on_it() { + use solidcraft_engine::geom::Vec3; + let normal = |h: &crate::scenario::Harness| h.app.session.world_state().sketches.first().map(|s| s.plane.normal()).unwrap_or_default(); + for (plane, axis) in [("XY", Vec3::Z), ("XZ", Vec3::Y), ("YZ", Vec3::X)] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.settle(); + let p = h.at(&json!({"plane": plane})).unwrap(); + h.call("ui.menu", json!({"x": p[0], "y": p[1]})); + let menu = h.call("ui.menu", json!({}))["result"].clone(); + let items = menu["items"].as_array().unwrap(); + let labels: Vec<&str> = items.iter().filter_map(|i| i["label"].as_str()).filter(|l| !l.is_empty()).collect(); + assert_eq!(labels[..3], ["Select Other…", "Create Sketch", "Pan"], "{plane}: {labels:?}"); + let sketch = items.iter().find(|i| i["label"] == "Create Sketch").unwrap(); + assert_eq!(sketch["params"], json!({"plane": plane})); + assert!(h.app.session.selection.is_empty(), "the right-click leaves the plane unselected"); + assert_eq!(h.call("ui.menuPick", json!({"item": "Create Sketch"}))["ok"], true); + assert!(h.app.session.active_sketch.is_some(), "{plane}: picking it starts a sketch"); + assert!(normal(&h).dot(axis).abs() > 0.999, "{plane}: sketched on {:?}", normal(&h)); + } + + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + crate::browser::toggle(&mut h.app, "origin", false); + h.frames(3); + let at = crate::scenario::handle_at("row:XZ").expect("the Origin folder lists XZ"); + h.call("ui.click", json!({"x": at.x, "y": at.y, "button": "right"})); + h.frames(2); + let menu = h.call("ui.menu", json!({}))["result"].clone(); + assert_eq!(menu["target"], json!({"type": "origin_plane", "name": "XZ"}), "{menu}"); + let t = Target::OriginPlane { name: "XZ".into() }; + act(&mut h.app, &t, "Show/Hide"); + assert!(h.app.session.visibility.hidden_origin.contains(&"XZ".to_string())); + act(&mut h.app, &t, "Show/Hide"); + assert!(!h.app.session.visibility.hidden_origin.contains(&"XZ".to_string())); + context_menu::close(&mut h.app); + // The toolbar's Create Sketch is already waiting for a plane: the menu's replaces it. + h.app.start("sketch.create"); + assert!(h.app.dialog.is_some()); + act(&mut h.app, &t, "Create Sketch"); + h.frames(3); + assert!(h.app.dialog.is_none(), "no Create Sketch dialog is left waiting for a plane"); + assert!(normal(&h).dot(Vec3::Y).abs() > 0.999); + + let mut h = crate::scenario::Harness::new(); + h.app.run("construct.plane.offset", json!({"base": "XY", "offset": 25, "name": "Cut"})).unwrap(); + let id = h.app.session.doc.find_feature("Cut").map(|f| f.id).unwrap(); + act(&mut h.app, &Target::Plane { id }, "Create Sketch"); + h.frames(3); + let sketch = h.app.session.active_sketch.and_then(|s| h.app.session.world_state().sketch(s).map(|s| s.plane.origin.z)); + assert_eq!(sketch, Some(25.0), "the sketch is on the construction plane"); +} + +/// Menus opened near the window's bottom right corner, in the viewport or the browser, open up +/// or to the left where they would not fit, ending at the cursor and staying in the window, at +/// any UI scale (#1354). +#[test] +fn menus_near_the_window_corner_open_inward() { + for scale in [0.7, 1.0, 1.5] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"ui_scale": scale})); + let screen = h.ctx.content_rect(); + let vp = h.app.viewport.rect.unwrap(); + let at = vp.right_bottom() - egui::vec2(40.0, 40.0); + for target in [None, Some(json!({"type": "origin"}))] { + let mut params = json!({"x": at.x, "y": at.y}); + if let Some(t) = &target { + params["target"] = t.clone(); + } + h.call("ui.menu", params); + assert_eq!(h.call("ui.menu", json!({}))["result"]["open"], true, "{scale} {target:?}"); + // An auto-sized menu takes a few frames to settle its size. + h.frames(3); + let r = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_context_menu"))).unwrap(); + assert!(screen.contains_rect(r), "{scale} {target:?}: {r:?} outside {screen:?}"); + // Below and right of the cursor where it fits, else ending at it. + let x = if at.x + r.width() <= screen.right() { at.x } else { at.x - r.width() }; + let y = if at.y + r.height() <= screen.bottom() { at.y } else { at.y - r.height() }; + assert!((r.left() - x).abs() <= 2.0 && (r.top() - y).abs() <= 2.0, "{scale} {target:?}: {r:?} for {at:?} in {screen:?}"); + if target.is_none() { + assert!((r.bottom() - at.y).abs() <= 2.0, "{scale}: the empty-space menu is too tall for below and opens above"); + } + context_menu::close(&mut h.app); + h.frames(2); + } + } +} diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs new file mode 100644 index 00000000..654ed752 --- /dev/null +++ b/crates/ui-egui/src/navigation.rs @@ -0,0 +1,742 @@ +//! Camera navigation shared by every page that shows the model through a camera: the modelling +//! viewport and the Render preview today, and any future canvas (a 2D drawing or flat pattern +//! page calls it with orbit off, so pan and zoom still follow the user's bindings). +//! +//! One entry point, [`handle`], reads a frame's pointer, wheel, trackpad and modifier input over a +//! view and moves `app.cam` the way Preferences › Navigation says: the navigation preset (Fusion, +//! SolidWorks, Blender, Touchpad…), Orbit around, zoom to the cursor, the middle double-click and +//! orbit snapping. The SpaceMouse, view keys and the navigators act on `app.cam` independently of +//! the page. New canvases must call `navigation::handle` rather than reading drags or the wheel +//! themselves; `hand_rolled_camera_input_is_rejected` fails when they do. + +use egui::{Pos2, Rect, pos2, vec2}; +use serde_json::json; +use solidcraft_engine::geom::Vec3; +use solidcraft_engine::render::Camera; + +use crate::SolveApp; +use crate::prefs::{OrbitAround, pivot_under}; +use crate::viewport::{Hit, NavMode, axis_rect, nearest_quarter_turn, pick, pick_cached, projection}; + +/// What a page's camera may do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct NavCaps { + /// Orbit (and continuous orbit) is allowed; off for flat 2D pages, where pan and zoom remain. + pub orbit: bool, + /// What is under the pointer can be picked or dragged, so presses on it don't navigate the + /// way presses on empty space do (OpenSCAD's left drag, Ctrl+left orbit). + pub pick: bool, +} + +impl NavCaps { + /// The modelling viewport. + pub const MODEL: NavCaps = NavCaps { orbit: true, pick: true }; + /// The Render workspace's product preview: the same navigation, nothing to select. + pub const RENDER: NavCaps = NavCaps { orbit: true, pick: false }; +} + +/// Raw wheel/trackpad input for one frame, split by source. +/// +/// Mouse wheels report lines or pages; trackpads report points. Trackpad +/// scrolls arrive bracketed (`Start` … `Move` … `End`, momentum included) +/// while mouse wheels send bare `Move` events, so phases tell them apart. +pub(crate) struct RawWheel { + /// Vertical mouse-wheel input in points (zooms). + zoom: f32, + /// Trackpad point input, routed like the wheel (zooms when panning is off). + point_zoom: f32, + /// Bracketed two-finger scroll in points (pans when panning is on). + pan: egui::Vec2, + /// Bare horizontal input in points (thumb wheel: always pans sideways). + mouse_pan: egui::Vec2, +} + +/// Route wheel points to the vertical axis, matching egui's scroll modifiers. +pub(crate) fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifiers, options: egui::InputOptions) -> f32 { + let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier); + let vertical = modifiers.matches_any(options.vertical_scroll_modifier); + if horizontal && !vertical { + 0.0 + } else if vertical && !horizontal { + points.x + points.y + } else { + points.y + } +} + +/// Match egui's wheel units and modifier routing, without its temporal smoothing. +/// +/// `gesture` tracks an ongoing bracketed trackpad scroll (`Start` … `End`): +/// moves inside one pan, bare `Move` input is a mouse (vertical zooms, +/// horizontal pans). Only point input brackets; line/page input always zooms. +pub(crate) fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel { + let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO }; + for event in &input.events { + let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue }; + if modifiers.matches_any(options.zoom_modifier) { + continue; + } + let point = matches!(unit, egui::MouseWheelUnit::Point); + match phase { + egui::TouchPhase::Start => { + if point { + *gesture = true; + } + continue; + } + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + if point { + *gesture = false; + } + continue; + } + egui::TouchPhase::Move => {} + } + let points = match unit { + egui::MouseWheelUnit::Point => *delta, + egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed, + egui::MouseWheelUnit::Page => *delta * page_height, + }; + if point && *gesture { + out.pan += points; + // Legacy path only (ignored while panning is on). + out.point_zoom += route_wheel_vertical(points, *modifiers, options); + } else if point { + // Bare moves are a mouse: vertical zooms, horizontal pans sideways. + // A wheel needs the opposite sign of fingers for the same motion. + out.zoom += route_wheel_vertical(points, *modifiers, options); + out.mouse_pan += egui::vec2(-points.x, 0.0); + } else { + out.zoom += route_wheel_vertical(points, *modifiers, options); + } + } + out +} + +/// Combined pinch-to-zoom factor for one frame (1 = no pinch input). +pub(crate) fn pinch_factor(input: &egui::InputState) -> f32 { + input + .events + .iter() + .filter_map(|event| { + let egui::Event::Zoom(f) = event else { return None }; + (*f).is_finite().then_some(*f).filter(|f| *f > 0.0) + }) + .fold(1.0, |acc, f| acc * f) +} + +/// Zoom anchor: the model point under the cursor, else the target's depth. +/// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre). +pub(crate) fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option, surface: bool) -> Option { + hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| { + let proj = projection(app, rect); + // Toward the model's surface under the cursor, else the target's depth. + if surface && let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) + { + return q; + } + let (o, d) = proj.ray(p); + let n = app.cam.back(); + let den = d.dot(n); + if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target } + }) +} + +/// Modifier-only navigation never borrows pointer movement from another widget or gesture. +pub(crate) fn modifier_motion( + app: &mut SolveApp, + ui: &egui::Ui, + inside: bool, + hover: Option, + mods: egui::Modifiers, + picking: bool, +) -> Option<(NavMode, egui::Vec2)> { + let owns = inside + && ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed()) + && !ui.ctx().egui_wants_keyboard_input() + && !egui::Popup::is_any_open(ui.ctx()) + && !crate::dim_view::editing() + && app.viewport.context_menu.is_none() + && app.viewport.axis_drag.is_none() + && app.viewport.point_drag.is_none() + && app.viewport.text_drag.is_none() + && app.viewport.boxsel.is_none() + && !(picking && hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p)))) + && !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p))); + let next = + hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p))); + let previous = app.viewport.modifier_nav; + if previous.map(|(m, _)| m) != next.map(|(m, _)| m) { + app.viewport.orbit_pivot = None; + app.viewport.orbit_free = None; + app.viewport.orbit_snap = None; + } + app.viewport.modifier_nav = next; + next.map(|(mode, p)| { + let delta = previous.filter(|(m, _)| *m == mode).map_or(egui::Vec2::ZERO, |(_, last)| p - last); + let delta = if delta.is_finite() && delta.x.abs() <= 1e4 && delta.y.abs() <= 1e4 { delta } else { egui::Vec2::ZERO }; + (mode, delta) + }) +} + +/// Continuous camera changes use the same JSON command as programmatic callers. +pub(crate) fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) { + let stored_fov = app.cam.fov; + params["camera"] = json!(projection(app, rect).cam); + params["viewport"] = json!([rect.width().max(1.0), rect.height().max(1.0)]); + let _ = app.run("view.navigate", params); + app.cam.fov = stored_fov; +} + +/// What one frame of camera input saw and did, for the caller's hover, selection and drawing. +pub(crate) struct CameraInput { + pub hover: Option, + pub scroll: f32, + pub mods: egui::Modifiers, + pub middle: bool, + pub secondary_down: bool, + pub primary_down: bool, + pub primary_pressed: bool, + pub delta: egui::Vec2, + /// The pointer is over this view (not a window, menu or the axis view above it). + pub inside: bool, + /// The axis view or navigation cube's rectangle. + pub axes: Rect, + pub pinch: f32, + pub pan: egui::Vec2, + /// The navigation bar's fixed mode, or the preset's left-button binding. + pub primary_nav: Option, + /// The navigation this frame's pointer input drives. + pub mode: Option, +} + +/// Camera input for one frame over `rect`: the navigation preset's buttons and modifiers, Orbit +/// around, wheel, trackpad and pinch zoom (to the cursor), middle double-click, continuous orbit +/// and orbit snapping. Every camera page calls this, so the mouse moves the camera the same way +/// in every workspace (#1454). With `caps.pick` off nothing under the pointer is treated as +/// selectable, so a press that would start a selection navigates as on empty space; with +/// `caps.orbit` off, bindings that orbit do nothing and pan and zoom work as usual. +pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &egui::Response, caps: NavCaps) -> CameraInput { + let picking = caps.pick; + let (hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, primary_released, delta) = ui.input(|i| { + ( + i.pointer.hover_pos(), + i.smooth_scroll_delta.y, + i.modifiers, + i.pointer.middle_down(), + i.pointer.secondary_down(), + i.pointer.primary_down(), + i.pointer.primary_pressed(), + i.pointer.primary_released(), + i.pointer.delta(), + ) + }); + // The right button belongs to the marking menu while it is open. + let secondary_down = secondary_down && app.viewport.context_menu.is_none(); + // The axis view handles its own clicks; the model underneath must not see them. + let axes = axis_rect(app, rect); + // Floating windows and menus own input over the model, including wheel navigation. egui's + // hit test also counts the model as under the pointer a few points past its own edge and + // inside a window's or a list's edge (#678), so the pointer must be in the viewport too, + // with no window or menu above it. + let inside = resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p)); + app.viewport.mouse = hover.filter(|_| inside); + // Ctrl or Alt held: drawing and dragging don't snap. + crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); + middle_double_click(app, ui, inside); + + // ---- navigation ---- + let trackpad_pan = app.preferences.trackpad_pans(); + if !app.preferences.smooth_wheel_zoom { + app.viewport.wheel_zoom_raw_until_idle = true; + } else if !ui.input(|i| i.is_scrolling()) { + app.viewport.wheel_zoom_raw_until_idle = false; + } + let options = ui.ctx().options(|o| o.input_options); + let page_height = ui.ctx().viewport_rect().height(); + let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture)); + if trackpad_pan && raw.pan != egui::Vec2::ZERO { + // Swallow egui's smoothing tail while the trackpad runs (a wheel-only + // tail ends the moment trackpad input starts; mixed input is rare). + app.viewport.trackpad_until_idle = true; + } else if !ui.input(|i| i.is_scrolling()) { + app.viewport.trackpad_until_idle = false; + // Self-healing: with no scroll activity no gesture can be mid-flight + // (its End was lost); a stuck flag would pan future mouse input. + app.viewport.trackpad_gesture = false; + } + let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan; + let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) { + raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom } + } else { + scroll + }; + // Pinch-to-zoom (trackpad): direct manipulation, never smoothed. + let pinch = ui.input(pinch_factor); + let h = rect.height().max(1.0) as f64; + if inside && pinch != 1.0 { + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; + // Spread zooms in: the camera distance shrinks by the pinch factor. + let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app)); + let anchor = zoom_anchor(app, rect, hover, caps.orbit); + if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { + let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)}); + if let Some(anchor) = anchor { + params["anchor"] = json!(anchor); + } + camera_motion(app, rect, params); + } else { + app.cancel_view_animation(); + app.cam.zoom_at(f, anchor); + } + } + if inside && scroll != 0.0 { + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; + let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); + let anchor = zoom_anchor(app, rect, hover, caps.orbit); + if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { + let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)}); + if let Some(anchor) = anchor { + params["anchor"] = json!(anchor); + } + camera_motion(app, rect, params); + } else { + app.cancel_view_animation(); + app.cam.zoom_at(f, anchor); + } + } + // Two-finger scroll pans the model with the fingers. + if inside && pan != egui::Vec2::ZERO { + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; + if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { + camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]})); + } else { + app.cancel_view_animation(); + app.cam.pan(f64::from(pan.x), f64::from(pan.y), h); + } + } + // A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let + // go (kept through the release frame, so the end of the drag doesn't select either). + if primary_pressed && inside { + let empty_space = |app: &SolveApp| { + app.viewport.context_menu.is_none() + && hover.is_some_and(|p| { + if !picking { + return true; + } + let proj = projection(app, rect); + pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none() + }) + }; + let openscad_orbit = + app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app); + let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit { + crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods) + } else { + None + }; + // Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click + // still selects, and Ctrl on geometry still drags it without snapping. + let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app); + app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit)); + } else if !primary_down && !primary_released { + app.viewport.primary_nav = None; + } + // The fixed navigation mode (navigation bar) or the preset's left-button binding. + let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); + let button = if middle { + Some(crate::prefs::NavButton::Middle) + } else if secondary_down { + Some(crate::prefs::NavButton::Right) + } else { + None + }; + let dragging = resp.dragged() || (inside && button.is_some()); + // Without orbit (a flat page), bindings that orbit do nothing. + let allowed = |m: NavMode| caps.orbit || m != NavMode::Orbit; + let motion = modifier_motion(app, ui, inside, hover, mods, picking).filter(|(m, _)| allowed(*m)); + let mode = motion + .map(|(m, _)| m) + .or_else(|| { + if dragging && app.viewport.axis_drag.is_none() { + match button { + Some(b) => crate::prefs::nav_mode(app, b, mods), + None if primary_down => primary_nav, + None => None, + } + } else { + None + } + }) + .filter(|m| allowed(*m)); + if let Some((mode, delta)) = motion { + if delta != egui::Vec2::ZERO { + let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]}); + if mode == NavMode::Orbit { + let pivot = match app.preferences.orbit_around { + crate::prefs::OrbitAround::ViewCentre => None, + crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO), + crate::prefs::OrbitAround::Cursor => Some(match app.viewport.orbit_pivot { + Some(pivot) => pivot, + None => { + let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta); + app.viewport.orbit_pivot = Some(pivot); + pivot + } + }), + }; + if let Some(pivot) = pivot { + params["pivot"] = json!(pivot); + } + } + camera_motion(app, rect, params); + } + } else if mode == Some(NavMode::Orbit) { + let press = ui.input(|i| i.pointer.press_origin()); + app.cancel_view_animation(); + // Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt), + // rather than an additional request for snapping. Only an extra modifier snaps. + let without_shift = egui::Modifiers { shift: false, ..mods }; + let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV + && mods.shift + && match button { + Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit), + None => app.viewport.nav.is_some(), + }; + orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true); + } else { + if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) { + app.viewport.orbit_snap = None; + } + if app.viewport.axis_drag.is_none() { + app.viewport.orbit_free = None; + } + if delta != egui::Vec2::ZERO { + if mode.is_some() { + app.cancel_view_animation(); + } + match mode { + Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h), + Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None), + _ => {} + } + } + } + if !dragging && motion.is_none() { + app.viewport.orbit_pivot = None; + } + if app.viewport.spin { + // A click on the navigation bar (its own button) is not a stop. + let bar = app + .viewport + .layout + .as_ref() + .map(|l| l.navigation) + .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0))); + let (pressed, key, dt) = ui.input(|i| { + ( + i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)), + i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })), + i.stable_dt, + ) + }); + if pressed || key || dragging || motion.is_some() || !caps.orbit { + app.viewport.spin = false; + } else { + app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0); + ui.ctx().request_repaint(); + } + } + if !app.cam.is_valid() { + app.viewport.orbit_free = None; + app.viewport.orbit_snap = None; + app.cam = Camera::default(); + app.fit_view(); + } + + step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt))); + if app.viewport.orbit_snap.is_some() { + ui.ctx().request_repaint(); + } + CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode } +} + +/// Six principal views, twelve edge diagonals and eight isometric corner directions. +pub(crate) fn snap_directions() -> impl Iterator { + [-1.0, 0.0, 1.0].into_iter().flat_map(|x| { + [-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized())) + }) +} + +/// Snap to the nearest standard or diagonal direction in the user's up-axis frame. +pub(crate) fn snapped_axis_camera(cam: Camera) -> Camera { + let b = cam.to_frame(cam.back()); + let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z); + let mut to = cam.looking_from(cam.from_frame(dir)); + if dir.x == 0.0 && dir.y == 0.0 { + to.yaw = nearest_quarter_turn(cam.yaw); + } + to +} + +pub(crate) struct OrbitSnap { + pub(crate) target: Camera, + pub(crate) yaw_velocity: f64, + pub(crate) pitch_velocity: f64, + pub(crate) pivot: Option<(Vec3, Pos2)>, +} + +/// Pointer motion accumulates independently of the spring preview, so many small deltas +/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker. +pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option, snap: bool, around_cursor: bool) { + let displayed = app.cam; + if let Some(free) = app.viewport.orbit_free.take() { + app.cam = free; + } + if around_cursor { + orbit(app, rect, dx, dy, press); + } else { + app.cam.orbit(dx, dy); + } + let free = app.cam; + app.viewport.orbit_free = Some(free); + if snap || !around_cursor || app.viewport.orbit_snap.is_some() { + let mut target = if snap { snapped_axis_camera(free) } else { free }; + if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() { + let b = free.back(); + if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) { + target.yaw = previous.target.yaw; + target.pitch = previous.target.pitch; + } + } + // The cursor and origin pivots stay still while a snap eases in; the axis gizmo has no + // pivot (it orbits about the view centre, see `axis_view`). + let pivot = if around_cursor { + match app.preferences.orbit_around { + crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO), + _ => app.viewport.orbit_pivot, + } + } else { + None + } + .and_then(|p| { + app.cam = displayed; + projection(app, rect).to_screen(p).map(|at| (p, at)) + }); + let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot }); + spring.target = target; + spring.pivot = pivot; + app.cam = displayed; + } +} + +/// A lightly damped spring: mostly settled in about a third of a second, with a small +/// elastic finish. Bounded substeps keep the behavior consistent across frame rates. +pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) { + let Some(mut spring) = app.viewport.orbit_snap.take() else { return }; + let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 }; + let steps = (dt * 240.0).ceil().max(1.0) as usize; + let h = dt / steps as f64; + let omega = 16.0; + let damping = 0.82; + let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + for _ in 0..steps.min(12) { + spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h; + spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h; + app.cam.yaw += spring.yaw_velocity * h; + app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2); + } + app.cam.target = spring.target.target; + let done = yaw_error(app.cam.yaw).abs() < 1e-5 + && (spring.target.pitch - app.cam.pitch).abs() < 1e-5 + && spring.yaw_velocity.abs() < 1e-4 + && spring.pitch_velocity.abs() < 1e-4; + if done { + app.cam = spring.target; + } + if let Some((pivot, at)) = spring.pivot + && let Some(after) = projection(app, rect).to_screen(pivot) + { + app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0))); + } + if !done { + app.viewport.orbit_snap = Some(spring); + } +} + +/// A middle-button double-click goes Home (or fits, or nothing: Preferences › Navigation). Only +/// clicks count: a press that moves (a pan or orbit drag) or is held is not one, and the two +/// clicks must both be the middle button's, close together in time and place. +pub(crate) fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: bool) { + use egui::PointerButton::{Extra1, Extra2, Middle, Primary, Secondary}; + let (double, releases, middle, other, dragging) = ui.input(|i| { + ( + i.pointer.button_double_clicked(Middle), + i.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: Middle, pressed: false, .. })).count(), + i.pointer.button_clicked(Middle), + [Primary, Secondary, Extra1, Extra2].into_iter().any(|b| i.pointer.button_clicked(b)), + i.pointer.is_decidedly_dragging(), + ) + }); + // egui pairs clicks of any buttons; the first click must have been the middle one too (or + // both arrive in this frame). + let go = double && inside && !other && (app.viewport.middle_clicked || releases >= 2); + app.viewport.middle_clicked = if other || dragging || go { false } else { middle || app.viewport.middle_clicked }; + if !go { + return; + } + match crate::prefs::MiddleDoubleClick::of(app) { + crate::prefs::MiddleDoubleClick::Home => app.animate_view("home"), + crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(), + crate::prefs::MiddleDoubleClick::Off => {} + } +} + +/// Orbit by pixel deltas about the configured centre: the view's centre (default), the world +/// origin, or the model point that was under the cursor when the drag began. The origin and the +/// cursor pivot are kept still on screen; a cursor drag that begins over empty space turns around +/// the centre of the visible geometry instead, so the model never swings around a far-off point. +/// The cursor pivot is chosen once, when the drag begins. +pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option) { + let pivot = match app.preferences.orbit_around { + OrbitAround::ViewCentre => None, + OrbitAround::Origin => Some(Vec3::ZERO), + OrbitAround::Cursor => Some(match app.viewport.orbit_pivot { + Some(p) => p, + None => { + let proj = crate::viewport::projection(app, rect); + let at = press.unwrap_or(rect.center()); + let p = pivot_under(app, &proj, at); + app.viewport.orbit_pivot = Some(p); + p + } + }), + }; + let Some(pivot) = pivot else { + app.cam.orbit(dx, dy); + return; + }; + orbit_about(app, rect, dx, dy, pivot); +} + +/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan +/// correction puts it back where it was. +pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) { + let before = crate::viewport::projection(app, rect).to_screen(pivot); + app.cam.orbit(dx, dy); + let after = crate::viewport::projection(app, rect).to_screen(pivot); + if let (Some(a), Some(b)) = (before, after) { + app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0))); + } +} + +#[cfg(test)] +mod tests { + /// Every page with a camera navigates through [`super::handle`] (#1454). A page that reads + /// the wheel or moves the view camera itself would drift from the user's navigation settings, + /// so outside this module (and the SpaceMouse driver, a device rather than a page) the camera + /// may only be moved through it, the view commands or the navigators. + #[test] + fn hand_rolled_camera_input_is_rejected() { + const PATTERNS: [&str; 7] = + ["smooth_scroll_delta", "raw_scroll_delta", "zoom_delta()", "cam.orbit(", "cam.pan(", "cam.zoom_at(", "cam.roll_by("]; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let dirs = [root.join("src"), root.join("../../apps/solidcraft/src")]; + let allowed = ["navigation.rs", "space_mouse.rs"]; + let mut offenders = Vec::new(); + let mut scanned = 0; + let mut stack: Vec = dirs.iter().filter(|d| d.is_dir()).cloned().collect(); + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string(); + if path.is_dir() { + stack.push(path); + continue; + } + if !name.ends_with(".rs") || name.ends_with("_tests.rs") || allowed.contains(&name.as_str()) { + continue; + } + scanned += 1; + let text = std::fs::read_to_string(&path).unwrap(); + // In-file unit tests set cameras up directly; only the code above them counts. + let code = text.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default(); + for (n, line) in code.lines().enumerate() { + if !line.trim_start().starts_with("//") && PATTERNS.iter().any(|p| line.contains(p)) { + offenders.push(format!("{}:{}: {}", path.display(), n + 1, line.trim())); + } + } + } + } + assert!(scanned > 50, "the source tree was scanned ({scanned} files)"); + assert!(offenders.is_empty(), "camera input outside navigation::handle; call it instead:\n{}", offenders.join("\n")); + } + + /// A flat page (orbit off) pans and zooms with the same bindings and never turns the view. + #[test] + fn flat_pages_pan_and_zoom_with_the_same_bindings_but_never_orbit() { + use egui::{Event, Modifiers, PointerButton, pos2, vec2}; + let flat = super::NavCaps { orbit: false, pick: false }; + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &serde_json::json!({"length":40,"width":30,"height":20})).unwrap(); + let mut app = crate::SolveApp::new(s, crate::Services::default()); + app.preferences.nav = preset.into(); + app.viewport.spin = true; + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut frame = |app: &mut crate::SolveApp, mods: Modifiers, events: Vec| { + time += 1.0 / 60.0; + let events = std::iter::once(Event::ModifiersChanged(mods)).chain(events).collect(); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + focused: true, + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let rect = ui.max_rect(); + app.viewport.rect = Some(rect); + let resp = ui.interact(rect, ui.id().with("flat"), egui::Sense::click_and_drag()); + super::handle(app, ui, rect, &resp, flat); + }); + out.textures_delta.clear(); + }; + frame(&mut app, Modifiers::NONE, vec![]); + assert!(!app.viewport.spin, "{preset}: continuous orbit stops on a flat page"); + let mut panned = false; + let at = pos2(200.0, 300.0); + for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { + for mods in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] { + let before = app.cam; + frame(&mut app, mods, vec![Event::PointerMoved(at)]); + frame(&mut app, mods, vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }]); + for k in 1..5 { + frame(&mut app, mods, vec![Event::PointerMoved(at + vec2(12.0, 7.0) * k as f32)]); + } + frame(&mut app, mods, vec![Event::PointerButton { pos: at + vec2(48.0, 28.0), button, pressed: false, modifiers: mods }]); + frame(&mut app, Modifiers::NONE, vec![]); + assert_eq!((app.cam.yaw, app.cam.pitch), (before.yaw, before.pitch), "{preset}: {button:?} with {mods:?} turned a flat page"); + panned |= app.cam.target != before.target; + } + } + assert!(panned, "{preset}: some binding pans"); + let before = app.cam; + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + }; + frame(&mut app, Modifiers::NONE, vec![Event::PointerMoved(at), wheel]); + for _ in 0..20 { + frame(&mut app, Modifiers::NONE, vec![]); + } + assert_ne!(app.cam.distance, before.distance, "{preset}: the wheel zooms"); + } + } +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 21ab2a0a..9722bc36 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -206,7 +206,8 @@ pub fn nav_preset(id: &str) -> &'static NavPreset { NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0]) } /// The dialog's pages, in order. -pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"]; +pub const PAGES: [&str; 8] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection", "AI"]; +pub const PAGE_AI: usize = 7; pub const PAGE_SHORTCUTS: usize = 2; pub const PAGE_DESIGN: usize = 3; pub const PAGE_GRAPHICS: usize = 4; @@ -325,6 +326,27 @@ pub struct Prefs { pub save_mesh_send: bool, pub save_mesh_slicer: String, pub custom_slicers: Vec, + /// AI features (docs/agent-chat.md): the master switch. Off: no AI button, shortcut, menu + /// entry or panel, and nothing is looked for or started. + pub ai_features: bool, + /// The AI button at the toolbar row's right end (the shortcut and View menu entry stay). + pub ai_toolbar_button: bool, + /// Agents the user added (the installed ones are found by themselves). API keys are never + /// here: they live in the system keychain. + pub agents: Vec, + /// The agent a new conversation starts with (an agent's id; empty: the first one available). + pub agent_default: String, + /// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent. + pub ai_permissions: crate::agent_chat::agents::Mode, + pub agent_permissions: std::collections::BTreeMap, + /// "Always allow" answers: per agent, the tools it may use without asking. + pub agent_always_allow: std::collections::BTreeMap>, + /// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip. + pub agent_chat_side: String, + pub agent_chat_width: f32, + pub agent_chat_minimized: bool, + /// Changes the agent may make for one message. + pub agent_chat_turn_limit: u32, } /// What a middle-button double-click in the view does. @@ -394,6 +416,17 @@ impl Default for Prefs { save_mesh_send: false, save_mesh_slicer: String::new(), custom_slicers: Vec::new(), + ai_features: true, + ai_toolbar_button: true, + agents: Vec::new(), + agent_default: String::new(), + ai_permissions: crate::agent_chat::agents::Mode::Edits, + agent_permissions: Default::default(), + agent_always_allow: Default::default(), + agent_chat_side: "right".into(), + agent_chat_width: 380.0, + agent_chat_minimized: false, + agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT, } } } @@ -492,6 +525,17 @@ fn defaults(app: &mut SolveApp, page: usize) { app.autosave_minutes = 5.0; set_autosave(app); } + PAGE_AI => { + // The agents the user added and their "always allow" answers stay. + p.ai_features = d.ai_features; + p.ai_toolbar_button = d.ai_toolbar_button; + p.ai_permissions = d.ai_permissions; + p.agent_permissions = d.agent_permissions; + p.agent_chat_side = d.agent_chat_side; + p.agent_chat_width = d.agent_chat_width; + p.agent_chat_minimized = d.agent_chat_minimized; + p.agent_chat_turn_limit = d.agent_chat_turn_limit; + } 1 => { p.nav = d.nav; p.navigator_style = d.navigator_style; @@ -604,44 +648,6 @@ pub fn zoom_sign(app: &SolveApp) -> f64 { if app.preferences.zoom_reverse { -1.0 } else { 1.0 } } -/// Orbit by pixel deltas about the configured centre: the view's centre (default), the world -/// origin, or the model point that was under the cursor when the drag began. The origin and the -/// cursor pivot are kept still on screen; a cursor drag that begins over empty space turns around -/// the centre of the visible geometry instead, so the model never swings around a far-off point. -/// The cursor pivot is chosen once, when the drag begins. -pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option) { - let pivot = match app.preferences.orbit_around { - OrbitAround::ViewCentre => None, - OrbitAround::Origin => Some(Vec3::ZERO), - OrbitAround::Cursor => Some(match app.viewport.orbit_pivot { - Some(p) => p, - None => { - let proj = crate::viewport::projection(app, rect); - let at = press.unwrap_or(rect.center()); - let p = pivot_under(app, &proj, at); - app.viewport.orbit_pivot = Some(p); - p - } - }), - }; - let Some(pivot) = pivot else { - app.cam.orbit(dx, dy); - return; - }; - orbit_about(app, rect, dx, dy, pivot); -} - -/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan -/// correction puts it back where it was. -fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) { - let before = crate::viewport::projection(app, rect).to_screen(pivot); - app.cam.orbit(dx, dy); - let after = crate::viewport::projection(app, rect).to_screen(pivot); - if let (Some(a), Some(b)) = (before, after) { - app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0))); - } -} - /// The model point under a screen position. Over empty space: the centre of the visible bodies /// and sketches (what Fit frames), or the view's centre when nothing is visible. pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 { @@ -683,6 +689,16 @@ pub fn length_number(mm: f64, units: &str, decimals: u8) -> String { format!("{:.*}", length_precision(value, units, decimals), value) } +/// A plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5" +/// and "2,5" are both 2.5, as a numeric keypad types either depending on the locale. Spaces are +/// ignored and "−" is a minus, as in egui's own parser. A comma beside a point ("1.234,5") or a +/// second comma is not guessed at: the text is not a number. +pub fn parse_number(text: &str) -> Option { + let t: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect(); + let t = if !t.contains('.') && t.matches(',').count() == 1 { t.replacen(',', ".", 1) } else { t }; + t.parse::().ok().filter(|v| v.is_finite()) +} + /// Edit a millimetre value through a control expressed in the current design units. /// Keep the stored value untouched unless the user actually changes the field. pub fn length_drag<'a>(value: &'a mut f64, units: &str, decimals: u8) -> egui::DragValue<'a> { @@ -953,6 +969,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { crate::keymap::page(app, ui); return; } + if app.prefs_window.section == PAGE_AI { + crate::agent_chat::settings::page(app, ui); + return; + } egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section { 0 => { ui.label(app.tr("ui.language")); @@ -1001,7 +1021,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }); ui.end_row(); ui.label(app.tr("ui.autosave_every")); - if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() { + if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min").custom_parser(parse_number)).changed() { set_autosave(app); } ui.end_row(); @@ -1438,6 +1458,19 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result)>, /// The running job builds a tool alone (for a join, cut or intersect). running_tool: bool, - /// Inputs of an in-flight joint preview, excluding only its driven values. - running_motion_key: Option, + /// Inputs of a joint or distance preview, excluding only its driven values. + running_stream_key: Option, /// Bodies the preview stands in for (hidden in the model scene). pub replaced: Vec, /// The preview scene for the GPU and its key (changes whenever the scene does). @@ -102,7 +102,7 @@ impl PreviewState { fn clear(&mut self) { self.running = None; self.running_tool = false; - self.running_motion_key = None; + self.running_stream_key = None; self.shown_key = None; self.error = None; self.applied = None; @@ -152,7 +152,7 @@ impl PreviewState { /// Errors validate only the current inputs. Keep the last good geometry while /// computing new inputs, but never let an obsolete result block their Apply. - fn poll(&mut self, key: u64, motion_key: Option) { + fn poll(&mut self, key: u64, stream_key: Option) { if self.shown_key != Some(key) { self.sketches.clear(); self.error = None; @@ -167,11 +167,11 @@ impl PreviewState { if let Some(job) = job { self.running = None; let good = matches!(&job, Ok(b) if b.error.is_none()); - let intermediate = good && (self.running_tool || motion_key.is_some() && self.running_motion_key == motion_key); - self.running_motion_key = None; + let intermediate = good && (self.running_tool || stream_key.is_some() && self.running_stream_key == stream_key); + self.running_stream_key = None; self.running_tool = false; // A held slider, a dragged arrow or an animation may advance faster than the - // worker. Show completed poses from the same joint setup, and tools built for + // worker. Show completed joint/distance results from the same setup, and tools built for // inputs since changed, then compute the latest. Errors still require an exact // key. if k == key || intermediate { @@ -209,6 +209,41 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option Some(h.finish()) } +/// Tool previews already stream in `poll`. New-body extrusions and face offsets instead +/// build their full result; allow successful intermediate distances only within the same +/// setup. Preserve selections, feature identity, extent mode, revision and colours. The +/// exact job key still ensures the latest distance is computed and errors never go stale. +fn stream_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option { + if let Some(key) = motion_key(cmds, revision, colors) { + return Some(key); + } + if cmds.is_empty() || as_new_body(cmds).is_some() { + return None; + } + let mut setup = cmds.to_vec(); + for (command, params) in &mut setup { + let (command, params) = if command == "timeline.redefine" { + let command = params.get("command")?.as_str()?.to_string(); + (command, params.get_mut("params")?) + } else { + (command.clone(), params) + }; + let fields: &[&str] = match command.as_str() { + "solid.extrude" => &["distance", "distance2", "to_offset"], + "solid.offset_face" => &["distance"], + _ => return None, + }; + let params = params.as_object_mut()?; + for field in fields { + // Keep presence: changing extent mode must not reuse the old result. + if let Some(value) = params.get_mut(*field) { + *value = Value::Null; + } + } + } + Some(job_key(&setup, revision, colors)) +} + /// Keep the preview in step with the open dialog: start a computation when its inputs changed, /// take finished results. Called once per frame. pub fn update(app: &mut SolveApp, ctx: &egui::Context) { @@ -227,13 +262,13 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) { }; let colors = Colors::from_tokens(&Tokens::get()); let key = job_key(&cmds, app.session.revision, colors); - let motion_key = motion_key(&cmds, app.session.revision, colors); + let stream_key = stream_key(&cmds, app.session.revision, colors); let tool = as_new_body(&cmds).is_some(); let pv = &mut app.preview; - pv.poll(key, motion_key); + pv.poll(key, stream_key); if pv.running.is_none() && pv.shown_key != Some(key) { pv.started = crate::now_ms(); - pv.running_motion_key = motion_key; + pv.running_stream_key = stream_key; let scratch = app.session.scratch(); spawn(&mut app.preview, key, scratch, cmds, colors, tool); } @@ -720,7 +755,7 @@ mod tests { for (running, wanted) in [(1, 2), (2, 3), (3, 4)] { let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((running, rx)); - pv.running_motion_key = Some(42); + pv.running_stream_key = Some(42); pv.poll(wanted, Some(42)); assert!(pv.running.is_some()); tx.send(Ok(pose())).unwrap(); @@ -733,7 +768,7 @@ mod tests { } let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((4, rx)); - pv.running_motion_key = Some(42); + pv.running_stream_key = Some(42); tx.send(Ok(pose())).unwrap(); pv.poll(4, Some(42)); assert_eq!(pv.shown_key, Some(4), "release catches up to the exact final pose"); @@ -748,7 +783,7 @@ mod tests { (Some(42), Err("old invalid value".into())), (Some(42), Ok(Built { error: Some("old tool preview error".into()), ..pose() })), ] { - let mut pv = PreviewState { running_motion_key: Some(42), ..PreviewState::default() }; + let mut pv = PreviewState { running_stream_key: Some(42), ..PreviewState::default() }; let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((1, rx)); tx.send(job).unwrap(); @@ -997,3 +1032,7 @@ mod tests { assert!(pv.error.is_none() && pv.shown_key == Some(1), "an old error is dropped"); } } + +#[cfg(test)] +#[path = "preview_drag_tests.rs"] +mod drag_tests; diff --git a/crates/ui-egui/src/preview_drag_tests.rs b/crates/ui-egui/src/preview_drag_tests.rs new file mode 100644 index 00000000..24f56f7b --- /dev/null +++ b/crates/ui-egui/src/preview_drag_tests.rs @@ -0,0 +1,149 @@ +//! Completed distances must reach the viewport before a continuously moving pointer stops. +use super::*; +use serde_json::json; + +fn colors() -> Colors { + Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } +} + +fn fixture() -> Session { + let mut s = Session::new(solidcraft_engine::doc::Document::new("Preview drag")); + s.execute("sketch.create", &json!({"plane":"XY", "name":"Profile"})).unwrap(); + s.execute("sketch.rectangle.two_point", &json!({"p0":[0,0], "p1":[10,10]})).unwrap(); + s.execute("sketch.finish", &json!({})).unwrap(); + s +} + +fn extrude(distance: i32) -> Vec<(String, Value)> { + vec![("solid.extrude".into(), json!({"sketch":"Profile", "distance":distance, "operation":"new"}))] +} + +fn check_drag(s: &Session, commands: impl Fn(i32) -> Vec<(String, Value)>, base_z: f64) { + let original = serde_json::to_value(&*s.doc).unwrap(); + let undo = s.undo.len(); + let mut pv = PreviewState::default(); + for (finished, wanted) in [(10, 20), (20, 30), (30, 30)] { + let old = commands(finished); + let new = commands(wanted); + let old_key = job_key(&old, s.revision, colors()); + let key = job_key(&new, s.revision, colors()); + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((old_key, rx)); + pv.running_tool = as_new_body(&old).is_some(); + pv.running_stream_key = stream_key(&old, s.revision, colors()); + let built = compute_tool(s, &old, colors()).unwrap(); + tx.send(Ok(built)).unwrap(); + pv.poll(key, stream_key(&new, s.revision, colors())); + assert!(pv.active && pv.error.is_none(), "completed geometry must show during the drag: {:?}", pv.error); + assert!((pv.bounds.max.z - base_z - f64::from(finished)).abs() < 1e-8); + assert_eq!(pv.shown_key, Some(old_key)); + assert_eq!(pv.shown_key == Some(key), finished == wanted, "only the final distance satisfies the latest request"); + assert!(pv.running.is_none()); + } + assert_eq!(serde_json::to_value(&*s.doc).unwrap(), original); + assert_eq!(s.undo.len(), undo); +} + +#[test] +fn new_body_extrude_keeps_moving_during_a_drag() { + check_drag(&fixture(), extrude, 0.0); +} + +#[test] +fn edited_new_body_extrude_keeps_moving_during_a_drag() { + let mut s = fixture(); + let feature = s.execute("solid.extrude", &extrude(5)[0].1).unwrap()["feature"].as_u64().unwrap(); + check_drag( + &s, + |distance| vec![("timeline.redefine".into(), json!({"feature":feature,"command":"solid.extrude","params":extrude(distance)[0].1}))], + 0.0, + ); +} + +#[test] +fn face_offset_keeps_moving_during_a_drag() { + let mut s = Session::new(solidcraft_engine::doc::Document::new("Face offset drag")); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + check_drag(&s, |distance| vec![("solid.offset_face".into(), json!({"faces":[[5,5,10]],"distance":distance}))], 10.0); +} + +#[test] +fn join_tool_keeps_moving_without_waiting_for_its_boolean() { + let mut s = fixture(); + s.execute("solid.box", &json!({"length":10,"width":10,"height":5})).unwrap(); + check_drag(&s, |distance| vec![("solid.extrude".into(), json!({"sketch":"Profile","distance":distance,"operation":"join"}))], 0.0); +} + +#[test] +fn distance_stream_keeps_feature_selection_and_extent_context() { + let old = extrude(10); + let key = stream_key(&old, 7, colors()); + assert!(key.is_some()); + assert_eq!(key, stream_key(&extrude(20), 7, colors())); + assert_ne!(key, stream_key(&old, 8, colors())); + let mut recolored = colors(); + recolored.added = [40, 50, 60, 255]; + assert_ne!(key, stream_key(&old, 7, recolored)); + for (field, value) in [ + ("sketch", json!("Other")), + ("profiles", json!([1])), + ("face", json!([0, 0, 5])), + ("operation", json!("cut")), + ("direction", json!("negative")), + ("distance2", json!(5)), + ("to", json!([0, 0, 50])), + ("taper", json!(5)), + ("start_offset", json!(3)), + ("participants", json!(["Other"])), + ] { + let mut changed = old.clone(); + changed[0].1[field] = value; + assert_ne!(key, stream_key(&changed, 7, colors()), "changed {field} cannot reuse the old result"); + } + for field in ["distance2", "to_offset"] { + let mut from = old.clone(); + from[0].1[field] = json!(5); + let mut to = from.clone(); + to[0].1[field] = json!(9); + assert_eq!(stream_key(&from, 7, colors()), stream_key(&to, 7, colors())); + } + let edit = + |feature, distance| vec![("timeline.redefine".into(), json!({"feature":feature,"command":"solid.extrude","params":extrude(distance)[0].1}))]; + assert_eq!(stream_key(&edit(2, 10), 7, colors()), stream_key(&edit(2, 20), 7, colors())); + assert_ne!(stream_key(&edit(2, 10), 7, colors()), stream_key(&edit(3, 20), 7, colors())); + assert_ne!(key, stream_key(&edit(2, 10), 7, colors())); + let offset = |distance| vec![("solid.offset_face".into(), json!({"faces":[[5,5,10]],"distance":distance}))]; + assert!(stream_key(&offset(1), 7, colors()).is_some()); + assert_eq!(stream_key(&offset(1), 7, colors()), stream_key(&offset(5), 7, colors())); + assert!(stream_key(&[], 7, colors()).is_none()); + assert!(stream_key(&[("solid.fillet".into(), json!({"radius":5}))], 7, colors()).is_none()); + let mut mixed = old; + mixed.push(("select.clear".into(), json!({}))); + assert!(stream_key(&mixed, 7, colors()).is_none()); +} + +#[test] +fn distance_results_reject_other_setups_and_obsolete_errors() { + let s = fixture(); + let old = extrude(10); + let current = extrude(20); + for (revision, failed) in [(s.revision + 1, false), (s.revision, true)] { + let mut built = compute_tool(&s, &old, colors()).unwrap(); + if failed { + built.error = Some("old distance failed".into()); + } + let (tx, rx) = std::sync::mpsc::channel(); + let mut pv = PreviewState { + running: Some((job_key(&old, s.revision, colors()), rx)), + running_stream_key: stream_key(&old, s.revision, colors()), + ..Default::default() + }; + tx.send(Ok(built)).unwrap(); + pv.poll(job_key(¤t, revision, colors()), stream_key(¤t, revision, colors())); + assert!(!pv.active && pv.error.is_none() && pv.shown_key.is_none()); + } + let mut join = current.clone(); + join[0].1["operation"] = json!("join"); + assert!(as_new_body(&join).is_some()); + assert!(stream_key(&join, s.revision, colors()).is_none(), "tool previews retain their upstream streaming path"); +} diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 6045336c..68c9e8e9 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -266,6 +266,18 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) { app.render_workspace.view.dirty = true; } }); + let draft = &app.render_workspace.view.draft; + let size = (u64::from(draft.width), u64::from(draft.height)); + if let Some((w, h)) = RenderLimits::default().fit(size.0, size.1, None) + && (w, h) != size + { + ui.small(app.tr("render.image_too_large")); + if ui.button(format!("{} {w} × {h}", app.tr("render.image_use_largest"))).clicked() { + app.render_workspace.view.draft.width = u32::try_from(w).unwrap_or(1); + app.render_workspace.view.draft.height = u32::try_from(h).unwrap_or(1); + app.render_workspace.view.dirty = true; + } + } let transparent_label = app.tr("render.image_transparent").to_string(); ui.checkbox(&mut app.render_workspace.view.transparent, transparent_label); if ui.button(app.tr("render.image_check")).clicked() { @@ -435,23 +447,21 @@ fn hash_surface(surface: Option, full: (u64, u64)) -> (u64, u64) { + executor.and_then(|e| e.fit(full.0, full.1)).unwrap_or(full) +} + pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let rect = ui.max_rect(); app.viewport.rect = Some(rect); - let response = ui.allocate_rect(rect, Sense::click_and_drag()); - let delta = ui.input(|i| i.pointer.delta()); - if response.dragged_by(egui::PointerButton::Primary) { - app.cam.orbit(f64::from(delta.x), f64::from(delta.y)); - } - if response.dragged_by(egui::PointerButton::Middle) { - app.cam.pan(f64::from(delta.x), f64::from(delta.y), f64::from(rect.height())); - } - if response.hovered() { - let scroll = ui.input(|i| i.smooth_scroll_delta.y); - if scroll != 0.0 { - app.cam.zoom_at((-f64::from(scroll) * 0.0015 * crate::prefs::zoom_sign(app)).exp(), None); - } - } + crate::viewport::refresh_layout(app, ui.ctx().pixels_per_point()); + let response = ui.interact(rect, ui.id().with("render_preview"), Sense::click_and_drag()); + // The modelling viewport's camera input (preset, Orbit around, zoom to the cursor, middle + // double-click), with nothing to pick or select here. + crate::navigation::handle(app, ui, rect, &response, crate::navigation::NavCaps::RENDER); let bg = app.render_workspace.view.draft.background; ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); let result = (|| -> Result<(), String> { @@ -463,12 +473,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if setup.exposure_ev != 0.0 || setup.environment_rotation != 0.0 || setup.environment_intensity != 1.0 { return Err("fixed studio preview requires exposure 0, environment rotation 0 and intensity 1".into()); } + // A 4K or 5K viewport is past the preview's pixel budget: draw it smaller and filter it + // up into the viewport rather than refusing the frame. + let full = ((rect.width() * ppp).max(1.0) as u64, (rect.height() * ppp).max(1.0) as u64); + let (width, height) = preview_size(app.session.render_viewport(), full); let mut request = RenderRequest { backend: Some(setup.backend.clone()), mode: setup.mode, operation: RenderOperation::Viewport, - width: (rect.width() * ppp).max(1.0) as u64, - height: (rect.height() * ppp).max(1.0) as u64, + width, + height, format: None, features: Vec::new(), policy: Default::default(), @@ -525,7 +539,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { access: None, outline: false, eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }], - ssaa: 1.0, + ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 }, }; ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback)); Ok(()) @@ -533,4 +547,5 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Err(error) = result { ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, error, egui::FontId::proportional(14.0), Tokens::get().text); } + crate::viewport::axis_view(app, ui, rect); } diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index 4645d703..027dc4c8 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -470,3 +470,206 @@ fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwr assert!(matches!(&h.app.status, Some((_, _, true)))); } } + +/// One frame of either the modelling viewport or the Render preview, over the same rectangle. +fn nav_frame(app: &mut crate::SolveApp, ctx: &egui::Context, render: bool, time: &mut f64, mods: egui::Modifiers, events: Vec) { + *time += 1.0 / 60.0; + let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + time: Some(*time), + focused: true, + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + if render { + show(app, ui); + } else { + crate::viewport::show(app, ui); + } + }); + out.textures_delta.clear(); +} + +fn nav_app(render: bool, preset: &str, around: crate::prefs::OrbitAround) -> crate::SolveApp { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap(); + let mut app = crate::SolveApp::new(s, crate::Services::default()); + app.viewport.no_pixels = true; + if render { + enter(&mut app, true).unwrap(); + assert!(active(&app)); + } + app.preferences.nav = preset.into(); + app.preferences.orbit_around = around; + app.preferences.zoom_to_cursor = true; + app.ui.perspective = true; + let mut cam = Camera::default(); + cam.set_view(solidcraft_engine::render::StandardView::Iso); + cam.fit(&solidcraft_engine::product_scene::extract(&app.session).unwrap().bounds); + app.cam = cam; + app +} + +/// #1454: the same mouse, wheel and modifier input moves the Render preview's camera exactly as +/// it moves the modelling viewport's, under every navigation preset and Orbit around setting. +#[test] +fn render_preview_navigates_like_the_modelling_viewport_in_every_preset() { + use egui::{Event, Modifiers, PointerButton, pos2, vec2}; + let none = Modifiers::NONE; + let chords = [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT]; + let arounds = [crate::prefs::OrbitAround::ViewCentre, crate::prefs::OrbitAround::Cursor, crate::prefs::OrbitAround::Origin]; + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + for around in arounds { + let (design_ctx, render_ctx) = (egui::Context::default(), egui::Context::default()); + let mut design = nav_app(false, preset, around); + let mut render = nav_app(true, preset, around); + let (mut design_time, mut render_time) = (0.0, 0.0); + let mut both = |events: Vec, mods: Modifiers, design: &mut crate::SolveApp, render: &mut crate::SolveApp| { + nav_frame(design, &design_ctx, false, &mut design_time, mods, events.clone()); + nav_frame(render, &render_ctx, true, &mut render_time, mods, events); + }; + both(vec![], none, &mut design, &mut render); + assert_eq!(design.viewport.rect, render.viewport.rect); + // Presses start on empty space (the Render preview has nothing to pick), and the wheel + // turns over the model, so zoom to the cursor anchors on its surface. + let empty = pos2(90.0, 520.0); + let proj = crate::viewport::projection(&design, design.viewport.rect.unwrap()); + assert!(crate::viewport::pick(&design, &proj, empty).is_empty()); + let model = proj.to_screen(design.cam.target).unwrap(); + assert!(!crate::viewport::pick(&design, &proj, model).is_empty()); + let mut moved = 0; + for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { + for mods in chords { + let before = design.cam; + both(vec![Event::PointerMoved(empty)], mods, &mut design, &mut render); + both(vec![Event::PointerButton { pos: empty, button, pressed: true, modifiers: mods }], mods, &mut design, &mut render); + let mut at = empty; + for _ in 0..5 { + at += vec2(14.0, -9.0); + both(vec![Event::PointerMoved(at)], mods, &mut design, &mut render); + } + both(vec![Event::PointerButton { pos: at, button, pressed: false, modifiers: mods }], mods, &mut design, &mut render); + for _ in 0..30 { + both(vec![], none, &mut design, &mut render); + } + assert_eq!(design.cam, render.cam, "{preset} {around:?}: {button:?} drag with {mods:?}"); + moved += usize::from(design.cam != before); + // Let any snap settle and start the next gesture from the same view. + design.cam = render.cam; + } + } + assert!(moved >= 2, "{preset}: the preset's drags move the camera ({moved})"); + // Wheel over the model, then modifier-only motion (Touchpad: Shift pans, Alt orbits). + let before = design.cam; + both(vec![Event::PointerMoved(model)], none, &mut design, &mut render); + let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; + both(vec![wheel], none, &mut design, &mut render); + for _ in 0..30 { + both(vec![], none, &mut design, &mut render); + } + assert_ne!(design.cam, before, "{preset}: the wheel zooms"); + assert_eq!(design.cam, render.cam, "{preset} {around:?}: wheel zoom to the cursor"); + for mods in [Modifiers::SHIFT, Modifiers::ALT] { + both(vec![Event::PointerMoved(empty)], mods, &mut design, &mut render); + both(vec![Event::PointerMoved(empty + vec2(25.0, 15.0))], mods, &mut design, &mut render); + both(vec![Event::PointerMoved(empty)], none, &mut design, &mut render); + assert_eq!(design.cam, render.cam, "{preset} {around:?}: modifier motion with {mods:?}"); + } + assert!(render.session.selection.is_empty(), "the Render preview never selects"); + } + } +} + +/// #1454: Fit (F6), a middle double-click, and the axis view's and navigation cube's Home and +/// view controls all work in the Render workspace, framing the product scene. +#[test] +fn render_workspace_view_keys_double_middle_click_and_navigators_work() { + for style in [crate::prefs::NavigatorStyle::Axes, crate::prefs::NavigatorStyle::Cube] { + let mut h = Harness::new(); + h.app.preferences.navigator_style = style; + h.app.preferences.look_at_anim = false; + h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap(); + enter(&mut h.app, true).unwrap(); + h.frames(3); + let bounds = solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds; + let mut home = h.app.cam; + home.set_view(solidcraft_engine::render::StandardView::Iso); + home.fit(&bounds); + let off = |h: &mut Harness| { + h.app.cam.yaw += 0.7; + h.app.cam.pitch = 0.1; + h.app.cam.distance *= 3.0; + h.app.cam.target.x += 25.0; + h.frames(2); + }; + // F6 fits, keeping the direction. + off(&mut h); + let mut fitted = h.app.cam; + fitted.fit(&bounds); + h.step(&json!({"key":"F6"})).unwrap(); + h.settle(); + assert_eq!(h.app.cam, fitted, "{style:?}: F6"); + // A middle double-click goes Home. + off(&mut h); + let centre = h.app.viewport.rect.unwrap().center(); + h.step(&json!({"click":[centre.x, centre.y], "double":true, "button":"middle"})).unwrap(); + h.settle(); + assert_eq!(h.app.cam, home, "{style:?}: middle double-click"); + // The navigator's Home control. + off(&mut h); + let handle = match style { + crate::prefs::NavigatorStyle::Axes => "axis_view:home", + crate::prefs::NavigatorStyle::Cube => "navigation_cube:control:home", + }; + h.step(&json!({"click":{"handle":handle}})).unwrap(); + h.settle(); + assert!( + (h.app.cam.yaw - home.yaw).abs() < 1e-6 && (h.app.cam.pitch - home.pitch).abs() < 1e-6, + "{style:?}: navigator Home turns to the home view" + ); + // A standard view from the navigator. + h.step(&json!({"click":{"handle":"axis_view:z"}})).unwrap(); + h.settle(); + assert!(h.app.cam.back().dot(solidcraft_engine::geom::Vec3::Z) > 0.999, "{style:?}: view from +Z, got {:?}", h.app.cam.back()); + assert!(h.app.session.selection.is_empty()); + } +} + +/// #1464: a maximised 4K or 5K window is past the preview's pixel budget. The preview draws +/// smaller at the same aspect instead of refusing every frame. +#[test] +fn four_and_five_k_viewports_preview_downscaled_within_the_limits() { + use solidcraft_engine::render::*; + for samples in [1, 4, 16] { + let host = host_executor(16384, true, samples).unwrap(); + let mut s = Session::default(); + s.set_render_viewport(Some(host.clone())).unwrap(); + let request = |width, height| RenderRequest { + backend: None, + mode: RenderMode::Preview, + operation: RenderOperation::Viewport, + width, + height, + format: None, + features: vec![RenderFeature::Pbr], + policy: Default::default(), + }; + if samples <= 4 { + assert_eq!(preview_size(s.render_viewport(), (1920, 1080)), (1920, 1080), "fits: drawn at full size"); + } + // 16× MSAA's scratch cost brings even 1080p under the budget. + let sizes: &[(u64, u64)] = if samples == 16 { &[(1920, 1080), (3840, 2160), (5120, 2880)] } else { &[(3840, 2160), (5120, 2880)] }; + for &full in sizes { + assert!(resolve_request(&[s.render_backend()], &request(full.0, full.1)).is_err(), "{full:?} at full size is over the limit"); + let (w, h) = preview_size(s.render_viewport(), full); + assert!(w < full.0 && h < full.1 && w * h <= host.limits.max_pixels, "{full:?} -> {w}x{h}"); + assert!((w as f64 / h as f64 - 16.0 / 9.0).abs() < 0.01, "{w}x{h} keeps the aspect"); + let resolved = resolve_request(&[s.render_backend()], &request(w, h)).unwrap(); + assert_eq!(resolved.executor.as_str(), "gpu-viewport"); + assert!(resolved.estimated_scratch_bytes <= host.limits.max_scratch_bytes); + } + } + assert_eq!(preview_size(None, (5120, 2880)), (5120, 2880), "no executor: resolution reports it"); +} diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 5a530c3a..798b4029 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -112,11 +112,12 @@ fn with_dir(v: Value, dir: &str) -> Value { } } -/// A fresh folder for one scenario's files. +/// A fresh folder for one scenario's files, under the temporary folder spelled with long names +/// (a short 8.3 `TEMP` would trip the short-name refusal before the scope checks the scenarios test). fn scratch_dir() -> PathBuf { use std::sync::atomic::{AtomicUsize, Ordering}; static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("solidcraft-scenario-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let d = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-scenario-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); let _ = std::fs::create_dir_all(&d); d } diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs index 5d58f17c..e40f40ac 100644 --- a/crates/ui-egui/src/select_other.rs +++ b/crates/ui-egui/src/select_other.rs @@ -508,8 +508,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut hovered = None; let mut picked = None; let w = crate::context_menu::LIST_W - 10.0; + // Title, rows up to the scroll height, margins and border. + let size = vec2(w + 10.0, 30.0 + (p.rows.len() as f32 * 24.0).min(360.0)); let resp = crate::frame::area(egui::Id::new("sc_select_other")) - .fixed_pos(p.at + vec2(12.0, 12.0)) + .fixed_pos(crate::context_menu::place(p.at, vec2(12.0, 12.0), size, ctx.content_rect())) .constrain(true) .order(egui::Order::Foreground) .show(ctx, |ui| { diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index 346b3b37..21632e7c 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -23,6 +23,8 @@ const KINDS: [ValueKind; FIELDS] = const FIELDS: usize = 6; /// The field shown in degrees, and the one without a unit. const ANGLE: usize = 3; +/// The fields before this one (X, Y, Height, Angle) can hold an expression of parameters. +const ANGLE_END: usize = 4; const LINE_SPACING: usize = 5; /// No input is active: a click on the canvas picks no path until the Path chip is clicked. @@ -43,6 +45,9 @@ pub struct Tx { initial: [String; FIELDS], /// The saved source: x, y, height (mm), angle (rad), letter spacing (mm) and line spacing. original: [f64; FIELDS], + /// The expressions saved behind x, y, height and angle (a field shows its expression, and + /// keeps it while untouched). + exprs: [Option; FIELDS], /// Bold, italic, alignment, the path (the Path input's curve) and its direction as chosen. pub(crate) style: TextStyle, initial_style: TextStyle, @@ -64,8 +69,9 @@ pub struct Tx { shown: Option, } -/// A text source as the dialog sees it: text, font (empty: built-in), its values and its style. -type Source = (String, String, [f64; FIELDS], TextStyle); +/// A text source as the dialog sees it: text, font (empty: built-in), its values, its style and +/// the expressions behind its values. +type Source = (String, String, [f64; FIELDS], TextStyle, [Option; FIELDS]); /// What the Text tool puts down, ready to be typed over. const PLACEHOLDER: &str = "Text"; @@ -200,13 +206,18 @@ fn source(app: &SolveApp, sketch: u64, link: &str) -> Option { } let sk = app.session.doc.sketch(sketch).ok()?; let source = sk.link(link).filter(|source| source.kind == LinkKind::Text)?; - let LinkSource::Text { text, at, height, angle, font, style } = &source.source else { return None }; + let LinkSource::Text { text, at, height, angle, font, style, exprs } = &source.source else { return None }; let values = [at.x, at.y, *height, *angle, style.letter_spacing, style.line_spacing]; - Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone())) + let exprs = [exprs.x.clone(), exprs.y.clone(), exprs.height.clone(), exprs.angle.clone(), None, None]; + Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone(), exprs)) } -fn fields(original: &[f64; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] { +/// The fields as shown: a value, or the expression behind it. +fn fields(original: &[f64; FIELDS], exprs: &[Option; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] { std::array::from_fn(|i| { + if let Some(Some(e)) = exprs.get(i) { + return e.clone(); + } let value = original.get(i).copied().unwrap_or_default(); match i { ANGLE => shown(value.to_degrees(), "deg"), @@ -252,9 +263,9 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option { let sketch = app.session.active_sketch?; let sk = app.session.doc.sketch(sketch).ok()?; let link = sk.curves.iter().find(|curve| curve.id == entity).and_then(|curve| curve.link.clone())?; - let (text, font, original, style) = source(app, sketch, &link)?; + let (text, font, original, style, exprs) = source(app, sketch, &link)?; let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units); - let values = fields(&original, unit, scale); + let values = fields(&original, &exprs, unit, scale); let mut tx = Tx { sketch, link, @@ -265,6 +276,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option { initial: values.clone(), values, original, + exprs, unit, initial_style: style.clone(), style, @@ -388,16 +400,16 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool { if k.shown.as_ref() == Some(¤t) { return true; } - let (text, font, original, style) = current; + let (text, font, original, style, exprs) = current; if style != k.initial_style { if k.style == k.initial_style { k.style = style.clone(); } k.initial_style = style; } - if original != k.original { + if original != k.original || exprs != k.exprs { let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units); - let fresh = fields(&original, unit, scale); + let fresh = fields(&original, &exprs, unit, scale); for ((value, initial), new) in k.values.iter_mut().zip(k.initial.iter_mut()).zip(fresh) { if value == initial { *value = new.clone(); @@ -405,6 +417,7 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool { *initial = new; } k.original = original; + k.exprs = exprs; k.unit = unit; } if text != k.initial_text { @@ -497,7 +510,7 @@ fn path_row(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut }; let stroke = if k.picking { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) }; let chip = egui::Button::new(text).fill(if on { t.chip } else { egui::Color32::TRANSPARENT }).stroke(stroke).min_size(egui::vec2(0.0, 22.0)); - let chip = ui.add(chip).on_hover_text(crate::i18n::tr("sketch_text.path_hint")); + let chip = crate::frame::add_outlined(ui, chip, stroke.width).on_hover_text(crate::i18n::tr("sketch_text.path_hint")); crate::scenario::publish_handle("dialog:Path", chip.rect.center()); if chip.clicked() { k.picking = true; @@ -631,41 +644,53 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result, S Ok(vec![("sketch.edit_text".into(), edit_params(app, k)?)]) } -/// `sketch.edit_text`'s parameters for the fields as they are. +/// `sketch.edit_text`'s parameters for the fields as they are. X, Y, Height and Angle go as +/// numbers, or as their expressions when these name parameters (the text then follows them): an +/// untouched field that holds one keeps it. fn edit_params(app: &SolveApp, k: &Tx) -> Result { let doc = &app.session.doc; let mut out = [0.0; FIELDS]; + let mut sent: [Value; FIELDS] = std::array::from_fn(|_| Value::Null); for (i, slot) in out.iter_mut().enumerate() { let (Some(value), Some(initial), Some(original), Some(kind), Some(label)) = (k.values.get(i), k.initial.get(i), k.original.get(i), KINDS.get(i), LABELS.get(i)) else { return Err("missing value".into()); }; + let given = k.exprs.get(i).and_then(Option::as_ref).filter(|_| value == initial); *slot = if value == initial { *original } else { - let v = doc.eval(&doc.with_design_unit(value.trim(), *kind), *kind).map_err(|e| format!("{label}: {e}"))?; + let typed = doc.with_design_unit(value.trim(), *kind); + let v = doc.eval(&typed, *kind).map_err(|e| format!("{label}: {e}"))?; if !v.is_finite() { return Err(format!("{label}: not a finite number")); } v }; + let names_parameters = i < ANGLE_END && !solidcraft_engine::doc::expr::references(value).is_empty(); + sent[i] = match given { + Some(e) => json!(e), + None if value != initial && names_parameters => json!(value.trim()), + None => json!(if i == ANGLE { slot.to_degrees() } else { *slot }), + }; } - let [x, y, height, angle, letter_spacing, line_spacing] = out; + let [_, _, _, _, letter_spacing, line_spacing] = out; + let [sent_x, sent_y, sent_height, sent_angle, ..] = sent; let align = match k.style.align { TextAlign::Left => "left", TextAlign::Center => "center", TextAlign::Right => "right", }; let mut params = json!({ - "link": k.link, "text": k.text, "at": [x, y], "height": height, + "link": k.link, "text": k.text, "at": [sent_x, sent_y], "height": sent_height, "bold": k.style.bold, "italic": k.style.italic, "align": align, "letter_spacing": letter_spacing, "line_spacing": line_spacing, "path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse, }); - // Omitting an unchanged angle keeps its exact saved radians. - if k.values.get(3) != k.initial.get(3) { - params["angle"] = json!(angle.to_degrees()); + // Omitting an unchanged angle keeps its exact saved radians (or its expression). + if k.values.get(ANGLE) != k.initial.get(ANGLE) { + params["angle"] = sent_angle; } // An untouched font stays as saved; an emptied one is the built-in font. if k.font.trim() != k.initial_font.trim() { @@ -812,6 +837,7 @@ mod tests { angle: std::f64::consts::FRAC_PI_2, font: None, style: TextStyle::default(), + exprs: Default::default(), }; assert_eq!(sk.link(&link).unwrap().source, expected); assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty()); @@ -839,7 +865,7 @@ mod tests { let LinkSource::Text { at, height, angle, .. } = original else { panic!("text source") }; assert_eq!( h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source, - LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::default() } + LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::default(), exprs: Default::default() } ); h.app.session.selection = vec![Sel::SketchCurve { id: h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone() }]; h.app.start("sketch.edit_text"); @@ -1020,6 +1046,89 @@ mod tests { assert!(sk.links.iter().any(|l| matches!(&l.source, LinkSource::Text { text, .. } if text == "Pending text"))); } + /// Selects the text `link` again (an edit makes its outlines anew). + fn select_text(h: &mut Harness, link: &str) { + let sid = h.app.session.active_sketch.unwrap(); + let sk = h.app.session.doc.sketch(sid).unwrap(); + let id = sk.curves[sk.link_curves(link)[0]].id.clone(); + h.app.session.selection = vec![Sel::SketchCurve { id }]; + h.frames(3); + } + + fn exprs_of(h: &Harness, link: &str) -> solidcraft_engine::sketch::TextExprs { + let sid = h.app.session.active_sketch.unwrap(); + let LinkSource::Text { exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source.clone() else { panic!("text source") }; + exprs + } + + #[test] + fn a_field_that_names_parameters_stays_an_expression_and_is_shown_as_typed() { + let (mut h, link, _) = setup(); + h.app.run("parameters.add", json!({"name":"tall","expression":"20 mm"})).unwrap(); + h.app.run("parameters.add", json!({"name":"turn","expression":"90 deg"})).unwrap(); + h.app.start("sketch.edit_text"); + h.frames(3); + replace(&mut h, "dialog:X", "tall + 5 mm"); + replace(&mut h, "dialog:Height", "tall / 2"); + replace(&mut h, "dialog:Angle", "turn"); + ok(&mut h); + assert!(!editing(&h.app), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone())); + let sid = h.app.session.active_sketch.unwrap(); + let LinkSource::Text { at, height, angle, exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else { + panic!("text source") + }; + assert_eq!( + (exprs.x.as_deref(), exprs.y.as_deref(), exprs.height.as_deref(), exprs.angle.as_deref()), + (Some("tall + 5 mm"), None, Some("tall / 2"), Some("turn")) + ); + assert!((at.x - 25.0).abs() < 1e-9 && (height - 10.0).abs() < 1e-9 && (angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + + // Opened again, the fields show the expressions; a number typed in one of the others + // leaves them alone. + select_text(&mut h, &link); + h.app.start("sketch.edit_text"); + h.frames(3); + assert_eq!(tx(&h.app).values[0], "tall + 5 mm"); + assert_eq!((tx(&h.app).values[2].as_str(), tx(&h.app).values[3].as_str()), ("tall / 2", "turn")); + replace(&mut h, "dialog:Y", "7"); + ok(&mut h); + let exprs = exprs_of(&h, &link); + assert_eq!( + (exprs.x.as_deref(), exprs.y.as_deref(), exprs.height.as_deref(), exprs.angle.as_deref()), + (Some("tall + 5 mm"), None, Some("tall / 2"), Some("turn")) + ); + + // A parameter changes while the saved numbers are not yet worked out again: OK on an + // untouched dialog keeps the expressions, and the numbers follow the parameter. + h.app.run("parameters.change", json!({"name":"tall","expression":"40 mm"})).unwrap(); + select_text(&mut h, &link); + h.app.start("sketch.edit_text"); + h.frames(3); + ok(&mut h); + let LinkSource::Text { at, height, exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else { + panic!("text source") + }; + assert_eq!((exprs.x.as_deref(), exprs.height.as_deref()), (Some("tall + 5 mm"), Some("tall / 2"))); + assert!((at.x - 45.0).abs() < 1e-9 && (at.y - 7.0).abs() < 1e-9 && (height - 20.0).abs() < 1e-9, "{at:?} {height}"); + + // A number typed over an expression replaces it. + select_text(&mut h, &link); + h.app.start("sketch.edit_text"); + h.frames(3); + replace(&mut h, "dialog:Height", "6"); + ok(&mut h); + let exprs = exprs_of(&h, &link); + assert_eq!((exprs.height, exprs.x.as_deref()), (None, Some("tall + 5 mm"))); + // A name that is no parameter is an error in the dialog; nothing changes. + select_text(&mut h, &link); + h.app.start("sketch.edit_text"); + h.frames(3); + replace(&mut h, "dialog:Height", "nope * 2"); + h.frames(2); + ok(&mut h); + assert!(editing(&h.app), "the dialog stays open on a field that cannot be used"); + } + #[test] fn fields_take_expressions_with_units_and_parameters() { let (mut h, link, _) = setup(); diff --git a/crates/ui-egui/src/sketch_text_handles.rs b/crates/ui-egui/src/sketch_text_handles.rs index b2e4cc3b..3f643a5f 100644 --- a/crates/ui-egui/src/sketch_text_handles.rs +++ b/crates/ui-egui/src/sketch_text_handles.rs @@ -245,10 +245,13 @@ pub(crate) fn drag_to(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) { let Some(d) = DRAG.with(|d| d.borrow().clone()) else { return }; let Some(to) = on_plane(app, proj, pos) else { return }; let Some((at, height, angle)) = follow(&d, to, shift) else { return }; - let mut params = json!({"link": d.start.link, "at": [at.x, at.y], "height": height, "live": true}); - // A resize keeps the exact saved angle. + let mut params = json!({"link": d.start.link, "at": [at.x, at.y], "live": true}); + // A resize keeps the exact saved angle; a rotation keeps the height (and the expression + // behind it, if it has one). if d.grab == Grab::Rotate { params["angle"] = json!(angle.to_degrees()); + } else { + params["height"] = json!(height); } if app.session.execute("sketch.edit_text", ¶ms).is_ok() { app.session.undo.truncate(d.depth + 1); @@ -449,6 +452,41 @@ mod tests { assert_eq!(h.app.session.doc.to_json(), before.0); } + #[test] + fn turning_a_text_keeps_the_expression_of_its_height_and_resizing_replaces_it() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("parameters.add", json!({"name":"tall","expression":"10 mm"})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + let result = h.app.run("sketch.text", json!({"text":"II","at":[0,0],"height":"tall"})).unwrap(); + let link = result["link"].as_str().unwrap().to_owned(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 60.0; + h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }]; + h.frames(3); + let height_expr = |h: &Harness| { + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let LinkSource::Text { exprs, .. } = &sk.link(&link).unwrap().source else { panic!("text") }; + exprs.height.clone() + }; + assert_eq!(height_expr(&h).as_deref(), Some("tall")); + let f = frame(&h.app, &link).unwrap(); + let center = f.to_sketch(f.center()); + let to = center + rotate(Vec2::new(0.0, 15.0), 40_f64.to_radians()); + h.step(&json!({"drag":[{"handle":"text_rotate"},{"sketch":[to.x,to.y]}],"steps":6})).unwrap(); + h.frames(2); + let (_, height, angle) = source(&h, &link); + assert!(angle.abs() > 0.5 && height == 10.0, "turned: {angle} {height}"); + assert_eq!(height_expr(&h).as_deref(), Some("tall"), "turning leaves the height alone"); + let f = frame(&h.app, &link).unwrap(); + let pivot = f.to_sketch(f.min); + let target = pivot + (f.to_sketch(f.max) - pivot) * 2.0; + h.step(&json!({"drag":[{"handle":"text_corner_2"},{"sketch":[target.x,target.y]}],"steps":6})).unwrap(); + h.frames(2); + assert!((source(&h, &link).1 - 20.0).abs() < 0.5); + assert_eq!(height_expr(&h), None, "a height set by hand is a number"); + } + #[test] fn the_rotation_knob_turns_the_text_in_steps_with_shift() { let (mut h, link) = setup(); diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs index c6274776..83ba676e 100644 --- a/crates/ui-egui/src/stand_up.rs +++ b/crates/ui-egui/src/stand_up.rs @@ -1,7 +1,7 @@ //! Standing up a Y-up file (#3). Many CAD programs save parts with Y up, so a STEP or IGES file //! from them opens lying on its back in a Z-up design. After such a file opens, SolidCraft -//! offers to stand it up: a quarter turn about X (Y → Z) of the bodies it brought, as one -//! Move feature that can be undone or edited. Preferences › Design › "Offer to stand up Y-up +//! offers to stand it up: a quarter turn about X (Y → Z) of the component the file came in as +//! (#1386), so an assembly's parts and instances turn together, in one step that can be undone. Preferences › Design › "Offer to stand up Y-up //! files" turns the offer off; with Y up as the default orientation it isn't needed. use serde_json::json; @@ -9,8 +9,9 @@ use serde_json::json; use crate::SolveApp; /// The bodies a just-opened file brought (`before`: body names before it opened, none for a -/// new design): offer to stand them up. -pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) { +/// new design), and the import's component placement from the command's `result`: offer to +/// stand them up. +pub fn offer(app: &mut SolveApp, path: &str, before: &[String], result: Option<&serde_json::Value>) { let io = &solidcraft_engine::io::is_step_path; if !(io(path) || solidcraft_engine::io::is_iges_path(path)) || !app.preferences.offer_stand_up || app.preferences.up_axis != "z" { return; @@ -18,13 +19,19 @@ pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) { let bodies: Vec = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|n| !before.contains(n)).collect(); if !bodies.is_empty() { app.stand_up = Some(bodies); + app.stand_up_occurrence = result.and_then(|r| r.get("occurrence")).and_then(serde_json::Value::as_u64); } } /// Turn the offered bodies a quarter turn about X (Y up becomes Z up). pub fn stand_up(app: &mut SolveApp) -> Result<(), String> { let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) }; - app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })).map_err(|e| e.to_string())?; + match app.stand_up_occurrence.take() { + // The import's component turns about its parent's origin, contents and all. + Some(occurrence) => app.run("occurrence.move", json!({ "occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg" })), + None => app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })), + } + .map_err(|e| e.to_string())?; app.animate_view("home"); Ok(()) } @@ -62,9 +69,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.set_status(e, true); } } - Some("leave") => app.stand_up = None, + Some("leave") => { + app.stand_up = None; + app.stand_up_occurrence = None; + } Some("never") => { app.stand_up = None; + app.stand_up_occurrence = None; app.preferences.offer_stand_up = false; } _ => {} diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index b8807a2f..7d2bdebd 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -3,13 +3,19 @@ //! double-click there maximizes or restores it, caption buttons at the far right minimize, //! maximize/restore and close, and thin invisible zones along the window's edges resize it. //! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners) -//! works as with a native title bar. macOS keeps its traffic lights over the bar +//! works as with a native title bar. +//! +//! The resize zones are as wide as Windows' own resize border at the window's scale +//! ([`resize_border_px`]), corners win where they overlap the edges ([`resize_hit`]). On Windows +//! the desktop app answers the system's hit test with them (`apps/solidcraft/src/native_frame.rs`), +//! so the OS shows its resize cursors and resizes from the first frame; elsewhere egui zones above +//! everything else start an OS resize ([`resize_zones`]). macOS keeps its traffic lights over the bar //! ([`SolveApp::integrated_titlebar`]); the web build has neither. //! //! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0). use crate::icons::glyphs::paint_glyph; -use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use crate::SolveApp; use crate::theme::Tokens; @@ -20,9 +26,8 @@ pub const BUTTON_WIDTH: f32 = 46.0; pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; /// Room for the macOS traffic lights at the bar's left end. pub const TRAFFIC_LIGHTS: f32 = 76.0; -/// Resize zone thickness along an edge, and the side of a corner zone. -const EDGE: f32 = 5.0; -const CORNER: f32 = 12.0; +/// Windows' resize border at 100 % (96 DPI): `SM_CXSIZEFRAME` + `SM_CXPADDEDBORDER`, 4 + 4 px. +pub const RESIZE_BORDER_AT_96_DPI: f32 = 8.0; /// The Close button's hover colour (as on Windows). const CLOSE_HOVER: Color32 = Color32::from_rgb(196, 43, 28); @@ -128,11 +133,23 @@ pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) { let _ = window_action(app, &ui.ctx().clone(), action); } -/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). -/// Corners come last so they win where they overlap the edges. -fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] { +/// The resize border in physical pixels at `scale` (1.0 = 96 DPI): Windows' default, scaled and +/// rounded, never less than a pixel. +pub fn resize_border_px(scale: f32) -> f32 { + let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + (RESIZE_BORDER_AT_96_DPI * s).round().max(1.0) +} + +/// The side of a corner zone for an edge `edge` wide: twice the edge, so a corner is easy to hit. +pub fn corner_size(edge: f32) -> f32 { + 2.0 * edge +} + +/// The edge and corner zones of a window with rect `w`, edges `edge` wide: (zone, direction, +/// cursor). Corners come last so they win where they overlap the edges. +pub fn zones(w: Rect, edge: f32) -> [(Rect, ResizeDirection, CursorIcon); 8] { use ResizeDirection::*; - let (e, c) = (EDGE, CORNER); + let (e, c) = (edge, corner_size(edge)); [ (Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical), (Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical), @@ -145,6 +162,28 @@ fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] { ] } +/// The resize direction at `p` in a window with rect `w` and edges `edge` wide, `None` away +/// from the edges and outside the window. A point is in the window when `min <= p < max` (pixel +/// rects: the right and bottom pixels are at `max - 1`). Corners win over edges. +pub fn resize_hit(w: Rect, p: Pos2, edge: f32) -> Option { + if !(w.is_positive() && edge > 0.0 && p.x >= w.left() && p.x < w.right() && p.y >= w.top() && p.y < w.bottom()) { + return None; + } + zones(w, edge).into_iter().rev().find(|(r, ..)| p.x >= r.left() && p.x < r.right() && p.y >= r.top() && p.y < r.bottom()).map(|(_, d, _)| d) +} + +/// The window's new physical size when it moves from `old_dpi` to `new_dpi` (Windows' +/// `WM_GETDPISCALEDSIZE`): the same logical size, rounded as winit rounds the size it then +/// applies, so the rect the OS suggests mid-move is exactly the rect the window gets. +pub fn dpi_scaled_size(size: (i32, i32), old_dpi: u32, new_dpi: u32) -> Option<(i32, i32)> { + if old_dpi == 0 || new_dpi == 0 || size.0 <= 0 || size.1 <= 0 { + return None; + } + let (old, new) = (f64::from(old_dpi) / 96.0, f64::from(new_dpi) / 96.0); + let scale = |v: i32| (f64::from(v) / old * new).round().clamp(1.0, f64::from(i32::MAX)) as i32; + Some((scale(size.0), scale(size.1))) +} + /// Invisible resize zones along the window's edges, above everything else; a press in one /// starts an OS resize in that direction. None while the window is maximized or fullscreen. pub fn resize_zones(ui: &mut Ui) { @@ -153,9 +192,11 @@ pub fn resize_zones(ui: &mut Ui) { return; } let w = ctx.content_rect(); + let ppp = ctx.pixels_per_point(); + let edge = resize_border_px(ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or(ppp)) / ppp; let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize")); let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w)); - for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() { + for (i, (r, dir, cursor)) in zones(w, edge).into_iter().enumerate() { let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); if resp.drag_started_by(PointerButton::Primary) { ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); @@ -177,6 +218,8 @@ mod tests { width: f32, time: f64, maximized: bool, + /// Lay out the whole window (`SolveApp::ui`) rather than only the bar and the zones. + full: bool, } impl Win { @@ -184,7 +227,7 @@ mod tests { let ctx = egui::Context::default(); let mut app = SolveApp::new(Session::default(), Default::default()); app.custom_titlebar = true; - Self { ctx, app, width, time: 0.0, maximized: false } + Self { ctx, app, width, time: 0.0, maximized: false, full: false } } fn frame(&mut self, events: Vec) -> egui::FullOutput { @@ -198,8 +241,12 @@ mod tests { if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) { v.maximized = Some(self.maximized); } - let app = &mut self.app; + let (app, full) = (&mut self.app, self.full); let mut out = self.ctx.run_ui(input, |ui| { + if full { + app.ui(ui); + return; + } crate::toolbar::app_bar(app, ui); if app.custom_titlebar { resize_zones(ui); @@ -326,4 +373,101 @@ mod tests { let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]); assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); } + + #[test] + fn resize_border_is_windows_default_for_the_scale() { + for (dpi, px) in [(96.0, 8.0), (120.0, 10.0), (144.0, 12.0), (168.0, 14.0), (192.0, 16.0), (288.0, 24.0)] { + assert_eq!(resize_border_px(dpi / 96.0), px, "at {dpi} DPI"); + } + // Never 0 px, whatever the scale says. + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!(resize_border_px(bad), 8.0, "{bad}"); + } + assert_eq!(resize_border_px(0.01), 1.0); + assert_eq!(corner_size(resize_border_px(1.5)), 24.0); + } + + #[test] + fn hit_test_edges_corners_and_inside() { + use ResizeDirection::*; + // A window at (100, 50), 1000 x 700 px, at 150 %: 12 px edges, 24 px corners. + let w = Rect::from_min_size(pos2(100.0, 50.0), vec2(1000.0, 700.0)); + let e = resize_border_px(1.5); + let (l, t, r, b) = (100.0, 50.0, 1099.0, 749.0); // the outermost pixels + for (p, want) in [ + ((l, 400.0), Some(West)), + ((l + 11.0, 400.0), Some(West)), + ((l + 12.0, 400.0), None), + ((r, 400.0), Some(East)), + ((r - 11.0, 400.0), Some(East)), + ((r - 12.0, 400.0), None), + ((600.0, t), Some(North)), + ((600.0, t + 12.0), None), + ((600.0, b), Some(South)), + ((600.0, b - 11.0), Some(South)), + ((l, t), Some(NorthWest)), + ((r, t), Some(NorthEast)), + ((l, b), Some(SouthWest)), + ((r, b), Some(SouthEast)), + // Corners win over the edges, along both edges for the corner's length. + ((l + 23.0, t), Some(NorthWest)), + ((l, t + 23.0), Some(NorthWest)), + ((l + 24.0, t), Some(North)), + ((l, t + 24.0), Some(West)), + ((r - 23.0, b), Some(SouthEast)), + ((r, b - 23.0), Some(SouthEast)), + ((r - 23.0, t), Some(NorthEast)), + ((l, b - 23.0), Some(SouthWest)), + ((600.0, 400.0), None), + // Outside the window: not ours. + ((l - 1.0, 400.0), None), + ((r + 1.0, 400.0), None), + ((600.0, t - 1.0), None), + ((600.0, b + 1.0), None), + ] { + assert_eq!(resize_hit(w, pos2(p.0, p.1), e), want, "at {p:?}"); + } + assert_eq!(resize_hit(w, pos2(l, 400.0), 0.0), None); + assert_eq!(resize_hit(Rect::NOTHING, pos2(l, 400.0), e), None); + // Every DPI has a zone on every edge. + for dpi in [96.0, 120.0, 144.0, 192.0] { + let e = resize_border_px(dpi / 96.0); + for (p, d) in [((l, 400.0), West), ((r, 400.0), East), ((600.0, t), North), ((600.0, b), South)] { + assert_eq!(resize_hit(w, pos2(p.0, p.1), e), Some(d), "{d:?} at {dpi} DPI"); + } + } + } + + #[test] + fn dpi_change_keeps_the_logical_size() { + assert_eq!(dpi_scaled_size((1600, 1000), 96, 144), Some((2400, 1500))); + assert_eq!(dpi_scaled_size((2400, 1500), 144, 96), Some((1600, 1000))); + assert_eq!(dpi_scaled_size((1001, 777), 120, 192), Some((1602, 1243))); + assert_eq!(dpi_scaled_size((1001, 777), 96, 96), Some((1001, 777))); + assert_eq!(dpi_scaled_size((1001, 777), 0, 96), None); + assert_eq!(dpi_scaled_size((0, 777), 96, 144), None); + } + + #[test] + fn edges_resize_on_the_start_page() { + // At launch without a design the start page shows; its layout returned before the + // resize zones were laid out, so the edges did nothing until a design opened (#1381). + let mut w = Win::new(1200.0); + w.full = true; + let rev = w.app.session.revision; + w.app.home.show(rev); + w.frame(vec![]); + assert!(w.app.home.open); + let p = pos2(1199.0, 350.0); + let cmds = w.gesture(&[p, p + vec2(6.0, 0.0)]); + assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::East)]); + let p = pos2(1.0, 699.0); + let cmds = w.gesture(&[p, p + vec2(-6.0, 6.0)]); + assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::SouthWest)]); + // Where the OS hit test offers the border, egui lays out no zones of its own. + w.app.native_resize_border = true; + let p = pos2(1199.0, 350.0); + let cmds = w.gesture(&[p, p + vec2(6.0, 0.0)]); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); + } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index caa85a3a..9ae138f4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -166,6 +166,34 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32) -> ( (Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled) } +/// An application-bar button for something on offer (Update, Install) at the right of `span` (the +/// bar's free space): an accent-framed `icon`, labeled with `text` when `labeled` and it fits. +/// None when even the icon alone doesn't fit: it never covers the bar's other buttons. `handle` +/// names it for scenarios. +pub(crate) fn offer_button(ui: &mut egui::Ui, span: Rect, labeled: bool, text: &str, icon: &str, handle: &str) -> Option<(Rect, egui::Response)> { + let t = Tokens::get(); + let full = 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), t.app_bar_text).size().x) + 10.0; + let labeled = labeled && full <= span.width(); + let width = if labeled { full } else { 28.0 }; + if width > span.width() { + return None; + } + let rect = Rect::from_min_size(pos2(span.right() - width, span.top()), vec2(width, 26.0)); + let response = ui.interact(rect, ui.id().with(("sc_offer_button", handle)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); + crate::scenario::publish_handle(handle, rect.center()); + crate::scenario::publish_rect(handle, rect); + let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; + ui.painter().rect_filled(rect, 4.0, fill); + ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + let glyph = Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), vec2(18.0, 18.0)); + icons::paint(ui.painter(), glyph, icon, t.accent, t.accent, t.app_bar); + if labeled { + ui.painter().text(pos2(rect.left() + 29.0, rect.center().y), Align2::LEFT_CENTER, text, FontId::proportional(13.0), t.app_bar_text); + } + Some((rect, response)) +} + /// Where the Report a bug text starts, from the button's left edge. const REPORT_TEXT_X: f32 = 29.0; @@ -206,6 +234,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, idle, &label)); crate::scenario::publish_handle("appbar:file", br.center()); crate::scenario::publish_handle("appbar:folder", br.center()); + crate::scenario::publish_rect("appbar:file", br); let open = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_file_menu")).unwrap_or(false)); if idle && (resp.hovered() || open) { ui.painter().rect_filled(br, 4.0, t.app_bar_hover); @@ -222,6 +251,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center()); + crate::scenario::publish_rect(&format!("appbar:{icon}"), br); if enabled && resp.hovered() { ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); } @@ -277,8 +307,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { d.insert_temp(egui::Id::new("sc_help_anchor"), at); }); } - // A newer release: its Update button, left of Report a bug (`update`). - let update = crate::update::button(app, ui, report.left() - 6.0, report.top(), report_label); + // A newer release: its Update button, left of Report a bug (`update`), in the space up to + // the bar's own buttons on the left (File, Undo and Redo, which end before `x`). + let span = Rect::from_min_max(pos2(x, report.top()), pos2(report.left() - 6.0, report.bottom())); + let update = crate::update::button(app, ui, span, report_label); let tabs_end = update.map_or(report.left(), |u| u.left()) - 8.0; // Narrow title bars can fit only part of the tab strip. Its hit regions must not // extend into the persistent report and window controls. @@ -289,6 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let response = ui.interact(report, ui.id().with("sc_report_button"), Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), report_text)); crate::scenario::publish_handle("titlebar:report", report.center()); + crate::scenario::publish_rect("titlebar:report", report); ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }); ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0)); @@ -390,6 +423,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { app.start("file.insert_mesh"); close = true; } + if item_if(ui, &format!("{}…", app.tr_label("Insert Design")), "", shown) { + app.start("component.insert_design"); + close = true; + } if item_if(ui, app.tr("ui.save"), "Ctrl+S", shown) { save(app); close = true; @@ -429,6 +466,16 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { close = true; } ui.separator(); + // The browser can install SolidCraft as an app (`install`): only while it offers to. + if let Some(offer) = crate::install::offered(app) + && ui + .add(egui::Button::new(crate::i18n::tr("install.menu")).min_size(vec2(180.0, 22.0))) + .on_hover_text(crate::i18n::tr("install.button_tip")) + .clicked() + { + crate::install::start(app, offer); + close = true; + } if item(ui, &format!("{}…", app.tr("ui.preferences")), "") { app.prefs_window.open = true; close = true; @@ -603,7 +650,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if tab == "SKETCH" && panel == "CREATE" { (count.max(1) as f32 * 68.0).max(64.0) + 8.0 } else { panel_width(count) } }; let widths: Vec = lists.iter().map(|(panel, _, shown)| width_of(panel, shown.len())).collect(); - let fits = fit(&widths, r.right() - 40.0 - px); + // The AI button keeps the toolbar row's right end, whatever the ribbon's width + // (docs/agent-chat.md); panels that no longer fit go to the » menu as usual. + let right = if crate::agent_chat::toolbar_button_shown(app) { r.right() - AI_BUTTON_ROOM } else { r.right() }; + let fits = fit(&widths, right - 40.0 - px); crate::scenario::publish_count("toolbar_collapsed", fits.iter().filter(|f| **f == Fit::Collapsed).count() as f64); crate::scenario::publish_count("toolbar_more", fits.iter().filter(|f| **f == Fit::More).count() as f64); for ((panel, cmds, shown), fit) in lists.into_iter().zip(fits) { @@ -863,7 +913,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { px += 2.0; } if !overflow.is_empty() { - let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0)); + let at = Rect::from_min_size(pos2(px.min(right - 40.0), top + 4.0), vec2(38.0, 52.0)); ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| { let response = ui.menu_button("»", |ui| { for (panel, cmds) in &overflow { @@ -891,6 +941,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { response.response.on_hover_text(crate::i18n::tr_label("More toolbar panels")); }); } + if right < r.right() { + ai_button(app, ui, Rect::from_min_max(pos2(right + 4.0, r.top() + 4.0), pos2(r.right() - 6.0, r.bottom() - 5.0))); + } painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); // Dragging a command: its icon follows the pointer; a toolbar button dragged off the // toolbar (or into the open drop-down) shows it will be removed. @@ -920,6 +973,40 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { }); } +/// Room the AI button takes at the toolbar row's right end. +const AI_BUTTON_ROOM: f32 = 52.0; + +/// The AI button: shows and hides the agent chat panel (lit while it is open). +fn ai_button(app: &mut SolveApp, ui: &mut egui::Ui, at: Rect) { + let t = Tokens::get(); + let resp = ui.interact(at, ui.id().with("ai_button"), Sense::click()); + crate::scenario::publish_handle("toolbar:ai", at.center()); + let open = app.agent_chat.open; + let painter = ui.painter(); + painter.line_segment([pos2(at.left() - 4.0, at.top() + 4.0), pos2(at.left() - 4.0, at.bottom() - 4.0)], Stroke::new(1.0, t.border)); + if open || resp.hovered() { + painter.rect_filled(at, 4.0, if open { t.accent_soft } else { t.hover }); + } + icons::paint( + painter, + Rect::from_center_size(pos2(at.center().x, at.top() + 24.0), vec2(26.0, 26.0)), + "agent_chat", + t.icon, + t.icon_fill, + t.accent, + ); + painter.text(pos2(at.center().x, at.top() + 52.0), Align2::CENTER_CENTER, crate::i18n::tr("ui.ai_button"), FontId::proportional(11.0), t.text); + let waiting = app.agent_chat.waiting(); + if waiting > 0 { + let c = pos2(at.right() - 6.0, at.top() + 9.0); + painter.circle_filled(c, 6.0, t.warning); + painter.text(c, Align2::CENTER_CENTER, waiting.min(9).to_string(), FontId::proportional(9.5), t.toolbar); + } + if resp.on_hover_text(crate::agent_chat::panel_tip()).clicked() { + crate::agent_chat::toggle(app); + } +} + /// What a click on a panel drop-down row asks for. enum RowAction { None, @@ -1174,6 +1261,11 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { }); // Home hides the design: only New, Open and Preferences work there. let shown = design_shown(app); + if shown { + crate::edit_clipboard::handle(app, ctx); + } else { + crate::edit_clipboard::flush(ctx); + } for k in keys { use egui::Key; if mods.command { @@ -1190,6 +1282,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { _ if !shown => {} Key::Z => drop(app.run("edit.undo", json!({}))), Key::Y => drop(app.run("edit.redo", json!({}))), + // Copy, Cut and Paste (`edit_clipboard::handle`). + Key::C | Key::X | Key::V if !mods.shift && !mods.alt => {} Key::S => save(app), _ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)), } diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 3f0c171a..b49c6003 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError}; -use egui::{Color32, FontId, RichText, text::LayoutJob}; +use egui::{FontId, RichText, text::LayoutJob}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -714,34 +714,10 @@ pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec)> { // ---- user interface --------------------------------------------------------------------------- -/// The application bar's Update button, right of `right`; returns its rect (None: not shown). -pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, right: f32, top: f32, labeled: bool) -> Option { +/// The application bar's Update button, at the right of `span`; returns its rect (None: not shown). +pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, span: egui::Rect, labeled: bool) -> Option { offered(app)?; - let t = Tokens::get(); - let text = crate::i18n::tr("update.button"); - let width = if labeled { - 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), Color32::WHITE).size().x) + 10.0 - } else { - 28.0 - }; - let rect = egui::Rect::from_min_size(egui::pos2(right - width, top), egui::vec2(width, 26.0)); - let response = ui.interact(rect, ui.id().with("sc_update_button"), egui::Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); - crate::scenario::publish_handle("titlebar:update", rect.center()); - let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; - ui.painter().rect_filled(rect, 4.0, fill); - ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), egui::vec2(18.0, 18.0)); - crate::icons::paint(ui.painter(), icon, "update", t.accent, t.accent, t.app_bar); - if labeled { - ui.painter().text( - egui::pos2(rect.left() + 29.0, rect.center().y), - egui::Align2::LEFT_CENTER, - text, - FontId::proportional(13.0), - t.app_bar_text, - ); - } + let (rect, response) = crate::toolbar::offer_button(ui, span, labeled, crate::i18n::tr("update.button"), "update", "titlebar:update")?; let version = app.updater.release.as_ref().map(|r| r.version().to_string()).unwrap_or_default(); if response.on_hover_text(crate::i18n::format("update.button_tip", &[("version", &version)])).clicked() { app.updater.open = !app.updater.open; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a09cf4aa..12313a5a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -11,6 +11,7 @@ use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; +use crate::navigation::{CameraInput, NavCaps, OrbitSnap, orbit_drag}; use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad}; use crate::theme::Tokens; use crate::{SolveApp, icons}; @@ -44,10 +45,15 @@ pub struct ViewportState { pick_bounds: std::sync::Mutex>, /// The model scene's blocks of bodies by key, from the last scene built. pub(crate) blocks: std::sync::Mutex>, + /// View-dependent display refinement (#1320): which bodies are drawn finer than the chosen + /// display quality, and their refined plans. + pub(crate) lod: std::sync::Mutex, + /// The view and model the refinement was last chosen for. + lod_view: u64, /// Each body mesh's box (the mesh held, so its address stays its own), from the last state. mesh_bounds: std::sync::Mutex, solidcraft_engine::geom::Aabb3)>>, /// The last click in the view was the middle button's (for the middle double-click). - middle_clicked: bool, + pub(crate) middle_clicked: bool, /// The last pick (its key and hits): reused while nothing it depends on changed. last_pick: Option<(u64, Vec)>, pub rect: Option, @@ -71,7 +77,7 @@ pub struct ViewportState { /// when the button went down, so the drag neither selects nor moves sketch geometry. pub primary_nav: Option, /// Button-free gesture and its last owned pointer position, kept through pauses. - modifier_nav: Option<(NavMode, Pos2)>, + pub(crate) modifier_nav: Option<(NavMode, Pos2)>, /// What a click would pick now (after the active command's filter). pub hover: Option, pub mouse: Option, @@ -86,13 +92,13 @@ pub struct ViewportState { /// Continuous orbit: the view turns about the vertical until the next click or key. pub spin: bool, /// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends. - wheel_zoom_raw_until_idle: bool, + pub(crate) wheel_zoom_raw_until_idle: bool, /// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so /// two-finger input is not replayed as zoom after it pans. - trackpad_until_idle: bool, + pub(crate) trackpad_until_idle: bool, /// Inside a bracketed trackpad scroll (`Start` without its `End` yet): /// point moves pan, bare moves are a mouse. - trackpad_gesture: bool, + pub(crate) trackpad_gesture: bool, /// A primary press on the axis gizmo: the pointer position used by its last orbit step. pub axis_drag: Option, /// Unsnapped camera while Shift-orbit previews a standard or diagonal view. @@ -274,6 +280,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.ui.visual_style.hash(&mut h); app.ui.ground_shadow.hash(&mut h); solidcraft_engine::doc::display_quality::current().hash(&mut h); + lod_generation(app).hash(&mut h); // The section plane (its cut outlines are part of the model scene). section_plane(app).map(|(o, n)| [o.x, o.y, o.z, n.x, n.y, n.z].map(f64::to_bits)).hash(&mut h); app.cam.up.hash(&mut h); @@ -372,7 +379,8 @@ const SS_SETTLE: f64 = 0.25; /// The display plan the model scene draws `b` from on the GPU, or `None` when its faces are built /// here from the reference mesh. Opaque shaded bodies (`opaque`: not faded, and no see-through /// body or face) are drawn from their plans where the device evaluates them; faded and -/// see-through ones (sorted and blended per triangle) are built here. +/// see-through ones (sorted and blended per triangle) are built here. A body seen up close is +/// drawn from its refined plan ([`crate::display_lod`]). fn drawn_plan( app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, @@ -380,11 +388,22 @@ fn drawn_plan( ) -> Option> { let shaded = app.ui.visual_style != 2; (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess)) - .then(|| b.display_plan()) + .then(|| refined(app, b).map(|(plan, _)| plan).or_else(|| b.display_plan())) .flatten() .filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH) } +/// The refined plan and mesh `b` is drawn from, when the view has it drawn finer than the chosen +/// display quality (#1320). +fn refined(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody) -> crate::display_lod::Refined { + app.viewport.lod.lock().ok()?.refined(b, solidcraft_engine::doc::display_quality::current()) +} + +/// Changes whenever a body's refinement changes (the scene and highlight keys). +fn lod_generation(app: &SolveApp) -> u64 { + app.viewport.lod.lock().map_or(0, |l| l.generation) +} + /// The mesh whose triangles the model scene shows for `b`'s faces: the plan's (evaluated on the /// CPU, the same triangles the GPU draws) or [`drawn_mesh`]. A face highlighted over the model /// must use the same triangles: another triangulation of the same face fights it at equal depth @@ -394,6 +413,10 @@ fn shown_mesh( b: &solidcraft_engine::doc::ModelBody, active_bodies: &Option>, ) -> std::sync::Arc { + if let Some((_, mesh)) = refined(app, b) { + // Refined: faces drawn from the refined plan on the GPU or built from its mesh here. + return mesh; + } let doc = &app.session.doc; let opaque = || !body_faded(app, b, active_bodies) && doc.model_body_opacity(b) >= 0.999 && doc.face_colors(b).iter().all(|(_, l)| l.opacity >= 0.999); @@ -401,18 +424,84 @@ fn shown_mesh( .then(|| drawn_plan(app, b, true)) .flatten() .and_then(|_| b.plan_mesh()) - .unwrap_or_else(|| drawn_mesh(b)) + .unwrap_or_else(|| drawn_mesh(app, b)) } /// The mesh the model scene builds `b` from on the CPU (its edges always, its faces when they -/// are not drawn from the plan on the GPU): the reference mesh at Normal display quality, else -/// the plan's mesh at the chosen quality (finer or coarser faces and edges alike), falling back -/// to the reference mesh when the body has no plan. -fn drawn_mesh(b: &solidcraft_engine::doc::ModelBody) -> std::sync::Arc { +/// are not drawn from the plan on the GPU): a refined plan's mesh when the view has one for it +/// (#1320), the reference mesh at Normal display quality, else the plan's mesh at the chosen +/// quality (finer or coarser faces and edges alike), falling back to the reference mesh when the +/// body has no plan. +fn drawn_mesh(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody) -> std::sync::Arc { use solidcraft_engine::doc::display_quality::{DisplayQuality, current}; + if let Some((_, mesh)) = refined(app, b) { + return mesh; + } if current() == DisplayQuality::Normal { b.mesh() } else { b.plan_mesh().unwrap_or_else(|| b.mesh()) } } +/// Choose which bodies to draw finer than the chosen display quality for this view (#1320): +/// each shown body's screen-space error from its box on screen, refinements planned off the UI +/// thread and swapped in when ready. Keeps repainting while refinements are being planned. +fn update_refinement(app: &mut SolveApp, ctx: &egui::Context, proj: &Proj) { + if app.viewport.gpu.is_none() || !crate::display_lod::enabled() { + return; // The CPU picture draws the reference mesh. + } + let q = solidcraft_engine::doc::display_quality::current(); + // Nothing to choose again while the view, the model and the planning stand still. + let mut h = std::collections::hash_map::DefaultHasher::new(); + scene_key(app).hash(&mut h); + crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); + let view = h.finish(); + // While refinements are being planned, the view standing still: only collect finished ones. + if view == app.viewport.lod_view { + let (busy, finished) = app.viewport.lod.lock().map_or((false, false), |mut l| { + let finished = l.collect(); + (l.busy(), finished) + }); + if busy { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + if !finished { + return; + } + } + let t0 = crate::now_ms(); + app.viewport.lod_view = view; + let st = app.session.world_state(); + let ppp = f64::from(app.viewport.pixels_per_point()); + let lens = crate::display_lod::Lens::new(&proj.cam, f64::from(proj.rect.height()) * ppp); + let bounds = body_bounds(app, &st); + let mut live = std::collections::HashSet::with_capacity(st.bodies.len()); + let mut candidates = Vec::new(); + for (b, (name, bx)) in st.bodies.iter().zip(bounds.iter()) { + let id = crate::display_lod::body_id(b).0; + live.insert(id); + if b.name != *name || b.body.is_mesh() { + continue; + } + let shown = app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name); + let on_screen = match screen_box(proj, bx, 0.0).filter(|_| shown) { + Some(r) => r.intersects(proj.rect).then(|| f64::from(r.width().max(r.height())) * ppp), + // A corner behind the eye: the view is inside or right by the box. + None => (shown && proj.cam.fov > 0.0 && lens.in_front(bx)).then_some(f64::INFINITY), + }; + let err_px = crate::display_lod::error_px(q, b.body.size(), bx, on_screen, &lens); + candidates.push(crate::display_lod::Candidate { body: b, err_px, triangles: b.mesh().triangles.len() }); + } + let busy = match app.viewport.lod.lock() { + Ok(mut lod) => { + lod.update(q, candidates, &live); + lod.stats.choose_ms = crate::now_ms() - t0; + lod.busy() + } + Err(_) => false, + }; + if busy { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } +} + /// A model scene block kept by the viewport: its bytes and the meshes they were built from /// (held, so no other mesh can take their addresses while the block is kept). pub(crate) struct CachedBlock { @@ -561,7 +650,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { .collect(); sc.planned.push(crate::gpu::PlannedBody { plan, face_colors }); } - let body = BodyDraw { mesh: drawn_mesh(b), col, faded, opacity, face_looks, on_gpu }; + let body = BodyDraw { mesh: drawn_mesh(app, b), col, faded, opacity, face_looks, on_gpu }; let key = body.key(shaded, edges, hidden_edges, edge_col, hidden_col); key.hash(&mut run_key); run.push(body); @@ -937,7 +1026,7 @@ fn screen_box(proj: &Proj, b: &solidcraft_engine::geom::Aabb3, px: f32) -> Optio /// What is under `pos`, reusing the last answer while the cursor, the camera and the model are /// unchanged. -fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { +pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { let mut h = std::collections::hash_map::DefaultHasher::new(); pos.x.to_bits().hash(&mut h); pos.y.to_bits().hash(&mut h); @@ -1683,6 +1772,7 @@ fn highlight_key(app: &SolveApp) -> u64 { app.viewport.hover_feature.hash(&mut h); app.viewport.hover_bodies.hash(&mut h); // Which mesh lights a face follows how the model draws its body (see `shown_mesh`). + lod_generation(app).hash(&mut h); app.session.active_component.hash(&mut h); app.session.active_occurrence_path.hash(&mut h); app.ui.visual_style.hash(&mut h); @@ -1783,7 +1873,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { // A live preview stands in for the body: its old faces would fight with it. if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) { face_tris(&mut sc, &shown(b), *index, c4(t.sel_face), false); - hidden_face_outline(&mut sc, &drawn_mesh(b), *index, t.sel_edge); + hidden_face_outline(&mut sc, &drawn_mesh(app, b), *index, t.sel_edge); } } Sel::Body { name } => { @@ -1799,7 +1889,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { Sel::Edge { body, index, point } => { if let Some(b) = st.body(body) && app.session.body_visible(body) - && let Some(e) = edge_of(&drawn_mesh(b), *index, *point) + && let Some(e) = edge_of(&drawn_mesh(app, b), *index, *point) { edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); } @@ -1863,7 +1953,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } let k = t.hover_face_lift; let c = body_rgb(app, &b.name); - let m = drawn_mesh(b); + let m = drawn_mesh(app, b); let lit = shown(b); for &f in &faces { if !sel.iter().any(|x| { @@ -1898,7 +1988,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = body_rgb(app, body); - let m = drawn_mesh(b); + let m = drawn_mesh(app, b); let patch = m.surface_patch(*index); let lit = shown(b); for &f in &patch { @@ -1916,7 +2006,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { Some(Hit::Edge { body, index, mid }) => { if let Some(b) = st.body(body) { // Lit over the drawn edges (finer away from Normal display quality). - let m = drawn_mesh(b); + let m = drawn_mesh(app, b); for i in m.circular_rim(*index) { let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i)); if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } { @@ -2004,392 +2094,34 @@ pub fn delete_selection(app: &mut SolveApp) { crate::delete::delete_selection(app); } -/// Raw wheel/trackpad input for one frame, split by source. -/// -/// Mouse wheels report lines or pages; trackpads report points. Trackpad -/// scrolls arrive bracketed (`Start` … `Move` … `End`, momentum included) -/// while mouse wheels send bare `Move` events, so phases tell them apart. -struct RawWheel { - /// Vertical mouse-wheel input in points (zooms). - zoom: f32, - /// Trackpad point input, routed like the wheel (zooms when panning is off). - point_zoom: f32, - /// Bracketed two-finger scroll in points (pans when panning is on). - pan: egui::Vec2, - /// Bare horizontal input in points (thumb wheel: always pans sideways). - mouse_pan: egui::Vec2, -} - -/// Route wheel points to the vertical axis, matching egui's scroll modifiers. -fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifiers, options: egui::InputOptions) -> f32 { - let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier); - let vertical = modifiers.matches_any(options.vertical_scroll_modifier); - if horizontal && !vertical { - 0.0 - } else if vertical && !horizontal { - points.x + points.y - } else { - points.y - } -} - -/// Match egui's wheel units and modifier routing, without its temporal smoothing. -/// -/// `gesture` tracks an ongoing bracketed trackpad scroll (`Start` … `End`): -/// moves inside one pan, bare `Move` input is a mouse (vertical zooms, -/// horizontal pans). Only point input brackets; line/page input always zooms. -fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel { - let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO }; - for event in &input.events { - let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue }; - if modifiers.matches_any(options.zoom_modifier) { - continue; - } - let point = matches!(unit, egui::MouseWheelUnit::Point); - match phase { - egui::TouchPhase::Start => { - if point { - *gesture = true; - } - continue; - } - egui::TouchPhase::End | egui::TouchPhase::Cancel => { - if point { - *gesture = false; - } - continue; - } - egui::TouchPhase::Move => {} - } - let points = match unit { - egui::MouseWheelUnit::Point => *delta, - egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed, - egui::MouseWheelUnit::Page => *delta * page_height, - }; - if point && *gesture { - out.pan += points; - // Legacy path only (ignored while panning is on). - out.point_zoom += route_wheel_vertical(points, *modifiers, options); - } else if point { - // Bare moves are a mouse: vertical zooms, horizontal pans sideways. - // A wheel needs the opposite sign of fingers for the same motion. - out.zoom += route_wheel_vertical(points, *modifiers, options); - out.mouse_pan += egui::vec2(-points.x, 0.0); - } else { - out.zoom += route_wheel_vertical(points, *modifiers, options); - } - } - out -} - -/// Combined pinch-to-zoom factor for one frame (1 = no pinch input). -fn pinch_factor(input: &egui::InputState) -> f32 { - input - .events - .iter() - .filter_map(|event| { - let egui::Event::Zoom(f) = event else { return None }; - (*f).is_finite().then_some(*f).filter(|f| *f > 0.0) - }) - .fold(1.0, |acc, f| acc * f) -} - -/// Zoom anchor: the model point under the cursor, else the target's depth. -/// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre). -fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option) -> Option { - hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| { - let proj = projection(app, rect); - // Toward the model's surface under the cursor, else the target's depth. - if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) { - return q; - } - let (o, d) = proj.ray(p); - let n = app.cam.back(); - let den = d.dot(n); - if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target } - }) -} - -/// Modifier-only navigation never borrows pointer movement from another widget or gesture. -fn modifier_motion(app: &mut SolveApp, ui: &egui::Ui, inside: bool, hover: Option, mods: egui::Modifiers) -> Option<(NavMode, egui::Vec2)> { - let owns = inside - && ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed()) - && !ui.ctx().egui_wants_keyboard_input() - && !egui::Popup::is_any_open(ui.ctx()) - && !crate::dim_view::editing() - && app.viewport.context_menu.is_none() - && app.viewport.axis_drag.is_none() - && app.viewport.point_drag.is_none() - && app.viewport.text_drag.is_none() - && app.viewport.boxsel.is_none() - && !hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p))) - && !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p))); - let next = - hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p))); - let previous = app.viewport.modifier_nav; - if previous.map(|(m, _)| m) != next.map(|(m, _)| m) { - app.viewport.orbit_pivot = None; - app.viewport.orbit_free = None; - app.viewport.orbit_snap = None; - } - app.viewport.modifier_nav = next; - next.map(|(mode, p)| { - let delta = previous.filter(|(m, _)| *m == mode).map_or(egui::Vec2::ZERO, |(_, last)| p - last); - let delta = if delta.is_finite() && delta.x.abs() <= 1e4 && delta.y.abs() <= 1e4 { delta } else { egui::Vec2::ZERO }; - (mode, delta) - }) -} - -/// Continuous camera changes use the same JSON command as programmatic callers. -fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) { - let stored_fov = app.cam.fov; - params["camera"] = json!(projection(app, rect).cam); - params["viewport"] = json!([rect.width().max(1.0), rect.height().max(1.0)]); - let _ = app.run("view.navigate", params); - app.cam.fov = stored_fov; -} - pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let rect = ui.max_rect(); app.viewport.rect = Some(rect); refresh_layout(app, ui.ctx().pixels_per_point()); let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); - let (hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, primary_released, delta) = ui.input(|i| { - ( - i.pointer.hover_pos(), - i.smooth_scroll_delta.y, - i.modifiers, - i.pointer.middle_down(), - i.pointer.secondary_down(), - i.pointer.primary_down(), - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.pointer.delta(), - ) - }); - // The right button belongs to the marking menu while it is open. - let secondary_down = secondary_down && app.viewport.context_menu.is_none(); - // The axis view handles its own clicks; the model underneath must not see them. - let axes = axis_rect(app, rect); - // Floating windows and menus own input over the model, including wheel navigation. egui's - // hit test also counts the model as under the pointer a few points past its own edge and - // inside a window's or a list's edge (#678), so the pointer must be in the viewport too, - // with no window or menu above it. - let inside = resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p)); - app.viewport.mouse = hover.filter(|_| inside); - // Ctrl or Alt held: drawing and dragging don't snap. - crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); - middle_double_click(app, ui, inside); - - // ---- navigation ---- - let trackpad_pan = app.preferences.trackpad_pans(); - if !app.preferences.smooth_wheel_zoom { - app.viewport.wheel_zoom_raw_until_idle = true; - } else if !ui.input(|i| i.is_scrolling()) { - app.viewport.wheel_zoom_raw_until_idle = false; - } - let options = ui.ctx().options(|o| o.input_options); - let page_height = ui.ctx().viewport_rect().height(); - let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture)); - if trackpad_pan && raw.pan != egui::Vec2::ZERO { - // Swallow egui's smoothing tail while the trackpad runs (a wheel-only - // tail ends the moment trackpad input starts; mixed input is rare). - app.viewport.trackpad_until_idle = true; - } else if !ui.input(|i| i.is_scrolling()) { - app.viewport.trackpad_until_idle = false; - // Self-healing: with no scroll activity no gesture can be mid-flight - // (its End was lost); a stuck flag would pan future mouse input. - app.viewport.trackpad_gesture = false; - } - let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan; - let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) { - raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom } - } else { - scroll - }; - // Pinch-to-zoom (trackpad): direct manipulation, never smoothed. - let pinch = ui.input(pinch_factor); - let h = rect.height().max(1.0) as f64; - if inside && pinch != 1.0 { - app.viewport.orbit_snap = None; - app.viewport.orbit_free = None; - // Spread zooms in: the camera distance shrinks by the pinch factor. - let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app)); - let anchor = zoom_anchor(app, rect, hover); - if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { - let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)}); - if let Some(anchor) = anchor { - params["anchor"] = json!(anchor); - } - camera_motion(app, rect, params); - } else { - app.cancel_view_animation(); - app.cam.zoom_at(f, anchor); - } - } - if inside && scroll != 0.0 { - app.viewport.orbit_snap = None; - app.viewport.orbit_free = None; - let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); - let anchor = zoom_anchor(app, rect, hover); - if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { - let mut params = json!({"motion":"zoom", "factor":f.clamp(0.05,20.0)}); - if let Some(anchor) = anchor { - params["anchor"] = json!(anchor); - } - camera_motion(app, rect, params); - } else { - app.cancel_view_animation(); - app.cam.zoom_at(f, anchor); - } - } - // Two-finger scroll pans the model with the fingers. - if inside && pan != egui::Vec2::ZERO { - app.viewport.orbit_snap = None; - app.viewport.orbit_free = None; - if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { - camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]})); - } else { - app.cancel_view_animation(); - app.cam.pan(f64::from(pan.x), f64::from(pan.y), h); - } - } - // A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let - // go (kept through the release frame, so the end of the drag doesn't select either). - if primary_pressed && inside { - let empty_space = |app: &SolveApp| { - app.viewport.context_menu.is_none() - && hover.is_some_and(|p| { - let proj = projection(app, rect); - pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none() - }) - }; - let openscad_orbit = - app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app); - let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit { - crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods) - } else { - None - }; - // Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click - // still selects, and Ctrl on geometry still drags it without snapping. - let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app); - app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit)); - } else if !primary_down && !primary_released { - app.viewport.primary_nav = None; - } - // The fixed navigation mode (navigation bar) or the preset's left-button binding. - let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); - let button = if middle { - Some(crate::prefs::NavButton::Middle) - } else if secondary_down { - Some(crate::prefs::NavButton::Right) - } else { - None - }; - let dragging = resp.dragged() || (inside && button.is_some()); - let motion = modifier_motion(app, ui, inside, hover, mods); - let mode = motion.map(|(m, _)| m).or_else(|| { - if dragging && app.viewport.axis_drag.is_none() { - match button { - Some(b) => crate::prefs::nav_mode(app, b, mods), - None if primary_down => primary_nav, - None => None, - } - } else { - None - } - }); - if let Some((mode, delta)) = motion { - if delta != egui::Vec2::ZERO { - let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]}); - if mode == NavMode::Orbit { - let pivot = match app.preferences.orbit_around { - crate::prefs::OrbitAround::ViewCentre => None, - crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO), - crate::prefs::OrbitAround::Cursor => Some(match app.viewport.orbit_pivot { - Some(pivot) => pivot, - None => { - let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta); - app.viewport.orbit_pivot = Some(pivot); - pivot - } - }), - }; - if let Some(pivot) = pivot { - params["pivot"] = json!(pivot); - } - } - camera_motion(app, rect, params); - } - } else if mode == Some(NavMode::Orbit) { - let press = ui.input(|i| i.pointer.press_origin()); - app.cancel_view_animation(); - // Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt), - // rather than an additional request for snapping. Only an extra modifier snaps. - let without_shift = egui::Modifiers { shift: false, ..mods }; - let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV - && mods.shift - && match button { - Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit), - None => app.viewport.nav.is_some(), - }; - orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true); - } else { - if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) { - app.viewport.orbit_snap = None; - } - if app.viewport.axis_drag.is_none() { - app.viewport.orbit_free = None; - } - if delta != egui::Vec2::ZERO { - if mode.is_some() { - app.cancel_view_animation(); - } - match mode { - Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h), - Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None), - _ => {} - } - } - } - if !dragging && motion.is_none() { - app.viewport.orbit_pivot = None; - } - if app.viewport.spin { - // A click on the navigation bar (its own button) is not a stop. - let bar = app - .viewport - .layout - .as_ref() - .map(|l| l.navigation) - .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0))); - let (pressed, key, dt) = ui.input(|i| { - ( - i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)), - i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })), - i.stable_dt, - ) - }); - if pressed || key || dragging || motion.is_some() { - app.viewport.spin = false; - } else { - app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0); - ui.ctx().request_repaint(); - } - } - if !app.cam.is_valid() { - app.viewport.orbit_free = None; - app.viewport.orbit_snap = None; - app.cam = Camera::default(); - app.fit_view(); - } - - step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt))); - if app.viewport.orbit_snap.is_some() { - ui.ctx().request_repaint(); + let flat = crate::dialogs_sheet::flat_active(app); + if !flat { + app.flat_view.fitted = false; } + let input = + if flat { crate::dialogs_sheet::navigate(app, ui, rect, &resp) } else { crate::navigation::handle(app, ui, rect, &resp, NavCaps::MODEL) }; + let CameraInput { + hover, + scroll, + mods, + middle, + secondary_down, + primary_down, + primary_pressed, + delta, + inside, + axes, + pinch, + pan, + primary_nav, + mode, + } = input; app.viewport.physical_scale = ui.ctx().pixels_per_point(); let proj = projection(app, rect); if let Ok(mut cache) = app.viewport.display.lock() { @@ -2439,6 +2171,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { painter.add(Shape::mesh(mesh)); // ---- model ---- + update_refinement(app, ui.ctx(), &proj); let key = scene_key(app); let t0 = crate::now_ms(); if let Some(_g) = app.viewport.gpu { @@ -3278,7 +3011,7 @@ fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 { axis_rect(app, rect).center() } -fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { +pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { app.viewport.layout.as_ref().filter(|l| l.viewport == rect).map(|l| l.axis_view).unwrap_or_else(|| { let size = axis_view_size(app.preferences.navigator_style); Rect::from_min_size(pos2(rect.right() - size, rect.top()), vec2(size, size)) @@ -3303,109 +3036,6 @@ fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option { tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i) } -/// Six principal views, twelve edge diagonals and eight isometric corner directions. -fn snap_directions() -> impl Iterator { - [-1.0, 0.0, 1.0].into_iter().flat_map(|x| { - [-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized())) - }) -} - -/// Snap to the nearest standard or diagonal direction in the user's up-axis frame. -fn snapped_axis_camera(cam: Camera) -> Camera { - let b = cam.to_frame(cam.back()); - let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z); - let mut to = cam.looking_from(cam.from_frame(dir)); - if dir.x == 0.0 && dir.y == 0.0 { - to.yaw = nearest_quarter_turn(cam.yaw); - } - to -} - -pub(crate) struct OrbitSnap { - target: Camera, - yaw_velocity: f64, - pitch_velocity: f64, - pivot: Option<(Vec3, Pos2)>, -} - -/// Pointer motion accumulates independently of the spring preview, so many small deltas -/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker. -fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option, snap: bool, around_cursor: bool) { - let displayed = app.cam; - if let Some(free) = app.viewport.orbit_free.take() { - app.cam = free; - } - if around_cursor { - crate::prefs::orbit(app, rect, dx, dy, press); - } else { - app.cam.orbit(dx, dy); - } - let free = app.cam; - app.viewport.orbit_free = Some(free); - if snap || !around_cursor || app.viewport.orbit_snap.is_some() { - let mut target = if snap { snapped_axis_camera(free) } else { free }; - if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() { - let b = free.back(); - if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) { - target.yaw = previous.target.yaw; - target.pitch = previous.target.pitch; - } - } - // The cursor and origin pivots stay still while a snap eases in; the axis gizmo has no - // pivot (it orbits about the view centre, see `axis_view`). - let pivot = if around_cursor { - match app.preferences.orbit_around { - crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO), - _ => app.viewport.orbit_pivot, - } - } else { - None - } - .and_then(|p| { - app.cam = displayed; - projection(app, rect).to_screen(p).map(|at| (p, at)) - }); - let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot }); - spring.target = target; - spring.pivot = pivot; - app.cam = displayed; - } -} - -/// A lightly damped spring: mostly settled in about a third of a second, with a small -/// elastic finish. Bounded substeps keep the behavior consistent across frame rates. -fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) { - let Some(mut spring) = app.viewport.orbit_snap.take() else { return }; - let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 }; - let steps = (dt * 240.0).ceil().max(1.0) as usize; - let h = dt / steps as f64; - let omega = 16.0; - let damping = 0.82; - let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; - for _ in 0..steps.min(12) { - spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h; - spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h; - app.cam.yaw += spring.yaw_velocity * h; - app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2); - } - app.cam.target = spring.target.target; - let done = yaw_error(app.cam.yaw).abs() < 1e-5 - && (spring.target.pitch - app.cam.pitch).abs() < 1e-5 - && spring.yaw_velocity.abs() < 1e-4 - && spring.pitch_velocity.abs() < 1e-4; - if done { - app.cam = spring.target; - } - if let Some((pivot, at)) = spring.pivot - && let Some(after) = projection(app, rect).to_screen(pivot) - { - app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0))); - } - if !done { - app.viewport.orbit_snap = Some(spring); - } -} - /// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press /// on a tip chooses its view. Floating windows own their input over the gizmo. fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { @@ -3438,7 +3068,7 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { true } -fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { +pub(crate) fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { match app.preferences.navigator_style { crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect), crate::prefs::NavigatorStyle::Cube => navigation_cube::show(app, ui, rect), @@ -3501,36 +3131,8 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } } -/// A middle-button double-click goes Home (or fits, or nothing: Preferences › Navigation). Only -/// clicks count: a press that moves (a pan or orbit drag) or is held is not one, and the two -/// clicks must both be the middle button's, close together in time and place. -fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: bool) { - use egui::PointerButton::{Extra1, Extra2, Middle, Primary, Secondary}; - let (double, releases, middle, other, dragging) = ui.input(|i| { - ( - i.pointer.button_double_clicked(Middle), - i.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: Middle, pressed: false, .. })).count(), - i.pointer.button_clicked(Middle), - [Primary, Secondary, Extra1, Extra2].into_iter().any(|b| i.pointer.button_clicked(b)), - i.pointer.is_decidedly_dragging(), - ) - }); - // egui pairs clicks of any buttons; the first click must have been the middle one too (or - // both arrive in this frame). - let go = double && inside && !other && (app.viewport.middle_clicked || releases >= 2); - app.viewport.middle_clicked = if other || dragging || go { false } else { middle || app.viewport.middle_clicked }; - if !go { - return; - } - match crate::prefs::MiddleDoubleClick::of(app) { - crate::prefs::MiddleDoubleClick::Home => app.animate_view("home"), - crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(), - crate::prefs::MiddleDoubleClick::Off => {} - } -} - /// At a halfway tie, prefer the square heading nearer the standard plan view. -fn nearest_quarter_turn(yaw: f64) -> f64 { +pub(crate) fn nearest_quarter_turn(yaw: f64) -> f64 { if !yaw.is_finite() { return 0.0; } @@ -3684,6 +3286,17 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { }); ui.checkbox(&mut s.follow_camera, crate::i18n::tr_label("Follow camera (Look At sketches)")); }); + // The agent chat panel, once the chat is turned on in Preferences. + if crate::agent_chat::shown(app) { + ui.separator(); + let mut open = app.agent_chat.open; + let r = ui.checkbox(&mut open, crate::agent_chat::panel_tip()); + crate::scenario::publish_handle("view:agent_chat", r.rect.center()); + if r.changed() { + app.agent_chat.open = open; + app.preferences.agent_chat_minimized &= !open; + } + } }); }); let clicked_out = ui.input(|i| i.pointer.any_pressed()) @@ -3694,6 +3307,7 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::navigation::*; /// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies /// built into triangles on the CPU: the sample design rendered both ways through the @@ -3845,6 +3459,8 @@ mod tests { assert_eq!(supersampled_size([3840.0, 2160.0], 2.0, 4096, 1), None); assert_eq!(supersampled_size([1920.0, 1080.0], 1.0, 16384, 1), None); assert_eq!(supersampled_size([0.0, 1080.0], 2.0, 16384, 1), None); + // Under 1: drawn smaller and filtered up (the product preview past its budget, #1464). + assert_eq!(supersampled_size([3840.0, 2160.0], 0.5, 4096, 4), Some([1920, 1080])); // 4× MSAA at 1440p: the memory budget caps it below 2×. let [w, h] = supersampled_size([2560.0, 1440.0], 2.0, 16384, 4).unwrap(); assert!(w < 5120 && w as f32 >= 2560.0 * 1.25 && u64::from(w) * u64::from(h) * 32 <= 384 << 20, "{w} x {h}"); @@ -4623,6 +4239,65 @@ mod tests { assert!(app.viewport.hover.is_some()); } + /// After a close-up refines a body (#1320), the model draws it from the refined plan (on the + /// GPU) or its mesh (on the CPU), its edges come from that mesh too, and a hovered face lights + /// exactly triangles the model draws, never another triangulation (#1064). + #[test] + fn a_refined_body_highlights_on_its_drawn_triangles() { + use solidcraft_engine::doc::display_quality::current; + let tris_of = |bytes: &[u8]| -> Vec<[u32; 9]> { + bytes + .chunks(3 * crate::gpu::TRI_SIZE) + .map(|t| { + let mut v: Vec<[u32; 3]> = t + .chunks(crate::gpu::TRI_SIZE) + .map(|c| std::array::from_fn(|i| u32::from_le_bytes(c[i * 4..i * 4 + 4].try_into().unwrap()))) + .collect(); + v.sort_unstable(); + std::array::from_fn(|i| v[i / 3][i % 3]) + }) + .collect() + }; + let mesh_tris = |m: &solidcraft_engine::geom::Mesh| -> std::collections::HashSet<[u32; 9]> { + let mut sc = GpuScene::default(); + for f in 0..m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0) { + face_tris(&mut sc, m, f, [0; 4], true); + } + tris_of(&sc.tris).into_iter().collect() + }; + for tess in [true, false] { + let mut app = cylinder_app(); + app.viewport.no_pixels = true; + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); + let body = app.session.world_state().bodies[0].clone(); + let before = build_scene(&app).merged(); + let key = scene_key(&app); + assert!(app.viewport.lod.lock().unwrap().force(&body, current(), 2)); + assert_ne!(scene_key(&app), key, "a refinement rebuilds the scene"); + let (plan, mesh) = refined(&app, &body).unwrap(); + assert!(mesh.triangles.len() > body.plan_mesh().unwrap().triangles.len() * 2); + let scene = build_scene(&app).merged(); + if tess { + assert!(std::sync::Arc::ptr_eq(&scene.planned[0].plan, &plan), "faces drawn from the refined plan"); + } else { + assert_eq!(scene.tris.len(), mesh.triangles.len() * 3 * crate::gpu::TRI_SIZE, "faces built from the refined mesh"); + assert!(scene.tris.len() > before.tris.len()); + } + // Edges (lines) from the refined mesh: finer polylines. + assert!(scene.lines.len() > before.lines.len(), "{} vs {}", scene.lines.len(), before.lines.len()); + let drawn = mesh_tris(&mesh); + let mut lit_faces = 0; + for index in 0..body.body.face_count() { + app.viewport.hover = Some(Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO }); + let lit = tris_of(&build_highlight(&app).tris); + assert!(!lit.is_empty(), "face {index} lights"); + assert!(lit.iter().all(|t| drawn.contains(t)), "face {index}: every lit triangle is a drawn one"); + lit_faces += 1; + } + assert!(lit_faces >= 3, "{lit_faces} faces"); + } + } + fn cylinder_app() -> SolveApp { let mut s = solidcraft_engine::Session::default(); s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap(); diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs index ef5498c1..05d88d33 100644 --- a/crates/ui-egui/src/viewport_appearance_tests.rs +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -14,8 +14,9 @@ fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() { .unwrap(); let mut document = doc::Document::default(); let component = document.add_component(Some("Imported part"), 0).unwrap(); - let feature = - document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step: step.into(), components: Vec::new() }, None).unwrap(); + let feature = document + .add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None) + .unwrap(); document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component; let mut session = Session::new(document); let source = session.model.state().bodies[0].name.clone(); diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 5ff07110..953fc3dc 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -11,6 +11,7 @@ const ASSEMBLE: &[&str] = &[ "component.create", "joint.create", "joint.as_built", + "component.insert_design", "joint.origin", "joint.rigid_group", "joint.drive", @@ -212,4 +213,36 @@ mod tests { assert!(ids("PLASTIC", "MODIFY").contains(&"solid.shell")); assert!(panels("PLASTIC").is_some_and(|p| p.first() == Some(&"SETUP"))); } + + /// #1240: a saved part reaches an assembly from the ASSEMBLE panel (and the File menu) as a + /// linked component, and a STEP file picked there is inserted as Insert STEP would. + #[test] + fn insert_design_is_on_the_assemble_panel_and_inserts_a_saved_part() { + use serde_json::json; + for tab in ["SOLID", "SHEET METAL", "PLASTIC"] { + assert!(ids(tab, "ASSEMBLE").contains(&"component.insert_design"), "{tab}"); + } + assert!(!ids("SOLID", "ASSEMBLE")[..3].contains(&"component.insert_design"), "Component, Joint and As-built Joint keep the big buttons"); + let mut h = crate::scenario::Harness::new(); + let part = h.dir.join("part1.solidcraft"); + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap(); + s.execute("file.save", &json!({"path": part})).unwrap(); + s.execute("file.export", &json!({"path": h.dir.join("part2.step")})).unwrap(); + h.step(&json!({"choose_file": "part1.solidcraft"})).unwrap(); + h.step(&json!({"start": "component.insert_design"})).unwrap(); + h.frames(3); + let links = h.app.session.execute("component.links", &json!({})).unwrap(); + assert_eq!(links["links"][0]["name"], "part1", "{links}"); + assert_eq!(h.app.session.world_state().bodies.len(), 1); + h.step(&json!({"choose_file": "part2.step"})).unwrap(); + h.step(&json!({"start": "component.insert_design"})).unwrap(); + for _ in 0..600 { + if h.app.session.world_state().bodies.len() == 2 { + break; + } + h.frames(1); + } + assert_eq!(h.app.session.world_state().bodies.len(), 2, "the STEP file's body is inserted"); + } } diff --git a/crates/ui-egui/tests/dialog_chip_hover.rs b/crates/ui-egui/tests/dialog_chip_hover.rs new file mode 100644 index 00000000..39ed962e --- /dev/null +++ b/crates/ui-egui/tests/dialog_chip_hover.rs @@ -0,0 +1,19 @@ +//! Hovering a dialog's Select chip doesn't change its size or the panel's (#1335). +use serde_json::json; +use solidcraft_ui_egui::scenario::{Harness, handle_at}; + +#[test] +fn hovering_the_select_chip_keeps_its_size() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.start("sketch.create"); + h.frames(3); + let away = handle_at("dialog:Plane").unwrap(); + h.step(&json!({"move":[away.x, away.y]})).unwrap(); + h.frames(3); + let hovered = handle_at("dialog:Plane").unwrap(); + assert!(away.distance(hovered) < 0.01, "the chip moved on hover: {away:?} -> {hovered:?}"); + h.step(&json!({"move":[5.0, 600.0]})).unwrap(); + h.frames(3); + assert!(away.distance(handle_at("dialog:Plane").unwrap()) < 0.01); +} diff --git a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json index 45c796e3..5a6c0ad8 100644 --- a/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json +++ b/crates/ui-egui/tests/scenarios/s01_box_fillet_hole_shell.json @@ -50,7 +50,14 @@ ] } }, - {"click": {"sketch": [20, -8]}}, + { + "click": { + "sketch": [ + 20, + -8 + ] + } + }, { "text": "50" }, @@ -60,11 +67,40 @@ { "key": "Escape" }, + { + "start": "sketch.dimension" + }, + { + "click": { + "sketch": [ + 50, + 15 + ] + } + }, + { + "click": { + "sketch": [ + 58, + 15 + ] + } + }, + { + "text": "30" + }, + { + "key": "Enter" + }, + { + "key": "Escape" + }, { "expect": { "dialog": null, "param": { - "d1": 50 + "d1": 50, + "d2": 30 } } }, diff --git a/docs/agent-chat.md b/docs/agent-chat.md new file mode 100644 index 00000000..185ba1a0 --- /dev/null +++ b/docs/agent-chat.md @@ -0,0 +1,181 @@ +# Agent chat + +Agent chat is a side panel where you talk to a coding agent you already use (Claude Code, +Codex, Gemini CLI, OpenCode and others), or to a model behind an OpenAI-compatible endpoint you +choose (Ollama or LM Studio on your computer, OpenAI, any other URL), and have it work on the +design that's open. A coding agent runs on your computer under **your own account** with that +agent: SolidCraft doesn't sell or proxy a model and needs no key for it. An endpoint's key, if +it needs one, goes into your system keychain, never into SolidCraft's settings. + +Desktop app only. + +## Turning it on and off + +Everything about it is on one page: **Preferences → AI**. + +- **Show AI features** (on by default) is the master switch. Turned off, SolidCraft shows nothing + of it: no AI button, no shortcut, no menu entry, no panel, and nothing is looked for or + started. The page stays, so you can turn it back on. +- **Show button in toolbar** (on by default): the **AI** button at the right end of the toolbar + row, which stays there however wide the window is. Turn it off to keep only the shortcut and + the menu entry. + +With AI features on, the panel opens from: + +- the **AI** button at the right end of the toolbar; +- **Ctrl+Shift+A** (Cmd+Shift+A on a Mac), which also works from the chat's own text box; +- **View settings** (the last button of the navigation bar under the view) → **Agent chat**. + +Nothing runs until you need it: SolidCraft looks for installed agents (a quick `--version` +each, in the background) when the panel or the AI page first opens, and starts an agent only +when you send a message or press **Connect**. + +## The panel + +- **Docking:** drag the panel's header (the dotted grip and the title) to the other half of the + window and let go. The side it would land on lights up while you drag. You can also pick + **Left** or **Right** under Preferences → AI → Panel. SolidCraft remembers the side. +- **Width:** drag the panel's inner edge. SolidCraft remembers it. +- **Minimise** (the line in the header) folds the panel into a thin strip that keeps the chat + icon, the agent's state, and a count of anything waiting for your answer. Click the icon to + unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does. +- **The agent switcher** under the title picks the agent for this conversation; switching stops + the one running. Next to it, when the agent has modes of its own (Claude Code has *Default*, + *Accept Edits*, *Plan*…), a second drop-down sets the agent's mode. + +## Agents + +Preferences → AI → **Agents** lists every agent the chat can use, each with its kind (a program or +an endpoint), version or model, whether it looks signed in, **Test**, **Default** (the one a new +conversation starts with), its own permission setting, and **Remove** for the ones you added. + +### Found by themselves + +SolidCraft looks for agents that speak the [Agent Client Protocol](https://agentclientprotocol.com) +on your `PATH` and in the usual install folders a desktop app's `PATH` often misses (npm's +global folder, nvm, Volta, bun, `~/.local/bin`, Homebrew, `%APPDATA%\npm`). For each one it +shows its **version** (from `--version`, given three seconds) and whether it looks **signed in**. +SolidCraft only checks whether the agent's own login file (`~/.claude/.credentials.json`, +`~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, …) or one of its key variables exists. It never +reads the file and never starts the agent to find out. On macOS Claude Code keeps its login in +the keychain, so SolidCraft doesn't guess there. + +| Agent | What to install | +|---|---| +| Claude Code | `npm install -g @agentclientprotocol/claude-agent-acp` (the ACP adapter for Claude Code) | +| Codex | `npm install -g @agentclientprotocol/codex-acp` | +| Gemini CLI | `npm install -g @google/gemini-cli` (it speaks ACP itself: `gemini --acp`) | +| OpenCode | `npm install -g opencode-ai` (`opencode acp`) | +| GitHub Copilot CLI, Qwen Code, goose | listed with their install command when missing | + +The panel lists agents that aren't installed with their install command and a **Copy** button. +**Refresh** looks again after you install one. **Test** starts an agent briefly, asks it for a +session and ends it again. + +### Add agent… + +- **Installed agent:** your own entry for an installed agent, for example with other arguments. +- **Custom command:** any other program that speaks ACP on stdin and stdout, such as + `my-agent --acp`. SolidCraft starts it directly, not through a shell. +- **OpenAI-compatible API:** a preset (Ollama `http://localhost:11434/v1`, LM Studio + `http://localhost:1234/v1`, OpenAI) or a custom URL, the model name, and an optional key. + The key goes into the system keychain (Keychain on macOS, Credential Manager on Windows, the + Secret Service on Linux) and is only ever sent over HTTPS or to this computer. **Test** asks the + endpoint for its models. For these agents SolidCraft runs the conversation itself, with the + same tools, limits and questions as for a coding agent, and at most 24 model requests per + message. + +## Signing in + +If a coding agent isn't signed in, it says so and the panel shows **Sign in to the agent** with +the agent's own ways to sign in: + +- **A command for a terminal**, e.g. `claude-agent-acp --cli auth login --claudeai`, with a **Copy** + button. Run it in a terminal, sign in there, then press **I've signed in: connect again**. +- **A button** for agents that sign in by themselves (Codex's ChatGPT login opens your browser). +- **A variable to set** for agents that take a key from the environment. The panel has no field + for a coding agent's key, on purpose. + +Whatever you typed before signing in is sent once the agent is ready. + +## What the agent can do + +The agent reaches the open design only through SolidCraft's own tools (the +[MCP server](mcp.md)), which SolidCraft hands it when the chat starts: + +- **Read the design:** list commands, inspect features, sketches and parameters, measure, + take pictures of the view. +- **Change the design:** run any modelling command, alone or in a batch. Changes show in the + window as they happen. +- **One undo per message:** everything the agent changes while answering one message becomes + a single undo step, named after your message. The conversation shows *N changes, one undo + step* with **Undo these changes** while nothing else has happened since. +- **Tool cards:** each tool call shows as a card with its state; click it to see what it returned. +- **Attach view:** tick it to send a picture of the current view with your message, for agents + that take pictures. + +## Permissions + +Preferences → AI → **Permissions** decides what the agent may do without asking, for every agent; +each agent can have its own setting in its row. + +- **Ask every time:** every change to the design and every file action waits for **Allow**, + **Always allow** or **Deny** in the panel. +- **Auto-allow model edits** (the default): modelling commands run at once; saving, opening, exporting, + importing, starting a new design, any command given a file name, and undoing past the start of + your message still ask. +- **Allow everything:** nothing asks. The page warns you: the agent can then save, overwrite and + replace your design. + +**Always allow** remembers that command for that agent. The page lists what you allowed this way, +per agent, with **Reset**. + +These settings cover SolidCraft's tools. A coding agent's own tools (its shell, its file edits) +follow the agent's own mode, which you pick in the panel's header. When an agent asks before +using one of SolidCraft's tools, SolidCraft answers for you, since its own gate checks the call, +and notes it in the chat as *Auto-allowed*. With **Allow everything**, or after **Always allow**, +the agent's other questions are answered the same way and noted too. + +## What it can't do + +- **Files wait for you** unless you chose **Allow everything**. **Cancel** or **Stop** declines + everything that's waiting, and a question nobody answers is declined after four minutes. The + [MCP server's folder limits](mcp.md) still apply after you allow: the design's folder, the + recovery folder and the server's own scratch folder. +- **A limit per message.** The agent may make 24 changes per message by default; you can set + the number on the AI page. Reading the design doesn't count. When it reaches the limit, it's + told to stop and report what's left; send another message to let it carry on. +- **No crash reports,** whatever the permissions. Crash reports can hold other designs and file + paths. +- **Not the rest of the app.** It can't click the interface, change settings, quit, or drive the + chat itself. +- **Its own tools stay off where we can turn them off.** SolidCraft declares no file system and + no terminal to the agent, runs it in an empty working folder of its own, and asks the Claude + Code adapter to turn off its shell, file and web tools for the session. + +When the panel's agent is stopped, another agent is picked, or SolidCraft quits, the agent and +every program it started (the CLI it wraps, its MCP server) are stopped. + +## Privacy + +Nothing goes anywhere except to the agent you run, or the endpoint you added. SolidCraft starts +a coding agent as a program on your computer and talks to it over its standard input and +output. What the agent sends to its own service, under your account and that service's terms, +is between you and that service, exactly as when you use the agent in a terminal. SolidCraft +never sees, stores or passes on a coding agent's password, token or key. An endpoint's key is +kept in the system keychain and sent only to that endpoint. SolidCraft's own connection to the +agent's tools uses a random per-launch token in a file only you can read, which never passes +through the agent. + +## Trying it without an account + +A scripted test agent is in the source tree. It makes a block, rounds it and measures it, with +no model behind it: + +```sh +cargo build --release -p solidcraft-ui-egui --example acp_test_agent +``` + +Then add it under Preferences → AI → **Add agent… → Custom command** with the path of +`target/release/examples/acp_test_agent`. Put "save" in your message to see an Allow / Deny card, +or "ask" to see the agent ask its own permission. diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index f3b7f33b..45a6543a 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -17,6 +17,7 @@ - [Updates and privacy](guide/updates.md) - [Troubleshooting: SolidCraft won't start](guide/troubleshooting.md) - [Driving SolidCraft with an AI agent (MCP)](guide/mcp.md) +- [Agent chat](guide/agent-chat.md) # Developer guide diff --git a/docs/book/src/guide/agent-chat.md b/docs/book/src/guide/agent-chat.md new file mode 100644 index 00000000..4c114852 --- /dev/null +++ b/docs/book/src/guide/agent-chat.md @@ -0,0 +1 @@ +{{#include ../../../agent-chat.md}} diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 67d3b9b1..e33e79b0 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -64,8 +64,10 @@ instead, so two origins given at one point with the same z stay as they are. For drive many steps, `quiet: true` leaves the per-part degrees of freedom out of joint answers, and `motion.play` with `quiet: true` gives the poses of only the parts that moved. -**Insert Design** (`component.insert_design {path, translate?}`) adds a component linked to -another saved design, so each part can live in its own document. The part's solid bodies come +**Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and +in the **File** menu) adds a component linked to another saved design, so each part can live in +its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert +Mesh** would. The part's solid bodies come in as they sit in that design, and they are kept in the assembly, so it opens even without the part file. The link is stored relative to the assembly's folder once the assembly is saved. After the part file changes, **Update Links** (`component.update_links`) reads it again. diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index e6c72b79..339409f3 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -10,7 +10,7 @@ it. | Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect; thin walls along curves), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror | | Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove | | Construct | offset plane, plane at angle, plane along path | -| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | +| Inspect | measure, section analysis, interference, compare bodies, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | Extrude automatically selects **Join** for additions when the active component already has a solid, including sideways elbow connections and screw bosses that meet only part of a face. @@ -117,6 +117,43 @@ local segment. Preview does not change the design; OK creates one editable timel The plane follows later path edits. Curved paths use their sampled polyline; absolute distance, reference-object placement and multi-curve chains are not yet available in this dialog. +## Comparing two bodies + +**Compare Bodies** (`inspect.compare`) lays two versions of one part over each other, such as a +part and the same part exported by another program, and finds the places where only one of them +has material. It works from the two surfaces, not from a boolean of the bodies, so it also +compares bodies that share almost every face, the case a boolean (Combine, Interference) handles +worst. + +```json +{"command": "inspect.compare", "params": {"a": "Bracket", "b": "Bracket (2)", "tolerance": 0.01, "color": true}} +``` + +Without `a` and `b` it compares the two selected bodies, or the design's only two. Both bodies +are measured (volume, area, centre of mass and box, and `difference`: b − a of each). Each +surface is sampled at the corners of its triangles and at points at most `spacing` (0.5 mm) +apart on larger faces, and every sample measures its distance to the other surface: `deviation` +gives the largest and the mean distance each way. Samples further than `tolerance` (0.01 mm) form +the **regions**: `only_in_a` where `a` has material and `b` has none, `only_in_b` the other way +round. Each region gives its volume, the deviating surface area, the largest deviation and where +it is, its centre and box, and the faces of each body it lies on (`faces_a`, `faces_b`, numbered +as `model.faces` lists them), largest volume first. The volume is integrated along a grid of +parallel lines through the region (`resolution` gives their spacing), so a thin sliver is +measured as well as a block. `identical` is true when no region is found, and `verdict` says it in +one line. `warnings` lists what makes the result less certain: a body whose surface does not +enclose the volume it measures (open, or with faces that failed to import), and lines left out of +a region's volume because they crossed a gap. With `color`, the faces that hold a region turn +red where only `a` has material and green where only `b` has, on both bodies: the faces mostly +beyond the tolerance (a small change on a large face leaves the face alone), or, for a region +with none, the face with the largest share. +That is one undo step; without `color` the command changes nothing. + +The bodies are meshed within a quarter of the tolerance. The kernel meshes no finer than a +millionth of a body's size, so keep the tolerance at least twice that (0.02 mm for a part 10 m +across). Large bodies are sampled more coarsely, at about `max_points` (eight million) points +each; `sampling.spacing_mm` says what was used, and a larger `max_points` (up to 50 million) +samples them finer, at the cost of time. + ## Editing a feature from a script For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, or `{origin, dir}`. diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 90384e1f..2338ad0c 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -16,6 +16,13 @@ Opening a STEP or IGES file creates a design with the file's bodies as a base fe inserting one adds them to the current design. Later features build on them. Mesh bodies render, measure, move and export, but solid features need B-rep bodies. +Every imported file (STEP, IGES, 3MF, STL or OBJ) comes in as a new component named after the +file, so its bodies never mix with yours. A STEP assembly keeps its structure: its parts and +sub-assemblies become nested components, and a part used several times is one component with an +occurrence for each place. The imported assembly is rigid: moving its component moves everything, +and its parts don't move on their own. Right-click the component and choose **Unlock Contents** to +joint the parts one by one. Undo removes a whole import in one step. + **File → Save as mesh…** (also on the Mesh tab under Modify, and in the right-click menu of a body) saves the selected bodies as STL, 3MF or 3MF with face tags (below). With **Send to slicer** ticked, it writes the file to a temporary folder and opens it in the chosen slicer instead of asking for a file name. The slicer @@ -68,7 +75,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio The default `cache: false` omits the portable thumbnail too. The repository's `docs/design-container.md` describes limits and encoding details. -Current designs use format 21, including design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis +Current designs use format 21, including design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, the expressions behind a sketch text's height, place and angle, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis links, persistent names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index f550209c..badd89ec 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -27,6 +27,15 @@ Open the release `.dmg` and drag `SolidCraft.app` onto Applications. When updating from a build released under the previous product name, follow the [macOS upgrade notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md#macos) so you replace it rather than install a second copy. +### Linux AppImage + +The first time you open the AppImage it adds a menu entry and icons under `~/.local/share` +(`applications/ai.storyteller.solidcraft.desktop`, `icons/hicolor`), so the desktop shows the +right name and icon. It adds nothing if [appimaged](https://github.com/probonopd/go-appimage) +already made an entry for the file (and removes the entry SolidCraft added before). To +turn it off for good, create an empty file `~/.local/share/solidcraft/no-desktop-integration`, or +start the AppImage with `SOLIDCRAFT_NO_DESKTOP_INTEGRATION=1`. + ### Linux Flatpak Install Flatpak using your distribution's package manager. For an installation that receives @@ -73,16 +82,25 @@ doesn't update automatically. ## Web The same app runs in the browser: [open SolidCraft on the web](../app/) (`?sample` opens the sample -part, `?webgl` forces WebGL2 where WebGPU is missing). Designs are saved in the browser's private -file storage, which needs HTTPS or localhost. Planned worker and tiered-storage integration is +part, `?webgl` forces WebGL2 where WebGPU is missing or misbehaves; the browser remembers it until +`?webgpu`, and an app installed from Chrome or Edge starts with it too). Designs are saved in the +browser's private file storage, which needs HTTPS or localhost. +Planned worker and tiered-storage integration is described in [web-runtime.md](https://github.com/bherbruck/solidcraft/blob/main/docs/web-runtime.md) (design; not yet wired in the shipped wasm build). After the first visit the web app works offline: the browser keeps a copy of it. Online, each launch gets the latest published build (the browser downloads it once when it changes); offline, when the network doesn't answer within a few seconds, or when a new build can't be downloaded -whole, the kept copy starts (and the download carries on for the next launch). To build it -yourself: +whole, the kept copy starts (and the download carries on for the next launch). + +Browsers that can install web apps add **Install app…** to the File menu: it installs SolidCraft +as an app with its own window and launcher icon, working offline like the page. In Chrome, Edge +and other Chromium browsers it shows the browser's install prompt, and once SolidCraft is +installed the item is gone. Safari has no such prompt, so the item shows its steps instead: +Share › Add to Home Screen on iPhone and iPad, File › Add to Dock on a Mac (Safari 17 or later). + +To build it yourself: ```sh rustup target add wasm32-unknown-unknown diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index 65a4043e..f9b43dc4 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -17,7 +17,9 @@ does not also submit the parameter. Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using `+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod -trunc` and unit conversion. A length where an angle is expected (or the other way round) is an +trunc` and unit conversion. A decimal comma works too, outside parentheses: `2,5 mm` is +`2.5 mm` (inside a function's parentheses the comma separates its arguments, so write +`max(2.5, 3)` there). A length where an angle is expected (or the other way round) is an error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a parameter updates every expression, dimension and feature input that uses it, and a parameter in use can't be deleted (the error lists its users). diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 57283ab2..3affeb01 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -1,8 +1,9 @@ # Sketching and constraints A sketch lies on a plane: an origin plane, a construction plane or a planar face. Start one with -**Create Sketch** (`sketch.create`) and click a plane, or pick a face first. **Finish Sketch** -returns to the model. +**Create Sketch** (`sketch.create`) and click a plane, or pick a face first. Right-clicking a +plane, in the view or on its browser row, offers **Create Sketch** on that plane too. +**Finish Sketch** returns to the model. In scripts, `XY`, `XZ`, `YZ` and their `Top`, `Front`, `Right` aliases name origin planes. These names are reserved: a construction plane cannot be created with or renamed to one @@ -46,16 +47,24 @@ circle and update the outline when the inline **Sides** value changes. `sketch.edit_text` take a `font`, the family or full name of an installed font ("DejaVu Sans") or the path of a `.ttf` / `.otf` file, and the **Edit Text** dialog has a **Font** field for it. An unknown font is an error, and the font is kept with the text in the design, so the text stays editable in it. `SOLIDCRAFT_FONT_DIRS` -adds folders to the ones searched for installed fonts. The same two commands refuse a `text` that is not a -string, an `at` that is not two numbers, and a `height` or `angle` that is not a number (or a `height` that is not -positive) with a message, instead of drawing the text with a default; left out, they keep their defaults. +adds folders to the ones searched for installed fonts. A text's `height`, its `angle` and each coordinate of +its `at` can be expressions of the design's parameters, given as strings (`"height": "plate_height / 10"`, +`"at": ["plate_width / 2", 5]`): the text keeps the expression and follows the parameters when they change, and +saves it with the design. A number takes the place of an expression, and so does moving the text or dragging its +handles (what moves becomes a number; turning a text leaves its height as it was). A string that names no +parameter is the number it comes to. The same two commands refuse a `text` that is not a string, an `at` that is +not two numbers or expressions, a `height` or `angle` that is neither (or a `height` that is not positive) and an +expression that cannot be worked out with a message, instead of drawing the text with a default; left out, they +keep their defaults. Right-click a letter's filled interior or outline and choose **Edit Text**. You can also start **Edit Text** first and click the letter. All outlines belonging to that text are highlighted while the dialog is open. A profile bounded by multiple text sources or other geometry cannot identify one text: use **Select Other** to choose its outline instead. Change the text, font, baseline X/Y, height or angle, then confirm with **OK**; **Cancel** -keeps the original text. Position and height use the document's units. +keeps the original text. Position and height use the document's units. The four fields take +expressions: one that names a parameter (`plate_width / 2`) stays an expression, is shown as typed +when the dialog is opened again, and keeps the text following the parameter. A text can follow a curve of its sketch (its **Path**). That curve becomes construction: the letters stand on it and their descenders cross it, so as a normal curve it would cut them into diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 3e29cbe7..423b9ed0 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -45,7 +45,8 @@ home, framing what is shown now (Navigation › **Middle double-click** can make it Fit, which keeps the orientation, or turn it off). Right-click for the marking menu: eight commands around the cursor, plus a - menu for whatever is under it. + menu for whatever is under it. Near the window's bottom or right edge a menu opens above or + to the left of the cursor, so it stays in view. Shaded views omit certified smooth joins between spline spans on extruded walls. Real creases, cap outlines and authored face splits remain visible. Wireframe retains its edge presentation, and hiding a join does not remove its CAD diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b700c340..be49d189 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -132,10 +132,11 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | +| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?`, `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) | | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | diff --git a/docs/design-container.md b/docs/design-container.md index f17d886f..b4f27588 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/21`). +`document.json` with the versioned parametric document (currently `solidcraft/22`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads. diff --git a/docs/design/in-app-agent-chat.md b/docs/design/in-app-agent-chat.md new file mode 100644 index 00000000..56f65a27 --- /dev/null +++ b/docs/design/in-app-agent-chat.md @@ -0,0 +1,216 @@ +# In-app agent chat over ACP + +Status: desktop only, behind Preferences → AI → *Show AI features* (on by default; off hides and +stops everything). The user guide is +[docs/agent-chat.md](../agent-chat.md). This note records what I found about the Agent Client +Protocol and the agents that speak it, how the chat is built, and what's still open. + +## Goal + +A chat panel inside SolidCraft that drives a coding agent **the user already has and is already +signed in to** (Claude Code, Codex, Gemini CLI, …). SolidCraft holds no API keys and no +accounts: the agent runs as a child process under the user's own login, and SolidCraft gives +it one thing, its MCP server bridged to the open window, so the agent models on the design the +user is looking at. + +## The protocol + +[ACP](https://agentclientprotocol.com) is JSON-RPC 2.0 over the agent's stdin/stdout, one +message per line, protocol version `1`. The editor (here SolidCraft) is the *client*; the agent +is the *server*, started as a subprocess. Zed introduced it to embed Claude Code and Codex; +the Rust SDK (`agent-client-protocol`, Apache-2.0) and the schema crate +(`agent-client-protocol-schema`, Apache-2.0) are the reference. + +What the prototype uses: + +| Direction | Message | Use | +|---|---|---| +| → agent | `initialize {protocolVersion: 1, clientCapabilities, clientInfo}` | We declare no file system and no terminal (`fs.readTextFile/writeTextFile: false`, `terminal: false`), and terminal login (`auth.terminal: true`, plus the older `_meta["terminal-auth"]`). The reply carries `agentInfo` and `authMethods`. | +| → agent | `session/new {cwd, mcpServers}` | `mcpServers` holds one stdio server: `solidcraft-cli mcp --connect 127.0.0.1:PORT`. `cwd` is the open design's folder (else SolidCraft's own `agent` folder). | +| → agent | `session/prompt {sessionId, prompt: [content]}` | One text block. The reply comes at the end of the turn with `stopReason` (`end_turn`, `cancelled`, `refusal`, …). | +| → agent | `session/cancel {sessionId}` (notification) | Cancel button. Open permission prompts are answered `cancelled` first, as the spec asks. | +| → agent | `authenticate {methodId}` | Only for an "agent" login method the user clicks (the agent then opens the browser itself). | +| ← agent | `session/update` | `agent_message_chunk`, `agent_thought_chunk` (streamed text), `tool_call` / `tool_call_update` (`toolCallId`, `title`, `kind`, `status`: pending, in_progress, completed, failed, `content`), `plan` (entries with status). Mode, command and usage updates are ignored for now. | +| ← agent | `session/request_permission {toolCall, options: [{optionId, name, kind}]}` | Shown as a prompt in the conversation; the answer is `{outcome: {outcome: "selected", optionId}}` or `{outcome: "cancelled"}`. `kind` is `allow_once`, `allow_always`, `reject_once` or `reject_always`. | +| ← agent | `fs/*`, `terminal/*` | Not offered, answered "method not found". | +| ← agent | `_auth/status_update` | An extension both current adapters send ("Not logged in"); shown next to the agent's name. | + +Errors: an agent that is not signed in answers `session/new` (Codex) or the first +`session/prompt` (Claude) with JSON-RPC error `-32000 "Authentication required"`. + +## The agents + +All checked on 2026-10-14 with the packages installed into a user prefix +(`npm i -g --prefix ~/…`). Licences are from each package's own metadata; nothing is bundled +with SolidCraft, the user installs the agent. + +| Agent | How it speaks ACP | Package / licence | Version probe | Login | +|---|---|---|---|---| +| Claude Code | `claude-agent-acp` adapter (renamed from `@zed-industries/claude-code-acp`, which is now deprecated) | `@agentclientprotocol/claude-agent-acp` 0.90.0, Apache-2.0. It depends on `@anthropic-ai/claude-agent-sdk`, which is under Anthropic's own terms ("SEE LICENSE IN README.md"): fine for the user to install, not something we would vendor. | `--version` prints `0.90.0` | Terminal method. Offered only when the client declares terminal login. On a desktop: "Claude Subscription" = `claude-agent-acp --cli auth login --claudeai`, "Anthropic Console" = `… --cli auth login --console`. Over SSH (no browser) it offers `claude-login` = `… --cli` (the TUI `/login`). The login is Claude Code's own: `~/.claude/.credentials.json` (Linux, Windows) or the macOS keychain. | +| Codex | `codex-acp` adapter (`@zed-industries/codex-acp` 0.16 moved to `@agentclientprotocol/codex-acp`) | `@agentclientprotocol/codex-acp` 2.2.2, Apache-2.0 | `--version` (0.16 rejected the flag; 2.x prints it) | Agent method `chat-gpt` (the adapter opens the browser for the ChatGPT login when the client calls `authenticate`), and `api-key` for a key the user configures in Codex. 0.16 also listed `env_var` methods for `CODEX_API_KEY` / `OPENAI_API_KEY`. The login is Codex's own: `~/.codex/auth.json`. | +| Gemini CLI | built in: `gemini --acp` (the ACP registry's current flag; older releases used `--experimental-acp`) | `@google/gemini-cli` 0.64, Apache-2.0 | `--version` | Its own Google login (`~/.gemini/oauth_creds.json`) or a key in the environment. | +| OpenCode | built in: `opencode acp` | `opencode-ai` 1.19, MIT | `--version` | Its own providers' logins (`~/.local/share/opencode/auth.json`) or keys in the environment. | +| Others in the ACP registry | `copilot --acp`, `qwen --acp`, `opencode acp`, `goose acp`, `cline --acp`, `kimi acp`, … | mixed; several are proprietary, one is GPL and one AGPL (we only start them as programs, never link them) | `--version` | each its own | + +The registry (`https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json`) lists +about forty agents with their `npx`/binary distribution; a later version could read it to fill +the picker instead of the hard-coded table. + +### The auth story + +- **SolidCraft never sees a credential.** It doesn't pass API keys, doesn't store tokens and + doesn't open login pages by itself. Each agent keeps its own login where its CLI always does + (Claude Code: `~/.claude` or the keychain; Codex: `~/.codex`; Gemini: `~/.gemini`). A user + who already uses the agent in a terminal is already signed in, and the chat simply works. +- **Not signed in:** the agent answers "Authentication required". The panel switches to + *Sign-in needed* and lists the agent's own `authMethods`: + - *terminal* methods are shown as the exact command to run in a terminal (with Copy), e.g. + `claude-agent-acp --cli auth login --claudeai`; the user runs it, then presses Connect; + - *agent* methods (Codex's ChatGPT login) get a button that calls `authenticate`; the agent + opens the browser itself; + - *env_var* methods say which variable to set in the user's environment. The panel has no + field for a key, on purpose. +- Verified here, against both adapters, with an empty home folder (no credentials, so nobody's + account was used): Claude's adapter returns `-32000 Authentication required` on the first + prompt and offers the terminal login methods; Codex's returns it on `session/new` and offers + `chat-gpt` and `api-key`. I stopped at that step. + +## How it works + +``` +SolidCraft window ──(child process, own process group, ACP over stdio)──> agent (claude-agent-acp, codex-acp, gemini --acp, opencode acp, …) + ▲ │ starts, per session/new + │ the chat's own endpoint (loopback, fresh token, gate) ▼ + └──────────────────────────────────────────────────── solidcraft-cli mcp --connect 127.0.0.1:PORT +``` + +Code: `crates/ui-egui/src/agent_chat/`. + +| File | What | +|---|---| +| `backend.rs` | The `ChatBackend` trait and its `Event`s: the only thing the panel talks to. | +| `acp.rs`, `acp_client.rs` | The protocol messages, and `AcpBackend`, the first backend. | +| `api_client.rs` | `ApiBackend`: an OpenAI-compatible endpoint, with SolidCraft running the tool loop. | +| `agents.rs` | The agents (found and added), permission modes, "always allow" answers. | +| `settings.rs` | Preferences → AI and the Add agent dialog. | +| `process.rs` | The agent as a child process in a process group of its own; stopping it ends the group. | +| `discover.rs` | Which agents are installed, their versions, whether they look signed in. | +| `gate.rs` | What the agent's tool requests may do: run, wait for Allow/Deny, or never. | +| `mod.rs` | The conversation, turns (undo as one step, the per-turn limit), held requests. | +| `panel.rs` | The panel: docking, minimising, picker, sign-in, conversation, input. | + +### A pluggable backend + +The panel doesn't know which protocol it's talking. A backend implements `ChatBackend` (`poll` +for events, `prompt`, `cancel`, `answer_permission`, `authenticate`, `set_mode`, `shutdown`) and +gets a `StartContext` with its working folder, the MCP bridge and `tools` (a sender for control +requests, for a backend that runs tools in process). Two backends exist: + +- `AcpBackend` drives a coding agent the user already has (the default, and the point of the + feature: the user's own account, no keys). +- `ApiBackend` talks to an OpenAI-compatible chat completions endpoint (Ollama, LM Studio, + OpenAI, a custom URL) and runs the tool loop itself: it starts the same `solidcraft-cli mcp + --connect` server an ACP agent would get and is its MCP client (`tools/list` becomes the + function tools, `crash_reports` left out), so its calls pass the same gate, limits, questions + and undo as an ACP agent's. At most 24 model requests per message. The HTTP request is the + desktop host's (`Services::agent_http`, ureq with no redirects); the key comes from the system + keychain (`Services::secrets`, the keyring crate) for the connection only and is refused over + plain HTTP to another computer. + +Another kind of backend slots in the same way without reshaping the panel. + +### Design tools and the gate + +On the first Connect the app starts **an endpoint of the chat's own** +(`control_server::listen_agent`): a free loopback port with a fresh random token, written to the +per-user token file where `solidcraft-cli mcp --connect` finds it. The token never passes +through the agent, `SOLIDCRAFT_CONTROL_TOKEN` is removed from the agent's environment, and +`--allow-any-path` is never passed, so the MCP server keeps its #205 folder limits (a request +that names no folders is confined to the chat's working folder). + +Requests from that endpoint don't go to the app directly. `pump_tools` passes each one through +`gate::request`: + +- **Methods:** only `engine.commands`, `document.inspect`, `engine.execute`, `engine.batch`, + `ui.view`, `ui.render` and `ui.screenshot` (what the MCP server uses). No clicks, preferences, + quitting, scripts or the chat itself. +- **Refused:** `diagnostics.*` (crash reports hold other designs and paths), `app.*`. +- **Allow/Deny in the panel:** `file.*`, `doc.*`, render export, any command given a file or + folder, a picture saved outside the MCP server's scratch folder, and `edit.undo` past the + turn's first change. The request waits (the endpoint is patient) until the user answers; + Cancel/Stop declines everything waiting, and after four minutes it is declined (the MCP + server stops waiting at five). +- **Permission modes** (Preferences → AI, global with a per-agent override): *Ask every time* + turns every change into a question too; *Auto-allow model edits* (default) is the list above; + *Allow everything* runs what would ask (never what is refused). "Always allow" on a card + remembers the command per agent. The agent's own ACP permission questions about SolidCraft's + tools are answered "allow once" by the client (the gate decides), and others follow the mode + and the remembered answers; every automatic answer leaves an *Auto-allowed* note. +- **Per-turn limit:** 24 requests that run commands (a batch counts once; reads don't count), + set in Preferences, and 400 requests of any kind. Past it the agent is told to stop and say + what's left. +- **Outside a turn:** refused. + +What runs goes to the same queue as the control channel's held requests, so file loads, the +agent cursor and the per-request folder scope all apply as for any other client. + +### One undo step per message + +When a message is sent the chat remembers the design (`Arc`) and the undo depth. When +the turn ends, the undo snapshots made since are merged into the first one (which holds the +design from before the turn), relabelled "Agent chat: ". If the history was trimmed in +the meantime the start is found by the design it holds; if it's gone, nothing is merged. The +conversation shows the merged step with an Undo button while it's still the latest thing done. + +### The agent's own tools + +ACP lets the client offer a file system and a terminal; the chat offers neither, and starts the +agent in an empty working folder of SolidCraft's own (`/agent-chat/work`), not the +design's folder. The Claude Code adapter has shell, file and web tools of its own anyway: the +session asks it to turn them off (`_meta.claudeCode.options.disallowedTools`) and not to offer +"bypass permissions". Other agents' own permission requests appear in the panel with their +options. + +### Processes + +The agent starts in a process group of its own (Unix; on Windows `taskkill /T` ends its tree). +Stop, Close-and-Stop, turning the preference off, the agent chat being dropped and quitting all +end it the same way: its stdin closes (ACP agents end at end of input), then after a grace period +the group gets TERM, and KILL after another. Quitting waits for this (at most about three +seconds). `process.rs`'s tests start a fake agent that leaves helpers behind, once polite and +once ignoring both end of input and TERM, and check that none survive. If SolidCraft itself is +killed, the agent sees its stdin close and ends with its helpers. + +### Discovery + +As before, plus key variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, …) +reported as *key set*, `CLAUDE_CONFIG_DIR` and `CODEX_HOME` respected, and OpenCode's +`~/.local/share/opencode/auth.json`. Nothing is started except `--version`. + +### Panel + +Opened from the **AI** button at the toolbar row's right end (the panels give it room and spill +into » as usual; Preferences → AI → *Show button in toolbar* hides it), Ctrl+Shift+A and the View +settings menu. Docked left or right (dragging the header across the window, or Preferences), resizable, folded +to a 30 px strip with Minimise (state dot and a count of open questions), closed with ×. Side, +width, fold and the per-turn limit are preferences, not design data. Ctrl+Shift+A is reserved in the shortcut editor and works from the chat's text box too. The +header has the agent switcher (per conversation) and, for agents with ACP `modes`, the agent's +own mode picker (`session/set_mode`). + +### Control channel + +`ui.agent_chat` (docs/control-protocol.md) sets the preferences, opens and docks the panel, +connects, sends, answers permission and approval prompts, cancels, undoes the last message and +returns the whole state, for tests and screenshots. + +## Still open + +| Area | Work | +|---|---| +| Conversation | Markdown rendering, diffs, history per design, resume (`session/load`) | +| Session controls | Model and mode pickers from `configOptions` / `modes`, slash commands | +| Auth | An embedded terminal or "open a terminal here" for terminal logins on each OS; logout | +| Discovery | Read the ACP registry; keychain-aware login check on macOS | +| Packaging | Make sure every desktop package ships `solidcraft-cli` next to `solidcraft` (Flatpak, AppImage, macOS bundle, Windows zip), or serve MCP from the app binary itself | +| Windows | Job objects instead of `taskkill /T` once there's a safe API for them | +| API backend | Streaming answers; images in tool results | diff --git a/docs/features.md b/docs/features.md index 8c4a2335..e07ed6b8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,8 +1,8 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **367 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **373 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). -## Sketch (99) +## Sketch (102) | Command | Id | Toolbar | |---|---|---| @@ -104,6 +104,9 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Break Link | `sketch.break_link` | – | | Auto-Project Edges on Reference | `sketch.auto_project` | – | | Redefine Sketch Plane | `sketch.redefine` | – | +| Copy Sketch Entities | `sketch.copy` | – | +| Cut Sketch Entities | `sketch.cut` | – | +| Paste Sketch Entities | `sketch.paste` | – | | Inspect Sketch | `sketch.inspect` | – | ## Solid modelling (35) @@ -146,7 +149,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (14, 3 planned) +## Surfaces (15, 2 planned) | Command | Id | Toolbar | |---|---|---| @@ -162,7 +165,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Offset Surface | `surface.offset` | SURFACE › CREATE | | Ruled Surface | `surface.ruled` | SURFACE › CREATE | | Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE | -| Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE | +| Blend Surface | `surface.blend` | SURFACE › CREATE | | Untrim | `surface.untrim` | SURFACE › MODIFY | | Unstitch | `surface.unstitch` | SURFACE › MODIFY | | Reverse Normal | `surface.reverse_normal` | SURFACE › MODIFY | @@ -221,7 +224,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Assign Plastic Rule | `plastic.assign_rule` | PLASTIC › SETUP | | List Plastic Rules | `plastic.rules` | – | -## Components (16) +## Components (17) | Command | Id | Toolbar | |---|---|---| @@ -232,12 +235,13 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Component Physical Properties | `component.properties` | – | | Rename Component | `component.rename` | – | | Delete Component | `component.delete` | – | +| Unlock Contents | `component.unlock` | – | | Move Bodies to Component | `component.move_bodies` | – | | Move Sketches to Component | `component.move_sketches` | – | | Paste New | `component.paste_new` | – | | Capture Position | `component.capture_position` | SOLID › POSITION | | Revert Position | `component.revert_position` | SOLID › POSITION | -| Insert Design | `component.insert_design` | – | +| Insert Design | `component.insert_design` | SOLID › ASSEMBLE | | Update Links | `component.update_links` | – | | Break Link | `component.break_link` | – | | List Links | `component.links` | – | @@ -297,7 +301,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Add Recorded Feature | `timeline.add` | – | | Export Command Script | `timeline.export_script` | – | -## Inspect (12, 1 planned) +## Inspect (13, 1 planned) | Command | Id | Toolbar | |---|---|---| @@ -313,6 +317,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Continuity Analysis *(planned: [#770](https://github.com/bherbruck/solidcraft/issues/770))* | `inspect.continuity` | SURFACE › INSPECT | | Interference | `inspect.interference` | SOLID › INSPECT | | Measure (I) | `inspect.measure` | SOLID › INSPECT | +| Compare Bodies | `inspect.compare` | – | | Section Analysis | `inspect.section` | SOLID › INSPECT | ## Files (8) diff --git a/docs/mcp.md b/docs/mcp.md index 7ef017db..4a2ba252 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,7 +146,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. | | `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. | | `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). | -| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. | +| `measure` | `body?` or `items?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: see below. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). | | `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | | `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. | @@ -157,6 +157,24 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `open` | `path` | Open a `.solidcraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. | | `save` | `path?` | Save the design (to its current file without `path`). | +`measure` with `items` reports each item under `items` (`length_mm`, `area_mm2` or `position`). +A circle, arc, hole or other cylindrical face also gives `diameter`, `radius`, `center` and +`axis`, with `circumference` when it goes all the way round, or `arc_length` and `angle_deg` +for an arc; `round` says which (`circle`, `arc`, `cylinder`). A slot (a sketch profile of two +half circles and two lines, or one half-cylinder end of a slot in a solid) gives `slot`: +`width`, `center_distance` and `overall_length`. With one item these fields are repeated at +the top level. With two items: + +- `min` (also `distance_mm`, with `from`, `to` and `delta`): the closest distance; +- `max` (with `max_from`, `max_to`): the farthest distance; +- `center_distance` (with `center_from`, `center_to`): when an item is round, from its centre + or axis: axis to axis for parallel axes, the closest distance between the axes otherwise, + and axis to plane, line or point; +- `measure`: the distance the Measure panel shows first (`center` where there is one, else `min`); +- `angle_deg` between two planar faces or straight edges. + +Earlier fields are unchanged, so older scripts keep working. + To change a feature after it is made, read its definition with `execute` `timeline.inspect` (`{feature}`: id or name) and change fields with `timeline.edit` (`{feature, set}`; e.g. `{"extent": {"distance": "30"}}`). The fields per feature type are listed in the book's diff --git a/docs/perf.md b/docs/perf.md index 42b39092..ad0cd727 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -816,3 +816,40 @@ observed 47.6 ms check; no local PNG was created and the active document remaine empty. The screenshot shows the transferred file's picture while built-in sample pictures are still loading. This is one functional visual check, not a first-frame benchmark. The separate counting-reader regression proves source-blob deferral. + +## Extrude drag preview delivery + +On macOS with Metal, editing a 15 mm radius cylinder's 10 mm extrusion and dragging its +arrow upward by 150 screen points (120 input steps, Ctrl held) exposed preview starvation. +A completed worker result was discarded whenever the pointer had already changed the +requested distance. The arrow also drew the previous frame's distance. + +Main's tool-only preview already delivers intermediate Join/Cut/Intersect geometry. New Body +extrusions (including feature edits) and Offset Face use full-result previews and still discard +completed distances during a moving drag. Those paths now accept successful intermediate +distances from the same setup. Selections, operation, extent mode, feature identity, revision +and colours remain part of the setup identity. Errors still apply only to the exact requested +inputs, and the final distance is computed normally. Tool previews and background Apply retain +their existing paths. The arrow and value-box anchor use the current frame's pointer value. + +Release-build control-channel checks against upstream `4dc63810` with the arrow fix alone, +then with the distance fallback, used the same 150-point, 120-step drags. The cylinder used +the setup above; planar-face Press Pull (Join) and Offset Face used a 30 × 30 × 10 mm box. +The samples captured 25–27 distinct requested distances per drag: + +| Preview updates before reaching the final distance | Main + arrow fix | With distance fallback | +|---|---:|---:| +| Edited New Body extrusion | 0 | 24 | +| Planar-face Press Pull (Join) | 22 | 24 | +| Offset Face | 0 | 23 | + +Join already streams on main; the small count difference reflects sampling, not a change to +that path. Applying the fixed New Body extrusion produced the requested 19.585 mm height; +applying the 11.672 mm face offset produced a 21.672 mm tall box. + +These are sampled preview deliveries (`ui.inspect.preview.ms` changes), not every rendered +frame or a geometry-kernel speed benchmark. With main's preview code unchanged, the New Body, +edited New Body and Offset Face drag regressions fail while Join passes. With the fallback, +all six distance-preview regressions pass, covering intermediate geometry, exact final +convergence, unchanged document/undo, obsolete errors and setup changes. A separate regression +checks the arrow's position on each held input frame. diff --git a/docs/physical-material-cards.md b/docs/physical-material-cards.md index 1b3dfb3a..bb4818a2 100644 --- a/docs/physical-material-cards.md +++ b/docs/physical-material-cards.md @@ -13,6 +13,26 @@ advanced rendering. The result reports `analysis_solver_available: false` and ## Contract (standalone card schema 1) +### Candidate review window + +Search for **Validate Physical Material Data** in the command palette (or use +`ui.start` with `material.properties.validate`) to open the candidate reviewer. +Paste a schema-1 card object and choose **Review card**. The existing engine +validator checks it; the window shows known properties in SI units, unknown +properties, per-property conditions and source claims as inert text. Material-local +axes do not assign a frame to a body. Isotropic G/K come from the engine report. + +Editing the input clears the previous review. Invalid input shows its refusal +without retaining a stale valid result. Input is capped at 262,144 characters and +256 KiB before JSON parsing; the validator's tighter structural and text budgets +still apply. The window does not read files, fetch references, assign properties, +replace density, modify history or save the candidate. Close discards it. Calling +the engine's JSON command still returns its report without opening a dialog. + +This main-based review unit is independent of assigned-card persistence, local +file import and the design-owned inspector follow-up in #1094. No dataset or +rendering capability is supplied by reviewing a candidate. + A card has `schema_version: 1`, `designation`, `family` (`wood`, `polymer`, `glass`, `metal`, `other`), `sources` and `properties`. Source IDs are exact, case-sensitive local references. Each source has `id`, `title`, `kind` (`user_supplied`, @@ -58,6 +78,16 @@ isotropic derived response and its reciprocals must also remain finite/positive. Moisture is kg water / kg dry material and can exceed one. It is not a wet-mass fraction. Unspecified temperature/state, wood moisture or polymer strain rate are reported in `unspecified_conditions`; they are not guessed or replaced by zero. +The validation report also returns `missing_condition_fields`, a map from each +incomplete property kind to its exact absent field names. Temperature and state +apply to every family; wood additionally checks dry-basis moisture and polymers +strain rate, following the same reporting policy as `unspecified_conditions`. +For example, a wood density with temperature and state but no moisture reports +`{"density":["moisture_dry_basis_kg_kg"]}`. Complete properties are omitted; +an empty card returns an empty map, without inventing properties or conditions. +This detail is read-only and does not change admission or the saved card schema. +Older serialized validation reports without the map deserialize with an empty +map; regenerate validation from the card to obtain the missing-field detail. The family label does not certify that a chosen constitutive model fits a material. Scalar strengths are individual measurements, not a three-dimensional failure envelope. Typed strength directions and test methods remain a separate extension. diff --git a/docs/render-texture-input.md b/docs/render-texture-input.md index c36516e6..de66fa3e 100644 --- a/docs/render-texture-input.md +++ b/docs/render-texture-input.md @@ -1,5 +1,9 @@ # Bounded texture input (resource admission) +The separate [explicit normalization API](render-texture-normalization.md) handles +admitted high-precision/color/orientation conversions. The strict utility described +below retains its existing refusals; normalization does not enable texture rendering. + `solidcraft_render::texture_input` is an independent input utility for #900, coordinated with #792 and #813. It accepts in-memory PNG/JPEG bytes and supplies completed RGBA8 pixels. It does not change a document, read files, assign materials, add a resource-pack diff --git a/docs/render-texture-normalization.md b/docs/render-texture-normalization.md new file mode 100644 index 00000000..0f23a094 --- /dev/null +++ b/docs/render-texture-normalization.md @@ -0,0 +1,132 @@ +# Explicit texture normalization (#1218) + +`texture_input::normalization` is a pure-Rust, in-memory input utility, separate +from the strict RGBA8 [texture admission API](render-texture-input.md). It does not +install a renderer, command, resource format, document property or GPU texture. +The old API still refuses 16-bit samples, ICC and nonidentity EXIF orientation. + +## Input, policy and completed output + +`NormalizationSource::new(bytes, role, options)` retains immutable encoded storage +and admits format/storage/budget only. `decode(cancelled)` admits metadata and +completes normalization before returning an immutable `NormalizedTexture`. +Header admission is not successful decoding. No partial result is returned. + +- PNG channels expand to RGB/gray with alpha at their original 8/16-bit sample + depth; palette/sub-byte expansion uses the existing PNG codec. Output is RGBA + f32, without an implicit RGBA8 preview. This preserves distinguishable adjacent + 16-bit raw samples; ICC conversion and floating-point arithmetic are not lossless. +- JPEG is initially 8-bit grayscale or three-component sequential/progressive DCT; + four-component/CMYK, arithmetic and lossless JPEG are outside this policy. + JPEG decoding is lossy irrespective of the output precision. +- Base color returns **linear sRGB** RGB and unassociated normalized alpha. A + renderer must not apply sRGB decoding again. Alpha never receives a color transform. +- Roughness, metallic, AO and OpenGL normal channels remain normalized **data**. + They receive no gamma correction, ICC conversion, normal renormalization, channel + convention conversion or filename inference. ICC on a data map refuses. +- Untagged color refuses by default. `DeclareSrgbPrimaries` is an explicit caller + declaration, not source certification: it uses PNG gAMA when present (encoding + gamma in [0.1, 10]), otherwise the sRGB transfer function. Non-sRGB cHRM refuses. + An explicit sRGB chunk selects sRGB transfer; ICC plus sRGB refuses. +- PNG HDR/CICP metadata and animation refuse for base color; HDR environments use + the separate [Radiance input utility](render-hdr-input.md). Data roles record + ignored PNG color metadata, rather than applying it to data channels. +- EXIF uses classic TIFF IFD0 only: either byte order, at most 128 entries, + one SHORT Orientation in 1..8. Missing Orientation uses TIFF's top-left default, + with distinct receipt flags for absent EXIF and absent Orientation. Other EXIF + fields/thumbnail IFDs are not interpreted or certified. Malformed admission refuses. + Nonidentity orientation requires `Apply`; all eight operations are exact pixel + permutations, without interpolation. Oriented normal maps refuse until a vector + and tangent-basis conversion contract exists. + +## ICC scope and measured limitations + +The already locked moxcms 0.8.1 is now a direct dependency (BSD-3-Clause OR +Apache-2.0). PNG 0.18.1 and SHA-256 were also already in the lockfile. No dependency +version, copied source, external image/profile, commercial asset or material dataset +is added. Fixtures are generated from original synthetic values in test code. + +Initial ICC admission supports version 2/4 RGB input/display matrix/TRC profiles +with XYZ PCS and **identical RGB tone curves**. Required colorant/white-point/curve +tags are checked; CLUT and unknown conversion tags refuse. Limits: 256 KiB profile, +64 tags, 64 KiB per tag and 4096 sampled curve values. Offsets are four-byte aligned; +exact shared ranges are allowed, partial overlaps and duplicate signatures refuse. +The optional informational `chrm` tag initially admits three explicit device xy +channels (type 0, bounded xy values); PCS colorants still define the conversion. +This is bounded supported-profile admission, not a complete ICC conformance checker. + +Conversion uses relative colorimetric intent, no source CICP override or fixed-point +preference, and **extended range**. Testing found the default destination LUT clips +before loss can be counted. Extended range preserves excursions until our policy. +f64 rows retain the CMS's 16-bit source LUT where analytic evaluation is unavailable; +the library still uses f32 matrix/curve arithmetic internally. No universal accuracy +or full-f64 colorimetry is claimed. Unequal RGB curves refuse because the pinned +same-curve optimization can treat two matching curves as three matching curves. + +`GamutPolicy::Refuse` rejects encoded destination RGB outside [0, 1], allowing an +explicit edge tolerance of 3/65535 for finite-precision roundoff. `Clip` is an +explicit lossy preview policy. Every clamped RGB channel, including edge-tolerance +clamps, is counted. Nonfinite values always refuse. Output RGB is then linearized. + +## Resource and provenance boundaries + +- Encoded length **and capacity**: at most 16 MiB. Dimensions: at most 2048 per edge. + Raw decoded storage: at most 32 MiB. Completed float RGBA: at most 64 MiB. +- Maximum working admission: 256 MiB, charging retained encoded capacity, 24 bytes + per pixel for raw/output allowances, a 64 MiB codec reservation, 16 MiB conservative + CMS workspace, two 256 KiB profile allowances and two f64 RGB rows. The constructor + requires encoded capacity plus codec reservation before header work. Codec defaults + outside our control, allocator overhead and other jobs mean this is **not an RSS, + CPU-time or combined GPU/CPU ceiling**. Owned large buffers use fallible allocation. +- Framing/presence checks cap PNG chunks and JPEG marker segments at 4096, refuse + duplicate/late semantic metadata and require the codec to retain declared ICC/EXIF + information. JPEG scan stuffing/restarts are skipped without entropy decoding. + Existing codecs remain responsible for pixels and format validation; no second + compression decoder is introduced. PNG is fully finished before publication. +- Cancellation is checked before/after whole-image decode/profile setup, per + normalization row and during hashing. It cannot interrupt codec/profile construction. + The host must schedule this work away from the interactive UI thread. Worker ownership, + document generations, stale-result rejection, pooling and browser scheduling remain + mandatory consumer work; an immutable completed object does not implement those jobs. +- A read-only serializable receipt records recipe version 1, source and normalized + SHA-256 (little-endian f32 sample bytes), role, precision, dimensions, color conversion, + ICC identity, orientation operations/presence, chosen policies, clamp count, storage + and working estimate. It includes no paths or license approval. Hashes identify bytes, + not publishers. The receipt is diagnostic, not a persistent resource-pack schema or + permission to redistribute images/profiles. Per-file provenance and licensing remain + the host/library's responsibility. + +Tests use independent analytic sRGB/linear expectations, adjacent 16-bit samples, +alpha/palette data, original ICC stress profiles, all eight orientations, malformed +and ignored metadata, budgets and cancellation. This utility alone cannot demonstrate +wood shading, material assignment, PBR/HDRI, GPU behavior or solver properties. + +## Individually licensed file probe (2026-10-15) + +The same [Black Oak Veneer](https://polyhaven.com/a/black_oak_veneer) 1K diffuse +files that the legacy utility refused now complete normalization with their actual +embedded ICC profile. Author: Jenelle van Heerden; asset license: +[Poly Haven CC0](https://polyhaven.com/license), verified at retrieval. Provider API +calls identified SolidCraft; this is an offline validation probe, not live API integration. +Files, generated output and probe harness are not bundled or committed. + +| Input | Source SHA-256 | Completed result | +|---|---|---| +| PNG, 5,262,864 bytes | `3303ebadba1277488e43b0936b27743525711985db0da62ac9f989f695f6065c` | 16-bit source, 1024x1024 linear-sRGB RGBA f32, 16,777,216 output bytes, zero clamped channels | +| JPEG, 580,236 bytes | `7893f9bfeda626364f0a803dea6327c9f3a7b2f2fe330f6b4f326f6b06c0c265` | 8-bit source, same dimensions/encoding/storage, zero clamped channels | + +Both receipts identify ICC SHA-256 +`500fa2f01de5a0e051ce3b4111b0c94aa223cda0f4ccfbc1c631adc30a050616`. +The first probe refused its optional `chrm` tag. Support was added from ICC.1 +sections 9.2.16/10.2, with an original valid/malformed fixture; no profile was stripped +or source primaries guessed. EXIF is present without Orientation, so TIFF's identity +default applies. PNG/JPEG output hashes differ as expected for different input bytes +and JPEG loss; these are completed input results, not wood rendering evidence. +Local receipts retain download URL/license/retrieval metadata and provider MD5 checks. +Any future shipped file needs its own ATTRIBUTION.md entry and reviewed provenance. + +Public format references: [PNG third edition](https://www.w3.org/TR/png-3/), +[TIFF 6](https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf), +[ICC.1](https://www.color.org/specification/ICC1v43_2010-12.pdf), +[ICC embedding note](https://www.color.org/technotes/ICC-Technote-ProfileEmbedding.pdf) +and [JPEG T.81](https://www.w3.org/Graphics/JPEG/itu-t81.pdf). diff --git a/docs/render-workspace.md b/docs/render-workspace.md index 983a010f..a1a0c2f5 100644 --- a/docs/render-workspace.md +++ b/docs/render-workspace.md @@ -43,7 +43,9 @@ discards staged settings. Draft settings are separate for each open document. **Restore Saved Camera** runs `render.settings.apply {name}`, preserving target, distance, yaw, pitch, roll, FOV and up axis as view state. It neither edits the document nor settles a stale model. The UI honours its up axis in Render; the Design camera -is restored when returning. Drag orbits, middle-drag pans, scrolling zooms, and +is restored when returning. The camera moves exactly as in Design: the navigation preset, +Orbit around, zoom to the cursor, the SpaceMouse, the middle double-click, F6 and the axis +view or navigation cube all work (nothing is picked or selected here), and **Fit Product** frames visible product bodies without including sketches or selection. Preview drafts do not make the document dirty until saved. Unknown backend preferences @@ -63,6 +65,16 @@ per dimension and 4,194,304 pixels, within the 256 MiB pixel scratch admission p The estimate includes the selected MSAA colour/depth cost; it excludes scene geometry, driver overhead and cached source meshes. It is not a measurement of total GPU memory. +The preview never refuses a frame because of the screen size. When the viewport's physical +size is past those limits (a maximised 4K or 5K window is 8–15 million pixels), the preview +draws at the largest size of the same aspect the executor admits (`RenderLimits::fit`, both +axes scaled by one factor, scratch included) and filters it up into the viewport. A viewport +within the limits draws at full size. + +PNG images keep the saved-setup limits above. A setup past them is refused with the limits and +the largest size at its aspect (for example `4096 × 4096` → `2048 × 2048`), and the image panel +offers to use that size. + Pure capability metadata lives in the session/host, outside persistence and history, surviving document replacement, scratch sessions and document-tab switches. No wgpu types enter the engine or document crates. A host without an installed GPU/depth buffer diff --git a/docs/step-import.md b/docs/step-import.md index c269e6ee..019722f2 100644 --- a/docs/step-import.md +++ b/docs/step-import.md @@ -6,13 +6,23 @@ SolidCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its o - **Insert** into the current design: File → Insert STEP… or SOLID → INSERT (`file.insert_step {path}`). - **Drag and drop** a STEP file on the window: inserted when the design has features, opened otherwise. -Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature for the components milestone. Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. +## Imports are components + +Every import (STEP, IGES, 3MF, STL, OBJ; inserted, dropped, opened as a new design, or run as `file.insert_step`, `file.insert_mesh` or `doc.open` from a script, the CLI or MCP) becomes a **new component named after the file** (`bracket.step` → component `bracket`), placed in the active component at the file's origin. The Import feature belongs to it, so the file's bodies never land loose next to your own. Opening a file as a design makes the component too, so the browser shows the file's name. The command's reply reports it: `component` (id), `component_name` and `occurrence`. + +A STEP **assembly** keeps its structure: each product under the file's root becomes a component nested under the import's, named after the product, at the place the file gives it. A part (or sub-assembly) the file places more than once becomes **one component with an occurrence per placement**: the Import feature builds the part once and skips the file's later copies (`skip` in the feature, format 22), and the occurrences show it everywhere else. The reply adds `subcomponents` (how many components were made below the import's) and `rigid_joints`. + +The contents of an imported assembly are **rigid by default**: rigid joints (a rigid group, its glyphs not drawn) hold each part where the file put it in its parent, so moving the import's component moves everything, and an inner part doesn't move on its own; joint degrees of freedom count the whole import as one body. To joint the parts one by one, release them with **Unlock Contents** on the component's browser menu (`component.unlock {component}`), one undo step. Undo takes a whole import away in one step. + +Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did. + +Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature and becomes the import's components (above). Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges. ## Files from other CAD systems Files from different systems differ in which way is up and in what they call their solids. `doc.open`, `file.insert_step` and the MCP `open` tool take two options for STEP and IGES files; without them a file comes in as written. -- **`up`**: `"z"` (the default) keeps the file's axes. Many systems write Y up, so such a part lies on its back in SolidCraft's Z-up space. `"up": "y"` turns the file's bodies a quarter turn about X (+90°: Y becomes Z) with one Move feature right after the Import, the one the window's "Stand it up" offer adds. The import and the turn are one undo step, the reply names the Move as `stand_up`, and only the bodies of the file turn, not the ones already in the design. The window offers to stand a part up after a STEP file opens unless the call said which way is up (`up: "z"` as well as `"y"`). +- **`up`**: `"z"` (the default) keeps the file's axes. Many systems write Y up, so such a part lies on its back in SolidCraft's Z-up space. `"up": "y"` turns the import's component a quarter turn about X (+90°: Y becomes Z), as the window's "Stand it up" offer does, so an assembly's parts and instances turn together. The import and the turn are one undo step, the reply names the turned occurrence as `stand_up`, and only the file's component turns, not what is already in the design. The window offers to stand a part up after a STEP file opens unless the call said which way is up (`up: "z"` as well as `"y"`). - **`names`**: what the bodies are called. `"solid"` (the default) is the name of the solid in the file, and the product's name where the solid has none. Some systems fill a solid's name with the last feature that made it (`Cut-Extrude3`), so every body of an assembly has a name that says nothing about its part. `"product"` names each body after the product (the part) that holds it, and `"instance"` after the assembly occurrence (`Post-2`), or the product where the body is not in an occurrence. A name the file leaves out, or fills with a placeholder, falls back to the next one (instance, product, solid). Repeated names are numbered as any body names are (`Post`, `Post (2)`). The names are stored with the Import feature, so the design reopens with them. The default stays the solid's name because SolidCraft's own STEP export writes a body's name there. ```json diff --git a/examples/regressions/cut_whole_face_after_bottom_chamfer.json b/examples/regressions/cut_whole_face_after_bottom_chamfer.json new file mode 100644 index 00000000..ce744efa --- /dev/null +++ b/examples/regressions/cut_whole_face_after_bottom_chamfer.json @@ -0,0 +1,11 @@ +{ + "description": "A rounded-rectangle block (40 x 40, R8 corners, 20 high) with a 2 mm chamfer round its bottom loop, then an Extrude Cut of its whole top face 5 mm down. The tool's walls, its corner cylinders included, lay on the block's walls; only straight profile edges were grown off the walls, so the cut failed with 'no result' (#1275). Result: the block 15 high, less the chamfer.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.rectangle.rounded", "params": {"p0": [0, 0], "p1": [40, 40], "radius": 8}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 20, "operation": "new"}}, + {"command": "solid.chamfer", "params": {"distance": 2, "edges": [[20, 0, 0], [40, 20, 0], [20, 40, 0], [0, 20, 0], [2.343, 2.343, 0], [37.657, 2.343, 0], [37.657, 37.657, 0], [2.343, 37.657, 0]]}}, + {"command": "solid.extrude", "params": {"face": [20, 20, 20], "distance": -5, "operation": "cut"}} + ] +} diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index 0144bfed..6f718517 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -9,7 +9,9 @@ # (and whenever the AppImage's path changes) install the .desktop entry and the # hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave # the launch untouched. Opt out with SOLIDCRAFT_NO_DESKTOP_INTEGRATION=1 (the pre-rename -# SOLVECRAFT_NO_DESKTOP_INTEGRATION still works: compatibility shim, docs/compatibility.md). +# SOLVECRAFT_NO_DESKTOP_INTEGRATION still works: compatibility shim, docs/compatibility.md), +# or for good with an empty file $XDG_DATA_HOME/solidcraft/no-desktop-integration. Nothing is +# installed either when appimaged (go-appimage) already made an entry for this AppImage. set -u @@ -36,12 +38,28 @@ integrate() { home="${HOME:-}" [ -n "$home" ] || return 0 data="${XDG_DATA_HOME:-$home/.local/share}" + [ -e "$data/solidcraft/no-desktop-integration" ] && return 0 src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 + # appimaged integrates AppImages itself (appimagekit_-.desktop, Exec= this file): + # a second entry from here would show the app twice, so drop an entry this script wrote earlier + # (only one it wrote: it points Exec at a path in quotes) and leave the rest to appimaged. + apps="$data/applications" + for other in "$apps"/appimagekit_*.desktop; do + if ! { [ -f "$other" ] && grep -qF "$appimage" "$other" 2>/dev/null; }; then + continue + fi + mine="$apps/$DESK_ID.desktop" + if [ -f "$mine" ] && grep -q '^Exec="' "$mine"; then + rm -f "$mine" + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 + fi + return 0 + done + # Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop # TryExec, which would hide the entry; everything else is shipped as packaged. - apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 tmp="$apps/.$DESK_ID.tmp.$$" esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b76d183a..06881f4e 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Build and package SolidCraft for macOS: # -# $DIST/solidcraft--macos-.dmg SolidCraft.app on a drag-to-Applications DMG +# $DIST/solidcraft--macos-.dmg SolidCraft.app (with solidcraft-cli inside) on a +# drag-to-Applications DMG # $DIST/solidcraft-cli--macos-.zip the headless CLI (and its solvecraft-cli # compatibility alias) with the licence files # @@ -60,6 +61,9 @@ done echo "==> assembling $APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$WORK/bin/solidcraft" "$APP/Contents/MacOS/SolidCraft" +# solidcraft-cli next to the app, as every other package has it: the agent chat starts it as the +# agent's MCP server (`solidcraft-cli mcp --connect`) and looks for it beside the running program. +cp "$WORK/bin/solidcraft-cli" "$APP/Contents/MacOS/solidcraft-cli" # Apple-compiled layered icon and older-macOS fallback (regeneration: assets/README.md). # Keep compiled resources so packaging also works on runners with older Xcode versions. cp "$ROOT/assets/icons/app/macos/exports/Assets.car" "$APP/Contents/Resources/Assets.car" @@ -72,6 +76,7 @@ printf 'APPL????' >"$APP/Contents/PkgInfo" # Ad-hoc signature over the whole bundle (Apple silicon refuses unsigned code). codesign --force --sign - --timestamp=none "$APP" codesign --verify --strict --deep --verbose=2 "$APP" +test -x "$APP/Contents/MacOS/solidcraft-cli" || { echo "solidcraft-cli is missing from $APP" >&2; exit 1; } # ---- DMG --------------------------------------------------------------------------------------- echo "==> building $DMG" diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 05515144..60485bf5 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -8,7 +8,7 @@ description: Use when building, driving or animating an assembly or mechanism in ## Overview An assembly is components placed by joints. Work the mechanism out as world points and axes first: every pin, slide and hinge, which part each one joins, and the value you expect at each pose. Then give every joint origin by those coordinates, and check each step against a number you computed yourself. A drive that reports success can still have moved nothing. Command shapes, a worked example, the traps and reply sizes are in `reference.md` next to this file. To build the parts themselves, use `solidcraft-modeling`. -Written against SolidCraft `main` after 0.11.1, which counts the degrees of freedom of closed loops and carries motion links through chains made in any order. The traps in `reference.md` say which answers are about to change. +Written against SolidCraft `main` after 0.11.1, which counts the degrees of freedom of closed loops, carries motion links through chains made in any order, and answers drives in degrees with `moved` (and a `reason` when nothing could move). ## Before the first call 1. Read `reference.md`. @@ -36,11 +36,11 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free - **Origins by world coordinates:** `{"occurrence":..., "point":[x,y,z], "z":[ax,ay,az]}`. Always give `occurrence`; without it the nearest body is used. Use the same form on both sides; `frame`, `face`, `circle` and named origins are in `reference.md`. - **Pass `flip: true`** when both origins are the same world point and axis, which is the case when you join parts where they sit. Without it, b's z mates against a's and part b turns 180 degrees. - **Parts already in place:** `joint.as_built {"type":..., "a":..., "b":..., "at":{origin}, "name":..., "limits":[[min,max]]}` keeps them where they are. -- **Values and limits** are degrees for angles and mm for distances. An angle turns right-handed about the joint's z. A drive past a joint's own limits is clamped to them, which is not a failure. A new joint starts at 0, so its limits must include 0, or the part turns to the nearer limit as it is joined. On a joint with several values, drive with `values`: `value` resets all but the first to 0. -- **Every chain must reach something grounded.** A part that is neither grounded nor joined to a grounded part is never placed. A drive on a joint in that chain answers with the new value and no conflicts, and nothing moves. `dof: 6` on an occurrence in `joint.list` is the sign. Fix it with `joint.rigid_group {"occurrences":["Frame:1", ...]}`, or with a joint to the grounded structure. +- **Values and limits** are degrees for angles and mm for distances. An angle turns right-handed about the joint's z. A drive past a joint's own limits is clamped to them, which is not a failure. A new joint starts at its `values` (default 0); one outside the limits starts at the nearer limit, so the part turns there as it is joined, and the reply's `warnings` say so. `value` drives a joint's first value and `values` the ones it lists; the others keep theirs. +- **Every chain must reach something grounded.** A part that is neither grounded nor joined to a grounded part is never placed. A drive on a joint in that chain moves nothing and answers `moved: false`, with a `reason` and the part joined to nothing as `unconnected`. `joint.list` lists such chains under `floating`, and their occurrences have `dof: 6`. Fix it with `joint.rigid_group {"occurrences":["Frame:1", ...]}`, or with a joint to the grounded structure. ## Linked and closed motion -- **Motion links:** `joint.motion_link {"a":..., "b":..., "ratio":r, "offset":o}` sets b = r·a + o, clamped to b's own limits. `offset` is in radians for an angle (mm for a distance), unlike every other joint value. A mirrored part takes `ratio: -1`, and limits mirrored too. Chains (a drives b, b drives c) work in any order of creation. Drive only the head of a chain. +- **Motion links:** `joint.motion_link {"a":..., "b":..., "ratio":r, "offset":o}` sets b = r·a + o, clamped to b's own limits. `ratio` is b's units per a's: a pinion that moves its rack 0.5 mm per degree takes `ratio: 0.5`. `offset` is in b's units: degrees for an angle, mm for a distance, or an expression. A mirrored part takes `ratio: -1`, and limits mirrored too. Chains (a drives b, b drives c) work in any order of creation. Drive only the head of a chain. - **Closed loops:** an actuator between a frame and a hinged panel takes three joints besides the hinge: - a revolute root pin (frame and barrel) - a slider (barrel and rod), with z along the actuator from root to tip on both origins, both at the root pin, and the stroke as its limits @@ -48,7 +48,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free A four-bar or a slider-crank follows the same pattern. Drive the hinge; the pins and the slider follow. - **Stops:** at the end of the stroke the drive stops, with `stopped_by_loop: {joint, fraction}` naming the joint that could not follow. A loop that cannot close at all is listed under `conflicts`, and nothing in it moves. -- **dof** counts the loop: here the panel, barrel and rod report 1 each. At a pose where the actuator lies along its tip pin's path, a part can report 0 and still move; drive a little and list again. +- **dof** counts the loop: here the panel, barrel and rod report 1 each, also at a pose where the actuator lies along its tip pin's path. ## Contact and motion studies - **Contact:** `contact.create {"occurrences":[...], "name":...}` and `contact.enable_sets {}`. A drive then stops where two bodies of one set would pass through each other, with `stopped_by_contact: {a, b, fraction}`. `contact.check {}` lists the pairs that overlap now. @@ -72,8 +72,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free | Thought | Do instead | |---|---| | "The origins are at the same point, so `flip` doesn't matter" | Pass `flip: true`, or the part turns 180 degrees. | -| "The drive answered values, so it moved" | Look for `dof: 6` in `joint.list`; a floating chain answers and moves nothing. | -| "`joint.drive` says 0.349, the drive failed" | Its `values` are radians; `joint.list` gives degrees. (Once its replies carry `unit`, they are degrees.) | +| "The drive answered values, so it moved" | Read `moved`; a chain joined to nothing grounded answers `moved: false` and a `reason`. | | "Key every joint of the loop in the study" | Key the driving joint only; the loop follows. | | "Check the pose with `component.list`" | `occurrence.properties` on the one occurrence. `component.list` is 140 KB on a 36-part assembly. | | "Open the design with `file.open`" | The `open` tool, or `doc.open`; there is no `file.open`. | diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index 91fa3185..cbc8ee61 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -58,10 +58,11 @@ A slider's value grows as b moves along +z of its origin. Give its two origins t {"command":"occurrence.properties","params":{"occurrence":"Wing:1"}} ``` +- `joint.create` and `joint.as_built` start the joint at its `values` (default 0). A value outside the limits starts at the nearer limit, and the reply's `warnings` say so: the part turned as it was joined. - `joint.as_built` takes occurrences, not origins. `at` is the joint's frame (default: b's component origin). - `joint.rigid_group` joins every occurrence to the first one with a rigid as-built joint. -- `joint.motion_link`: b = ratio·a + offset, clamped to b's limits. `offset` is a plain number in radians for an angle and mm for a distance: 10 degrees is `0.17453`. `ia`/`ib` pick a value of a multi-value joint (default 0). `joint.delete {"link": n}` removes link n (its index in `joint.list` `links`). -- `joint.drive`: `values` drives the values it lists, in order (`Spindle` above is cylindrical: 30 degrees, then 5 mm); values left out keep theirs, or follow in a closed loop. `value` drives the first value and **resets the others to 0**, so use `values` on a joint with several. Values are clamped to the limits. +- `joint.motion_link`: b = ratio·a + offset, clamped to b's limits. `ratio` is in b's units per a's: mm per degree when an angle drives a distance (a pinion of pitch radius r moves its rack r·π/180 mm per degree), degrees per mm the other way, and a plain factor between two angles or two distances. `offset` is in b's units: degrees for an angle, mm for a distance, or an expression such as `"10 deg"`. `joint.list` gives each link's `ratio` the same way and its `offset` with its `unit`. `ia`/`ib` pick a value of a multi-value joint (default 0). `joint.delete {"link": n}` removes link n (its index in `joint.list` `links`). +- `joint.drive`: `values` drives the values it lists, in order (`Spindle` above is cylindrical: 30 degrees, then 5 mm), and `value` the first; values left out keep theirs, or follow in a closed loop. Values are clamped to the limits. The reply gives `values` as `joint.list` does (`{name, value, unit, limits}`), and `moved`; when the joint can move nothing, a `reason`. - `motion.study {"study":..., ...}` edits a study; `keys` given replace all keys. - `motion.play` sets the joints to the study's values at a step (fractions work) and changes the design. `motion.export` does not change it. @@ -118,14 +119,10 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | Symptom | Cause and fix | |---|---| | A part turns 180 degrees when joined; a loop reports "off by ... and 180 degrees" | `flip` left out. Pass `flip: true`, or `joint.edit {"joint":..., "flip":true}`. | -| A part turns to a limit as soon as it is joined | A new joint starts at 0 and is clamped to its limits. Make the limits include 0: `[[10, 60]]` on a hinge turns it 10 degrees. | +| A part turns to a limit as soon as it is joined, and the reply has `warnings` | A new joint starts at 0, or its `values`, inside its limits: `[[10, 60]]` on a hinge turns it 10 degrees. Make the limits include 0, or join the parts where the limits allow. | | A part turns by some other angle when joined | The two origins have different forms or x axes. Use `point` + `z` on both sides, or `joint.as_built`. | -| `joint.drive` or `motion.play` answers values and no conflicts (`[]` from a drive, `0` from play), and nothing moves | The chain is joined to nothing grounded (`dof: 6` in `joint.list`). Ground it, join it, or `joint.rigid_group` it with the structure. | -| `joint.drive` answers `values: [0.349]` after a drive to 20 | Its `values` are radians. `joint.list` answers degrees with `unit`. | +| `joint.drive` answers `moved: false`, or `motion.play` lists the joint under `unmoved` | Read `reason`. Most often the chain is joined to nothing grounded: `unconnected` names the part, and `joint.list` lists the chain under `floating`, its occurrences with `dof: 6`. Ground it, join it, or `joint.rigid_group` it with the structure. | | A drive stops short, `stopped_by_loop` | A loop reached a limit or can't close further; `joint` names the joint that stopped it. Check the limits against your stroke numbers. | -| A part of a loop reports `dof: 0` at the pose it was modelled in, and moves once driven | `dof` is counted at the current pose. A part whose motion vanishes there (an actuator in line with the path of its tip pin, so it only extends) counts 0. Drive a little and list again. | -| A cylindrical, pin-slot, planar or ball joint loses its other values after a drive | `joint.drive {"value":v}` resets every value but the first to 0. Drive with `values: [v]`, which keeps the ones it leaves out. | -| A linked angle is off by far more than its `offset` | `joint.motion_link` reads `offset` in radians for angles: `"offset":10` adds 573 degrees. | | A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. | | A drive stops short, `stopped_by_contact` | Two bodies of a contact set touch. `fraction` is how far the move got. | | `occurrence.ground` says there is no occurrence `Frame` | It takes occurrence names only: `Frame:1`. | @@ -135,8 +132,6 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | `solidcraft-cli snapshot SCRIPT --in design` runs on an empty design | `snapshot` ignores `--in`. Run `solidcraft-cli run SCRIPT --in design --save out.solidcraft` first. | | A URDF export lacks a joint | `export` to `.urdf` leaves out each joint that closes a loop, with a warning, because URDF is a tree. | -**Coming changes.** A pending engine change reworks these answers. `joint.drive` and `motion.play` will give `values` in degrees and mm with their `unit`, and say `moved` (and why not). `joint.list` will list `floating` chains, and `quiet: true` will leave out the `dof` lists. When it lands, the radians row above no longer applies, and a drive on a floating chain says why it moved nothing. - ## Reply cost | Call | Size | @@ -145,7 +140,7 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | `joint.list` | about 0.5 KB per joint: 16 KB for 29 joints | | `motion.play` | about 0.2 KB per occurrence, per step: 9 KB on SELENE | | `motion.export` | the same per sample; pass `samples`, or `path` to write it to a file | -| `joint.create`, `joint.drive`, `joint.motion_link` | the `dof` list of every occurrence again, 40 B each | +| `joint.create`, `joint.drive`, `joint.motion_link` | the `dof` list of every occurrence again, 40 B each; `quiet: true` leaves it out | | `occurrence.properties` | under 1 KB per occurrence | ## Showing it moving diff --git a/tests/action_matrix/baseline.json b/tests/action_matrix/baseline.json index 7e121f79..312a3924 100644 --- a/tests/action_matrix/baseline.json +++ b/tests/action_matrix/baseline.json @@ -9159,10 +9159,10 @@ "Body1/F3:face:31|F3:face:32": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:31|F3:face:33": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:32": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:32|F3:face:37": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:32|F3:face:37": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:32|F3:face:8": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:33": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:33|F3:face:44": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:33|F3:face:44": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:33|F3:face:7": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:34": "curved.axis curved.tangent_plane", "Body1/F3:face:34|F3:face:36": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", @@ -9172,7 +9172,7 @@ "Body1/F3:face:35|F3:face:36": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:35|F3:face:37": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:36": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:36|F3:face:41": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:36|F3:face:41": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:37": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:face:38": "curved.axis curved.tangent_plane", "Body1/F3:face:38|F3:face:40": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", @@ -9188,7 +9188,7 @@ "Body1/F3:face:3|F3:face:8": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:4": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:face:40": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", - "Body1/F3:face:40|F3:face:45": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project", + "Body1/F3:face:40|F3:face:45": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project", "Body1/F3:face:41": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch", "Body1/F3:face:42": "curved.axis curved.tangent_plane", "Body1/F3:face:42|F3:face:44": "!edge.chamfer !edge.fillet edge.normal_plane edge.project", diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 30c5f15c..eae512b6 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -1,7 +1,7 @@ { "about": "What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\".", "seeds": { - "0": {"area":4795.764396,"bodies":1,"edges":79,"error":null,"faces":29,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","circular pattern","circular pattern","cut sphere","join cylinder","cut cylinder","fillet"],"unsupported":1,"volume":13466.472108}, + "0": {"area":4785.573759,"bodies":1,"edges":78,"error":null,"faces":29,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","hole","circular pattern","circular pattern","cut sphere","join cylinder","cut cylinder","fillet"],"unsupported":1,"volume":13461.708496}, "1": {"area":2642.476583,"bodies":1,"edges":37,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","join cylinder"],"unsupported":0,"volume":6991.559261}, "2": {"area":2522.922626,"bodies":1,"edges":36,"error":"boolean Cut: the operation failed: no result","faces":14,"outcome":"failed","rejected":1,"shells":1,"steps":["box","cut cylinder","fillet","cut sphere","rectangular pattern"],"unsupported":2,"volume":5580.088497}, "3": {"area":6336.456143,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","hole","fillet","hole"],"unsupported":0,"volume":28037.146888}, @@ -35,14 +35,14 @@ "31": {"area":10198.42294,"bodies":3,"edges":67,"error":null,"faces":29,"outcome":"ok","rejected":1,"shells":3,"steps":["box","cut box","fillet","join box","fillet","circular pattern","cut box","circular pattern"],"unsupported":0,"volume":56408.3612}, "32": {"area":3220.802769,"bodies":1,"edges":51,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","cut box","cut box","join box"],"unsupported":0,"volume":7565.955897}, "33": {"area":5286.736353,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13858.467983}, - "34": {"area":1086.303472,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275}, + "34": {"area":1086.305562,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275}, "35": {"area":4605.630216,"bodies":1,"edges":26,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","join box"],"unsupported":0,"volume":13965.526982}, "36": {"area":10408.825748,"bodies":1,"edges":60,"error":null,"faces":26,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","cut cylinder","join box","fillet","join cylinder"],"unsupported":0,"volume":55729.313103}, "37": {"area":11714.822253,"bodies":1,"edges":44,"error":null,"faces":18,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut cylinder","join cylinder","cut sphere","circular pattern","fillet","circular pattern","rectangular pattern"],"unsupported":2,"volume":75556.602064}, "38": {"area":11769.375811,"bodies":1,"edges":30,"error":"offset: a corner whose faces don't meet the same way after t","faces":12,"outcome":"failed","rejected":1,"shells":1,"steps":["box","hole","cut cylinder","shell"],"unsupported":0,"volume":78261.020975}, - "39": {"area":1892.050815,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","chamfer","cut sphere","rectangular pattern"],"unsupported":1,"volume":4040.62787}, + "39": {"area":1892.051593,"bodies":1,"edges":27,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","chamfer","cut sphere","rectangular pattern"],"unsupported":1,"volume":4040.619835}, "40": {"area":15548.516294,"bodies":1,"edges":49,"error":"offset: a corner whose faces don't meet the same way after t","faces":22,"outcome":"failed","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","cut cylinder","join cylinder","shell"],"unsupported":0,"volume":16543.777532}, - "41": {"area":7295.281456,"bodies":1,"edges":94,"error":null,"faces":32,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut sphere","shell","fillet","cut cylinder","rectangular pattern","fillet","cut sphere"],"unsupported":0,"volume":5185.215737}, + "41": {"area":7295.282884,"bodies":1,"edges":93,"error":null,"faces":32,"outcome":"ok","rejected":1,"shells":1,"steps":["box","cut sphere","shell","fillet","cut cylinder","rectangular pattern","fillet","cut sphere"],"unsupported":0,"volume":5185.221068}, "42": {"area":5486.877622,"bodies":1,"edges":51,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut box","cut cylinder","rectangular pattern"],"unsupported":0,"volume":19095.106157}, "43": {"area":3492.988343,"bodies":1,"edges":72,"error":null,"faces":26,"outcome":"ok","rejected":2,"shells":1,"steps":["extruded profile","cut cylinder","rectangular pattern","fillet","hole","rectangular pattern","join cylinder"],"unsupported":1,"volume":7528.548742}, "44": {"area":7972.435566,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","shell","cut sphere"],"unsupported":0,"volume":7622.184366}, @@ -58,7 +58,7 @@ "54": {"area":7080.555724,"bodies":4,"edges":90,"error":null,"faces":38,"outcome":"ok","rejected":0,"shells":4,"steps":["extruded profile","join box","circular pattern","cut cylinder","cut box","join cylinder","cut cylinder"],"unsupported":3,"volume":15737.224446}, "55": {"area":6796.974648,"bodies":1,"edges":60,"error":"Body","faces":23,"outcome":"invalid","rejected":0,"shells":1,"steps":["box","fillet","cut sphere","circular pattern","rectangular pattern","cut sphere"],"unsupported":0,"volume":24741.239331}, "56": {"area":4453.31728,"bodies":1,"edges":33,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["box","cut box","chamfer"],"unsupported":0,"volume":11829.375}, - "57": {"area":4678.619229,"bodies":1,"edges":63,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut sphere","chamfer","circular pattern","cut sphere","cut sphere","cut cylinder"],"unsupported":2,"volume":16016.156622}, + "57": {"area":4671.095724,"bodies":1,"edges":66,"error":null,"faces":24,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut sphere","circular pattern","chamfer","cut sphere","cut sphere","cut cylinder"],"unsupported":2,"volume":16017.21038}, "58": {"area":4197.049258,"bodies":1,"edges":42,"error":null,"faces":16,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","fillet","cut sphere"],"unsupported":0,"volume":12543.173591}, "59": {"area":7770.5632,"bodies":1,"edges":36,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["box","join box","join cylinder"],"unsupported":0,"volume":29735.209746}, "60": {"area":3443.328607,"bodies":1,"edges":39,"error":null,"faces":15,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":11779.204543}, @@ -116,6 +116,11 @@ {"reason":"#952: a hole placed on a face drills into the material; seed 81's hole moves its volume by 0.1 mm³ (area by 0.04 mm²)","seeds":[81]}, {"reason":"Cuts join the straight edges they used to leave split along untouched walls (#1049). Replaying each changed seed's new commands on the build before and after gives the same volume, area and faces at every step, and the same refusals (seeds 2 and 95 end on a cut the old build refuses too), except: seed 94's final join was 1.7 mm³ too large before (the box adds 875 − 2.5 × 97.967 = 630.08 mm³ to 5270.48; now 5900.56, before 5902.27), and seed 71's hole differs only in the coarse measuring mesh (identical at 2e-4 mm). Every other volume change is the generator picking other edges by index for fillets and chamfers, some of which are now accepted; seed 71's join now builds.","seeds":[2,4,7,12,13,17,25,31,38,42,45,51,52,56,65,67,69,71,72,74,82,93,94,95]}, {"reason":"#1164: a pattern copy of a box sitting flush on the half-ring's top face (a 27 mm2 contact patch) now joins the ring instead of staying a separate shell, so the later random steps pick different edges and positions","seeds":[13]}, - {"reason":"#1213: a round or chamfer on an edge chain that runs on across a curved seam now builds (it was refused with 'a curved seam where the edge chain turns'). Seed 41's second fillet (three edges, radius 1) was one of those refusals and is now built. Replaying the seed's commands on the build before and after gives the same volume, area, faces and edges after each of steps 1 to 6; step 7, the fillet, is refused before and builds after (5292.19 to 5289.17 mm³, area 7395.26 to 7381.22 mm²); the run then reaches its eight steps one step sooner, so its last step is a cut sphere (5185.22 mm³) where it was a join cylinder (the generator draws its later steps from the new state).","seeds":[41]} + {"reason":"A point at a boundary curve's end is that end in the boolean (#391): seeds 0 and 41 lose a sliver edge (8e-5 mm on seed 41) that a cut left. Seed 0's fillet of its two edges then takes out 10.62 mm³, as its edges do alone (3.11 and 7.40 mm³), where it took 5.86. Seed 39 moves by 0.008 mm³; seed 57's chamfer and pattern come in the other order, its body valid with no edge under 10 µm","seeds":[0,39,41,57]}, + {"reason":"#391 after merging main: seed 34's area moves by 0.002 mm² (1086.303472 to 1086.305562, 2e-6 relative); its steps, outcome, volume and face, edge and shell counts are unchanged; the same small kind of move as seed 39's","seeds":[34]}, + {"reason":"#1213: a round or chamfer on an edge chain that runs on across a curved seam now builds (it was refused with 'a curved seam where the edge chain turns'). Seed 41's second fillet (three edges, radius 1) was one of those refusals and is now built. Replaying the seed's commands on the build before and after gives the same volume, area, faces and edges after each of steps 1 to 6; step 7, the fillet, is refused before and builds after (5292.19 to 5289.17 mm³, area 7395.26 to 7381.22 mm²); the run then reaches its eight steps one step sooner, so its last step is a cut sphere (5185.22 mm³) where it was a join cylinder (the generator draws its later steps from the new state).","seeds":[41]}, + {"reason":"#391 after merging main with #1213: seed 41 now reaches its second fillet (built since #1213), and the end-snap fix again drops a sliver edge the cuts left (94 to 93 edges); its steps, outcome, faces and shells are unchanged, volume moves by 0.005 mm³ (5185.215737 to 5185.221068) and area by 0.0014 mm²","seeds":[41]}, + {"reason":"the chamfer bug is on main too, see #1390","seeds":[57]}, + {"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]} ] } diff --git a/tools/windows-resize-smoke.ps1 b/tools/windows-resize-smoke.ps1 new file mode 100644 index 00000000..8722a345 --- /dev/null +++ b/tools/windows-resize-smoke.ps1 @@ -0,0 +1,198 @@ +# Start the Windows app and check its resize border (#1381): as soon as the window exists (before +# the first frame), Windows' hit test reports a resize edge or corner within the resize-border +# width of every side and the client area inside it; a maximized window has none; and a real +# press-and-drag on the bottom edge (SetCursorPos + mouse input) resizes the window. +# +# What this can't check on a CI runner (one monitor at 100 %): moving the window between monitors +# with different scales, and the resize border's width at other scales (unit tests cover the sizes). +# +# Usage: tools/windows-resize-smoke.ps1 -Exe target\ci\solidcraft.exe +param([Parameter(Mandatory = $true)][string]$Exe) +$ErrorActionPreference = 'Stop' + +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class Win { + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; } + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern IntPtr SendMessageW(IntPtr h, uint msg, IntPtr w, IntPtr l); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); + [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); + [DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, uint data, UIntPtr extra); + [DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h); + [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i); + [DllImport("user32.dll")] public static extern int GetSystemMetricsForDpi(int i, uint dpi); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc f, IntPtr l); + public static string Text(IntPtr h) { var s = new System.Text.StringBuilder(256); GetWindowTextW(h, s, 256); return s.ToString(); } + public static string Class(IntPtr h) { var s = new System.Text.StringBuilder(256); GetClassNameW(h, s, 256); return s.ToString(); } + // The process's visible top-level window titled SolidCraft (the app's main window; the + // process has other top-level windows, such as the IME's). + public static IntPtr MainWindow(uint pid) { + IntPtr found = IntPtr.Zero; + EnumWindows((h, l) => { + uint p; GetWindowThreadProcessId(h, out p); + if (p == pid && IsWindowVisible(h) && Text(h).StartsWith("SolidCraft")) { found = h; return false; } + return true; + }, IntPtr.Zero); + return found; + } + public static int HitTest(IntPtr h, int x, int y) { + long l = ((long)(ushort)(short)y << 16) | (long)(ushort)(short)x; + return (int)SendMessageW(h, 0x0084 /* WM_NCHITTEST */, IntPtr.Zero, new IntPtr(l)); + } +} +'@ + +$HT = @{ 1 = 'client'; 10 = 'left'; 11 = 'right'; 12 = 'top'; 13 = 'top-left'; 14 = 'top-right'; 15 = 'bottom'; 16 = 'bottom-left'; 17 = 'bottom-right' } +$failures = New-Object System.Collections.Generic.List[string] +function Check([bool]$ok, [string]$what) { + if ($ok) { Write-Host "ok: $what" } else { Write-Host "FAIL: $what"; $failures.Add($what) } +} +function Rect($h) { $r = New-Object Win+RECT; [void][Win]::GetWindowRect($h, [ref]$r); $r } + +# Every side and corner, `in` px inside the window's outer pixel, and points just past the border. +function CheckBorder($h, [string]$when) { + $r = Rect $h + $dpi = [Win]::GetDpiForWindow($h) + # Windows' own resize border at this DPI (SM_CXSIZEFRAME + SM_CXPADDEDBORDER), as the app uses it. + $b = [Win]::GetSystemMetricsForDpi(32, $dpi) + [Win]::GetSystemMetricsForDpi(92, $dpi) + Check ($b -ge 1) "$when : a resize border of $b px" + $cx = [int](($r.Left + $r.Right) / 2); $cy = [int](($r.Top + $r.Bottom) / 2) + $l = $r.Left; $t = $r.Top; $rt = $r.Right - 1; $bt = $r.Bottom - 1 + Write-Host "$when : window $($r.Left),$($r.Top) - $($r.Right),$($r.Bottom) at $dpi DPI, border $b px" + $cases = @( + @($l, $cy, 10), @(($l + $b - 1), $cy, 10), @(($l + $b), $cy, 1), + @($rt, $cy, 11), @(($rt - $b + 1), $cy, 11), @(($rt - $b), $cy, 1), + @($cx, $t, 12), @($cx, ($t + $b - 1), 12), @($cx, ($t + $b), 1), + @($cx, $bt, 15), @($cx, ($bt - $b + 1), 15), @($cx, ($bt - $b), 1), + @($l, $t, 13), @($rt, $t, 14), @($l, $bt, 16), @($rt, $bt, 17), + @(($l + 2 * $b - 1), $t, 13), @($rt, ($bt - 2 * $b + 1), 17), + @($cx, $cy, 1) + ) + foreach ($c in $cases) { + $got = [Win]::HitTest($h, $c[0], $c[1]) + Check ($got -eq $c[2]) "$when hit test at ($($c[0]), $($c[1])): $($HT[$got]) ($got), want $($HT[$c[2]])" + } +} + +$port = 47901 +$root = Join-Path $env:RUNNER_TEMP 'resize-smoke' +Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue +$settings = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'SolidCraft\data' +Remove-Item -Recurse -Force $settings -ErrorAction SilentlyContinue +$local = Join-Path $root 'local' +$crashes = Join-Path $root 'crashes' +New-Item -ItemType Directory -Force $settings, $local, $crashes | Out-Null +$token = -join ((1..64) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) + +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = (Resolve-Path $Exe).Path +foreach ($a in @('--sample', '--control', "$port")) { [void]$psi.ArgumentList.Add($a) } +$psi.UseShellExecute = $false +$psi.RedirectStandardError = $true +$psi.RedirectStandardOutput = $true +$psi.Environment['LOCALAPPDATA'] = $local +$psi.Environment['SOLIDCRAFT_CRASH_DIR'] = $crashes +$psi.Environment['SOLIDCRAFT_CONTROL_TOKEN'] = $token +$p = [System.Diagnostics.Process]::Start($psi) +$stderr = $p.StandardError.ReadToEndAsync() +$stdout = $p.StandardOutput.ReadToEndAsync() + +try { + # The window as soon as it shows (eframe creates it hidden and shows it after the first frame). + $h = [IntPtr]::Zero + $deadline = (Get-Date).AddSeconds(180) + while ($h -eq [IntPtr]::Zero -and (Get-Date) -lt $deadline -and -not $p.HasExited) { + $h = [Win]::MainWindow([uint32]$p.Id) + if ($h -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds 20 } + } + if ($h -eq [IntPtr]::Zero) { throw "no window (exited: $($p.HasExited))" } + Write-Host "window: '$([Win]::Text($h))' class '$([Win]::Class($h))'" + CheckBorder $h 'first sight' + + $client = $null + while (-not $client -and (Get-Date) -lt $deadline -and -not $p.HasExited) { + try { $client = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $port) } catch { Start-Sleep -Milliseconds 500 } + } + if (-not $client) { throw "no control channel (exited: $($p.HasExited))" } + $client.ReceiveTimeout = 120000 + $stream = $client.GetStream() + $writer = New-Object System.IO.StreamWriter($stream) + $writer.AutoFlush = $true + $reader = New-Object System.IO.StreamReader($stream) + $writer.WriteLine((@{ id = 1; token = $token; method = 'ui.screenshot'; params = @{ path = (Join-Path $root 'first.png') } } | ConvertTo-Json -Compress)) + Write-Host "first frame: $($reader.ReadLine())" + + # A known place and size that fits the runner's screen (physical px; the minimum is 960 x 600). + $sw = [Win]::GetSystemMetrics(0); $sh = [Win]::GetSystemMetrics(1) + Write-Host "screen: $sw x $sh" + [void][Win]::SetWindowPos($h, [IntPtr]::Zero, 20, 20, [Math]::Min(1000, $sw - 40), [Math]::Min(620, $sh - 140), 0x0014) # NOZORDER | NOACTIVATE + Start-Sleep -Milliseconds 500 + CheckBorder $h 'restored' + + # A real drag on the bottom edge, if this session takes mouse input. + $before = Rect $h + [void][Win]::SetForegroundWindow($h) + $x = [int](($before.Left + $before.Right) / 2); $y = $before.Bottom - 2 + [void][Win]::SetCursorPos($x, $y) + $pt = New-Object Win+POINT; [void][Win]::GetCursorPos([ref]$pt) + if ($pt.X -ne $x -or $pt.Y -ne $y) { + Write-Host "skipped: the drag (this session doesn't move the pointer: at $($pt.X),$($pt.Y))" + } else { + Start-Sleep -Milliseconds 300 + [Win]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # LEFTDOWN + $grow = [Math]::Min(80, $sh - $y - 10) + for ($i = 1; $i -le 8; $i++) { + Start-Sleep -Milliseconds 40 + [void][Win]::SetCursorPos($x, $y + [int]($grow * $i / 8)) + [Win]::mouse_event(0x0001, 0, 0, 0, [UIntPtr]::Zero) # MOVE (relative 0: the position above) + } + Start-Sleep -Milliseconds 100 + [Win]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # LEFTUP + Start-Sleep -Milliseconds 500 + $after = Rect $h + $dh = ($after.Bottom - $after.Top) - ($before.Bottom - $before.Top) + Check ($dh -ge $grow - 4 -and $after.Top -eq $before.Top) "dragging the bottom edge down $grow px resizes the window (height +$dh, top $($before.Top) -> $($after.Top))" + } + + # Maximized: no resize border, the whole window is client area. + [void][Win]::ShowWindow($h, 3) # SW_MAXIMIZE + Start-Sleep -Milliseconds 500 + if ([Win]::IsZoomed($h)) { + # The maximized window's rect overhangs the screen by the hidden frame (Windows' own + # frame answers there); the screen's left edge is where the pointer can be. + $r = Rect $h + $x = [Math]::Max($r.Left, 0) + 1 + $got = [Win]::HitTest($h, $x, [int](($r.Top + $r.Bottom) / 2)) + Check ($got -eq 1) "maximized ($($r.Left),$($r.Top) - $($r.Right),$($r.Bottom)), the screen's left edge is client area ($($HT[$got]))" + } else { + Check $false 'the window maximizes' + } + [void][Win]::ShowWindow($h, 9) # SW_RESTORE + Start-Sleep -Milliseconds 500 + CheckBorder $h 'restored again' + + $writer.WriteLine((@{ id = 2; method = 'app.quit'; params = @{ force = $true } } | ConvertTo-Json -Compress)) + Write-Host "quit: $($reader.ReadLine())" + $client.Close() + if (-not $p.WaitForExit(60000)) { throw 'did not exit after app.quit' } + if ($p.ExitCode -ne 0) { throw ('exit code 0x{0:x}' -f $p.ExitCode) } +} catch { + $failures.Add($_.Exception.Message) +} finally { + if (-not $p.HasExited) { $p.Kill(); $p.WaitForExit() } +} +Write-Host $stderr.Result +if ($failures.Count -gt 0) { Write-Error ("resize border: " + ($failures -join '; ')) } +Write-Host 'PASS: resize border' diff --git a/vendor/README.md b/vendor/README.md index 2af44493..1f38c883 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,6 +17,10 @@ Changes: - `transversal/integrate/mod.rs`: the result's shells and faces keep the order of the shell they come from (truck's connected components followed hash order of face addresses, so a boolean's faces came out in a different order on every run). +- `transversal/loops_store/mod.rs`: a point at an end of a boundary curve (within the point + tolerance) is that end, whatever its parameter: a polyline's parameter counts segments, so + an intersection curve ending 2e-7 mm from another's end could read as inside it, cut it + into a sliver with a vertex of its own, and leave the result's shell open (#391). - `transversal/loops_store/mod.rs`: an intersection edge added to a face at a vertex that more than one wire visits (several curves end on it: the touching point of two equal cylinders) goes into the corner that holds its direction, swept counterclockwise about the face's normal from @@ -75,6 +79,13 @@ Changes: and the face was then left unmeshed, which also failed every boolean with the body (#882). A walk that succeeds is kept as it was: where a loop starts decides how it is laid out across a periodic surface's seam. +- `tessellation/mod.rs`, `triangulation.rs`: edges are sampled through a new `TrySubs` trait + (`try_subs`, `approx_subs`), a bound of `PolylineableCurve`, instead of `subs`. truck's + `IntersectionCurve::subs` unwraps a projection onto its two surfaces that can fail to + converge, which panicked while meshing (#1252). A failed sample at an interval's end uses the + intersection curve's leader point, so the polyline stays finite; a failed interior probe is + NaN, which already counts as flat. `truck-shapeops`' `ShapeOpsCurve` carries the same bound, + and `truck-modeling` is now a regular dependency (it was a dev-dependency). - `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose parameter box is clear of the point (it tested every segment of every loop, so a face with hundreds of holes meshed in quadratic time). diff --git a/vendor/truck-meshalgo/Cargo.toml b/vendor/truck-meshalgo/Cargo.toml index 99cf60e6..73c8199c 100644 --- a/vendor/truck-meshalgo/Cargo.toml +++ b/vendor/truck-meshalgo/Cargo.toml @@ -68,6 +68,9 @@ version = "0.5.0" [dependencies.truck-geometry] version = "0.5.0" +[dependencies.truck-modeling] +version = "0.6.0" + [dependencies.truck-polymesh] version = "0.6.0" diff --git a/vendor/truck-meshalgo/src/tessellation/mod.rs b/vendor/truck-meshalgo/src/tessellation/mod.rs index 0bc21fe6..f9d64f02 100644 --- a/vendor/truck-meshalgo/src/tessellation/mod.rs +++ b/vendor/truck-meshalgo/src/tessellation/mod.rs @@ -25,12 +25,51 @@ pub fn preview_face(surface: &S, loops: &[Vec] triangulation::preview_face(surface, loops, steps) } +/// SolidCraft: sampling a curve that may fail to evaluate. truck's `IntersectionCurve::subs` +/// unwraps the projection of its leader's point onto both surfaces, which does not converge +/// everywhere; the mesher samples through this trait so that a failed sample is data, not a panic. +pub trait TrySubs: ParametricCurve3D { + /// The point at `t`, or `None` where the curve does not evaluate. + fn try_subs(&self, t: f64) -> Option { Some(self.subs(t)) } + /// A point near the curve at `t` that always exists (an intersection curve's leader). + fn approx_subs(&self, t: f64) -> Point3 { self.subs(t) } +} + +impl TrySubs for truck_geometry::prelude::Line {} +impl TrySubs for truck_geometry::prelude::BSplineCurve {} +impl TrySubs for truck_geometry::prelude::NurbsCurve {} +impl TrySubs for PolylineCurve {} + +impl TrySubs for truck_geometry::prelude::IntersectionCurve +where + C: ParametricCurve3D, + S: ParametricSurface3D + SearchNearestParameter, +{ + fn try_subs(&self, t: f64) -> Option { self.search_triple(t).map(|triple| triple.0) } + fn approx_subs(&self, t: f64) -> Point3 { self.leader().subs(t) } +} + +impl TrySubs for truck_modeling::Curve { + fn try_subs(&self, t: f64) -> Option { + match self { + truck_modeling::Curve::IntersectionCurve(curve) => TrySubs::try_subs(curve, t), + other => Some(other.subs(t)), + } + } + fn approx_subs(&self, t: f64) -> Point3 { + match self { + truck_modeling::Curve::IntersectionCurve(curve) => TrySubs::approx_subs(curve, t), + other => other.subs(t), + } + } +} + /// Gathered the traits used in tessellation. pub trait PolylineableCurve: - ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable { + ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable + TrySubs { } impl< - C: ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable, + C: ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable + TrySubs, > PolylineableCurve for C { } diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index 558d202a..773f8f41 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -863,15 +863,20 @@ pub(super) fn face_cap(faces: usize) -> usize { /// tolerance is still halved while it turns more than the deflection's angle. fn bounded_polyline(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve { let (tol, turn2) = (d.chord, d.turn2()); - let mut out = vec![curve.subs(range.0)]; + // A sample that does not evaluate (an intersection curve whose projection fails): an interval + // end falls back to the curve's approximation so the polyline stays finite; an interior probe + // is NaN, which counts as flat. + let end = |t: f64| curve.try_subs(t).unwrap_or_else(|| curve.approx_subs(t)); + let probe = |t: f64| curve.try_subs(t).unwrap_or(Point3::new(f64::NAN, f64::NAN, f64::NAN)); + let mut out = vec![end(range.0)]; let mut stack: Vec<(f64, f64, usize)> = vec![(range.0, range.1, 0)]; while let Some((a, b, depth)) = stack.pop() { - let (pa, pb) = (curve.subs(a), curve.subs(b)); + let (pa, pb) = (end(a), end(b)); let t = a + (b - a) * 0.4729; let chord = pa + (pb - pa) * 0.4729; - let d2 = curve.subs(t).distance2(chord); + let d2 = probe(t).distance2(chord); let tm = 0.5 * (a + b); - let dm = curve.subs(tm).distance2(pa.midpoint(pb)); + let dm = probe(tm).distance2(pa.midpoint(pb)); let flat = !(d2 >= tol * tol || dm >= tol * tol); let flat = flat && !d.turns(turn2, d2.max(dm), pa.distance2(pb)); if flat || depth >= 24 || out.len() + stack.len() >= MAX_EDGE_POINTS { diff --git a/vendor/truck-shapeops/src/transversal/integrate/mod.rs b/vendor/truck-shapeops/src/transversal/integrate/mod.rs index d5252a47..dd45a8a4 100644 --- a/vendor/truck-shapeops/src/transversal/integrate/mod.rs +++ b/vendor/truck-shapeops/src/transversal/integrate/mod.rs @@ -27,6 +27,7 @@ impl ShapeOpsSurface for S where S: ParametricSurface3D /// Only solids consisting of edges whose curve is implemented this trait can be used for set operations. pub trait ShapeOpsCurve: ParametricCurve3D + + TrySubs + ParameterDivision1D + Cut + Invertible @@ -37,6 +38,7 @@ pub trait ShapeOpsCurve: + Sync { } impl ShapeOpsCurve for C where C: ParametricCurve3D + + TrySubs + ParameterDivision1D + Cut + Invertible diff --git a/vendor/truck-shapeops/src/transversal/loops_store/mod.rs b/vendor/truck-shapeops/src/transversal/loops_store/mod.rs index a6d4decd..3d3ef375 100644 --- a/vendor/truck-shapeops/src/transversal/loops_store/mod.rs +++ b/vendor/truck-shapeops/src/transversal/loops_store/mod.rs @@ -158,7 +158,7 @@ impl ParameterKind { } } -impl Loops { +impl Loops { fn search_parameter(&self, pt: P) -> Option<(usize, usize, ParameterKind)> where C: BoundedCurve + SearchParameter { self.iter() @@ -167,7 +167,18 @@ impl Loops { .find_map(|(i, j, edge)| { let curve = edge.curve(); curve.search_parameter(pt, None, 1).and_then(|t| { - let kind = ParameterKind::try_new(t, curve.range_tuple())?; + // SolidCraft: a point at an end of the curve is that end, however far its + // parameter is from the end's. The parameter alone is no distance: on a + // polyline it counts segments, and an intersection curve ending 2e-7 mm from + // another's end (two walls of a glyph meeting a face) read 1.4e-6 before it, + // so it was cut into a sliver and the result's shell never closed (#391). + let kind = if pt.near(&curve.front()) { + ParameterKind::Front + } else if pt.near(&curve.back()) { + ParameterKind::Back + } else { + ParameterKind::try_new(t, curve.range_tuple())? + }; Some((i, j, kind)) }) }) diff --git a/xtask/src/web_offline_test.rs b/xtask/src/web_offline_test.rs index 8b77b5fa..817d9753 100644 --- a/xtask/src/web_offline_test.rs +++ b/xtask/src/web_offline_test.rs @@ -48,6 +48,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -247,9 +248,15 @@ pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String // v5's worker installs, its files coming slowly; meanwhile v6 is deployed and launched. let update = "navigator.serviceWorker.getRegistration().then((r) => { r.update(); return true; })"; let name5 = kept.get(4).map(|(v, _)| cache_name(PREFIX, v)).unwrap_or_default(); + let asked = server.slow.load(Ordering::SeqCst); server.set(Mode::SlowFiles(v5)); cdp.eval(update)?; wait_for(&mut cdp, &state_js, "v5's worker starts downloading", |s| s["caches"].get(&name5).is_some())?; + // Chrome sends a server at most six requests at once: a build with more files has the rest + // wait for the first ones. v6 is deployed once all of v5's are asked for (any asked later + // would get v6's file, which rightly ends v5's download, but that isn't this step). + let files5 = kept.get(4).map_or(0, |(_, n)| *n); + server.wait_asked_slowly(asked + files5)?; server.set(Mode::Serve(v6)); let t = launch(&mut cdp, "v6")?; wait_for(&mut cdp, &state_js, "v5's download is finished, not dropped", |s| s["complete"].as_array().is_some_and(|c| c.contains(&json!(name5))))?; @@ -439,6 +446,8 @@ enum Mode { struct Server { port: u16, mode: Arc>, + /// How many files were asked for in Mode::SlowFiles. + slow: Arc, } impl Server { @@ -447,14 +456,26 @@ impl Server { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("server: {e}"))?; let port = listener.local_addr().map_err(|e| format!("server: {e}"))?.port(); let mode = Arc::new(Mutex::new(Mode::Refuse)); - let shared = mode.clone(); + let slow = Arc::new(AtomicUsize::new(0)); + let (shared, asked) = (mode.clone(), slow.clone()); std::thread::spawn(move || { for stream in listener.incoming().flatten() { - let (shared, elsewhere) = (shared.clone(), elsewhere.clone()); - std::thread::spawn(move || answer(stream, &shared, &elsewhere)); + let (shared, asked, elsewhere) = (shared.clone(), asked.clone(), elsewhere.clone()); + std::thread::spawn(move || answer(stream, &shared, &asked, &elsewhere)); } }); - Ok(Server { port, mode }) + Ok(Server { port, mode, slow }) + } + + /// Wait (at most 30 s) until `n` files in all were asked for in Mode::SlowFiles. + fn wait_asked_slowly(&self, n: usize) -> Result<(), String> { + for _ in 0..300 { + if self.slow.load(Ordering::SeqCst) >= n { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(format!("only {} of {n} slow files were asked for", self.slow.load(Ordering::SeqCst))) } fn set(&self, mode: Mode) { @@ -474,7 +495,7 @@ fn hold(shared: &Mutex, still: impl Fn(&Mode) -> bool) { } } -fn answer(mut stream: TcpStream, shared: &Mutex, elsewhere: &Path) { +fn answer(mut stream: TcpStream, shared: &Mutex, slow: &AtomicUsize, elsewhere: &Path) { let Ok(read) = stream.try_clone() else { return }; let mut reader = BufReader::new(read); let mut request = String::new(); @@ -506,6 +527,7 @@ fn answer(mut stream: TcpStream, shared: &Mutex, elsewhere: &Path) { Mode::SlowCheck(dir) => (dir, false, true), Mode::SlowFiles(dir) => { if !small { + slow.fetch_add(1, Ordering::SeqCst); std::thread::sleep(SLOW_FILE); } (dir, false, false)