From 8a811f8eff2f5f20b8ea7a7d243fcd6e8a5d2eb6 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 17:54:58 +0000 Subject: [PATCH] M0: desktop app: toolbar, browser, timeline, GPU viewport, view cube, sketch tools, dialogs, control channel Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3394 ++++++++++++++++++++++++- apps/solvecraft/Cargo.toml | 15 + apps/solvecraft/src/control_server.rs | 62 + apps/solvecraft/src/main.rs | 110 +- crates/doc/src/eval.rs | 2 +- crates/engine/src/cmd/sketch.rs | 4 +- crates/engine/src/lib.rs | 1 + crates/engine/src/sample.rs | 33 + crates/engine/src/tests.rs | 13 + crates/engine/src/view.rs | 5 +- crates/geom/src/mesh.rs | 3 + crates/geom/src/tests.rs | 2 +- crates/kernel/src/body.rs | 7 + crates/kernel/src/lib.rs | 2 +- crates/kernel/src/topo.rs | 48 + crates/render/src/raster.rs | 1 + crates/sketch/src/model.rs | 19 +- crates/ui-egui/Cargo.toml | 10 + crates/ui-egui/src/browser.rs | 116 + crates/ui-egui/src/control.rs | 210 ++ crates/ui-egui/src/dialogs.rs | 454 ++++ crates/ui-egui/src/gpu.rs | 329 +++ crates/ui-egui/src/icons.rs | 365 +++ crates/ui-egui/src/lib.rs | 321 +++ crates/ui-egui/src/palette.rs | 133 + crates/ui-egui/src/theme.rs | 70 + crates/ui-egui/src/timeline.rs | 129 + crates/ui-egui/src/toolbar.rs | 374 +++ crates/ui-egui/src/tools.rs | 281 ++ crates/ui-egui/src/viewport.rs | 821 ++++++ xtask/src/assets.rs | 3 +- xtask/src/main.rs | 3 +- xtask/src/parity.rs | 13 +- 33 files changed, 7321 insertions(+), 32 deletions(-) create mode 100644 apps/solvecraft/src/control_server.rs create mode 100644 crates/engine/src/sample.rs create mode 100644 crates/ui-egui/src/browser.rs create mode 100644 crates/ui-egui/src/control.rs create mode 100644 crates/ui-egui/src/dialogs.rs create mode 100644 crates/ui-egui/src/gpu.rs create mode 100644 crates/ui-egui/src/icons.rs create mode 100644 crates/ui-egui/src/palette.rs create mode 100644 crates/ui-egui/src/theme.rs create mode 100644 crates/ui-egui/src/timeline.rs create mode 100644 crates/ui-egui/src/toolbar.rs create mode 100644 crates/ui-egui/src/tools.rs create mode 100644 crates/ui-egui/src/viewport.rs diff --git a/Cargo.lock b/Cargo.lock index 5fea573f..cec49f72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,169 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -32,12 +183,227 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "array-macro" version = "2.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "220a2c618ab466efe41d0eace94dfeff1c35e3aa47891bdb95e1c0fefffd3c99" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -50,12 +416,64 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "branches" version = "0.3.0" @@ -103,6 +521,62 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.6.0" @@ -110,6 +584,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -119,6 +595,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "cgmath" version = "0.18.0" @@ -143,18 +634,100 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr 2.8.3", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -189,6 +762,18 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "derive_more" version = "0.99.20" @@ -202,18 +787,310 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools 0.15.0", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -223,6 +1100,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -246,12 +1129,78 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -265,11 +1214,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -283,6 +1243,181 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -294,6 +1429,27 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -329,6 +1485,17 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", ] [[package]] @@ -340,12 +1507,89 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -368,12 +1612,93 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "libc" version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -432,6 +1757,24 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -462,6 +1805,80 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "3.2.1" @@ -498,6 +1915,340 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -506,6 +2257,41 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -524,36 +2310,185 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -563,6 +2498,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -596,12 +2546,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.22.0" @@ -612,6 +2574,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr 2.8.3", +] + [[package]] name = "quote" version = "1.0.47" @@ -621,6 +2592,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.8" @@ -648,7 +2631,31 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -680,13 +2687,75 @@ dependencies = [ "branches", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", ] [[package]] @@ -695,6 +2764,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -710,18 +2785,65 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -771,33 +2893,176 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "solvecraft" version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "log", + "rfd", + "serde_json", + "solvecraft-engine", + "solvecraft-ui-egui", +] [[package]] name = "solvecraft-cli" @@ -891,6 +3156,15 @@ dependencies = [ [[package]] name = "solvecraft-ui-egui" version = "0.1.0" +dependencies = [ + "egui", + "egui-wgpu", + "image", + "log", + "serde", + "serde_json", + "solvecraft-engine", +] [[package]] name = "spade" @@ -898,12 +3172,27 @@ version = "2.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "num-traits", "robust", "smallvec", ] +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "syn" version = "1.0.109" @@ -937,6 +3226,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -977,6 +3288,91 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "truck-base" version = "0.5.0" @@ -985,7 +3381,7 @@ checksum = "4c279de9e92e5dc20a188deb0bb9a4bd421a6a185e57e03e19025e84a36c5a05" dependencies = [ "cgmath", "matext4cgmath", - "rustc-hash", + "rustc-hash 2.1.3", "serde", ] @@ -1019,7 +3415,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a31fe98d5a0589ad20576260e214af23dea21a8d125da81ad3bbcb666f411f" dependencies = [ - "getrandom", + "getrandom 0.2.17", "rand", "thiserror 1.0.69", "truck-base", @@ -1034,9 +3430,9 @@ checksum = "e18f325738e822c79bb1a48897ec789cbf50cbeeb0841105295f9da85bdf3613" dependencies = [ "array-macro", "derive_more", - "itertools", + "itertools 0.13.0", "rayon", - "rustc-hash", + "rustc-hash 2.1.3", "spade", "truck-base", "truck-geometry", @@ -1052,7 +3448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8082c2dfac8c2732a014d81807fb5d24dbac0d72ee4de2a705b7fbc05fa05b18" dependencies = [ "derive_more", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "thiserror 1.0.69", "truck-base", @@ -1070,8 +3466,8 @@ checksum = "b8f22def310af4a89c37beb4839bbf702b302d2c112ea29553759f0e16622fbf" dependencies = [ "array-macro", "bytemuck", - "itertools", - "rustc-hash", + "itertools 0.13.0", + "rustc-hash 2.1.3", "serde", "thiserror 1.0.69", "truck-base", @@ -1085,8 +3481,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6e83ea4d36d00229d38ab985a991e32f58c3ed2971083011ac66fb57e26d5c" dependencies = [ "derive_more", - "itertools", - "rustc-hash", + "itertools 0.13.0", + "rustc-hash 2.1.3", "truck-base", "truck-geometry", "truck-geotrait", @@ -1116,19 +3512,95 @@ dependencies = [ "parking_lot", "rayon", "rclite", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "thiserror 1.0.69", "truck-base", "truck-geotrait", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1149,17 +3621,36 @@ dependencies = [ "nom", "num-derive", "num-traits", - "quick-xml", + "quick-xml 0.22.0", "serde", "xz2", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -1173,6 +3664,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -1205,6 +3707,380 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1218,6 +4094,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -1246,6 +4133,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1264,6 +4161,309 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr 2.8.3", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "xtask" version = "0.1.0" @@ -1280,6 +4480,112 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.61" @@ -1311,3 +4617,59 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/apps/solvecraft/Cargo.toml b/apps/solvecraft/Cargo.toml index 5f08b89d..49895c78 100644 --- a/apps/solvecraft/Cargo.toml +++ b/apps/solvecraft/Cargo.toml @@ -1,5 +1,20 @@ [package] name = "solvecraft" +description = "SolveCraft desktop app" version.workspace = true edition.workspace = true license.workspace = true +publish = false +default-run = "solvecraft" + +[lints] +workspace = true + +[dependencies] +solvecraft-engine = { workspace = true } +solvecraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +rfd = { workspace = true } diff --git a/apps/solvecraft/src/control_server.rs b/apps/solvecraft/src/control_server.rs new file mode 100644 index 00000000..530e19a2 --- /dev/null +++ b/apps/solvecraft/src/control_server.rs @@ -0,0 +1,62 @@ +//! Loopback JSON-lines control server: one request per line, one reply per line. +//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0). + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use serde_json::{Value, json}; +use solvecraft_ui_egui::ControlRequest; + +pub fn start(port: u16, ctx: egui::Context) -> Receiver { + let (tx, rx) = channel::(); + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}"); + return rx; + } + }; + eprintln!("solvecraft: control server listening on 127.0.0.1:{port}"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || serve(stream, tx, ctx)); + } + }); + rx +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + for line in BufReader::new(read).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => { + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rrx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index f328e4d9..c0ab95ce 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -1 +1,109 @@ -fn main() {} +//! SolveCraft desktop app. +//! +//! Usage: `solvecraft [--control ] [--sample] [design.solvecraft]` +//! +//! `--control ` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control +//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`. +//! See `solvecraft_ui_egui::control` and docs/control-protocol.md. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod control_server; + +use solvecraft_engine::Session; +use solvecraft_ui_egui::{Services, SolveApp}; + +struct App(SolveApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + if self.0.quit_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter("SolveCraft design", &["solvecraft"]) + .add_filter("All files", &["*"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })), + pick_save: Some(Box::new(|name: &str, exts: &[&str]| { + rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string()) + })), + } +} + +const DEPTH_BITS: u8 = 24; +const MSAA: u16 = 4; + +fn main() -> eframe::Result { + let mut control_port: Option = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut files = Vec::new(); + let mut sample = false; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--sample" => sample = true, + "--version" | "-V" => { + println!("solvecraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + "--help" | "-h" => { + println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft]"); + return Ok(()); + } + _ => files.push(a), + } + } + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("SolveCraft") + .with_inner_size([1600.0, 1000.0]) + .with_min_inner_size([960.0, 600.0]) + .with_drag_and_drop(true) + .with_app_id("ai.storyteller.solvecraft"), + depth_buffer: DEPTH_BITS, + multisampling: MSAA, + ..Default::default() + }; + eframe::run_native( + "SolveCraft", + options, + Box::new(move |cc| { + let mut app = SolveApp::new(Session::default(), services()); + if let Some(rs) = &cc.wgpu_render_state { + app.set_wgpu(rs, DEPTH_BITS, u32::from(MSAA)); + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + if sample { + if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) { + app.set_status(format!("sample: {e}"), true); + } + app.session.doc_mut().name = "Sample Plate".into(); + app.session.mark_saved(); + app.session.undo.clear(); + } + for f in &files { + app.open_path(f); + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b26a8aad..0b27070b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -31,7 +31,7 @@ impl ModelBody { self.mesh .get_or_init(|| { let tol = (self.body.size() * 1e-3).max(1e-3); - Arc::new(self.body.tessellate(tol).unwrap_or_default()) + Arc::new(self.body.display_mesh(tol).unwrap_or_default()) }) .clone() } diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 9271441b..a903dab7 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -303,7 +303,9 @@ fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { && (fp - f.centroid).dot(n).abs() < tol * 10.0 && b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false) { - found = Plane::from_normal(f.centroid + n * (fp - f.centroid).dot(n), n); + // Sketch origin: the model origin projected onto the face plane. + let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n); + found = Plane::from_normal(n * d, n); } } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9540b1a5..6fd51184 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod cmd; pub mod params; +pub mod sample; pub mod view; use std::sync::Arc; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs new file mode 100644 index 00000000..ca3a6f42 --- /dev/null +++ b/crates/engine/src/sample.rs @@ -0,0 +1,33 @@ +//! A sample design (used by `solvecraft --sample` and demos): a parametric plate with rounded +//! corners, two holes and a boss. + +use serde_json::{Value, json}; + +pub fn script() -> Value { + json!({"commands": [ + {"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "80 mm", "comment": "plate width"}}, + {"command": "ChangeParameterCommand", "params": {"name": "depth", "expression": "50 mm"}}, + {"command": "ChangeParameterCommand", "params": {"name": "thickness", "expression": "8 mm"}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Base"}}, + {"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [80, 50]}}, + {"command": "ConstraintCoincident", "params": {"a": "p1", "b": "origin"}}, + {"command": "SketchDimension", "params": {"entities": ["l1"], "value": "width"}}, + {"command": "SketchDimension", "params": {"entities": ["l2"], "value": "depth"}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": "thickness", "body_name": "Plate"}}, + {"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 4], [80, 0, 4], [80, 50, 4], [0, 50, 4]], "radius": "6 mm"}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Holes"}}, + {"command": "CircleCenterRadius", "params": {"center": [12, 25], "diameter": 8}}, + {"command": "CircleCenterRadius", "params": {"center": [68, 25], "diameter": 8}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}}, + {"command": "SketchCreate", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}}, + {"command": "CircleCenterRadius", "params": {"center": [40, 25], "diameter": 24}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}}, + {"command": "SketchCreate", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}}, + {"command": "CircleCenterRadius", "params": {"center": [40, 25], "diameter": 12}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}}, + ]}) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b2f1763e..591d0225 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -299,3 +299,16 @@ fn registry_is_consistent() { let s = Session::default(); assert!(!find_command("DrawPolyline").unwrap().info(&s).enabled); } + +#[test] +fn sample_design_builds() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let m = run(&mut s, "MeasureCommand", json!({})); + assert_eq!(m["body_count"], 1, "{m}"); + let plate = 80.0 * 50.0 * 8.0 - 4.0 * (36.0 - PI * 9.0) * 8.0 - 2.0 * PI * 16.0 * 8.0; + let boss = PI * 144.0 * 14.0; + let bore = PI * 36.0 * 22.0; + let v = m["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 2a7ffbec..e3b728bb 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -106,7 +106,10 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); let mesh = body.mesh(); sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } }); - for e in &mesh.edges { + for (i, e) in mesh.edges.iter().enumerate() { + if mesh.seams.get(i).copied().unwrap_or(false) { + continue; + } sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false }); } } diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 3e36b6d7..76ef9b3a 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -61,6 +61,9 @@ pub struct Mesh { pub tri_face: Vec, /// B-rep edges as polylines. pub edges: Vec>, + /// Per edge: a seam between two pieces of the same smooth surface (not drawn). + #[serde(default)] + pub seams: Vec, } /// Mass properties of a closed mesh. diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 8fcf4c75..ccb050e7 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -6,7 +6,7 @@ fn cube_mesh(s: f64) -> Mesh { let triangles = vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; let n = triangles.len(); - Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![] } + Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![] } } #[test] diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 56c839e7..3f5084d6 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -153,6 +153,13 @@ impl Body { }) } + /// Display mesh: like [`Body::tessellate`], with seam edges flagged. + pub fn display_mesh(&self, tol: f64) -> Result { + let mut m = self.tessellate(tol)?; + m.seams = crate::topo::seam_flags(self, &m); + Ok(m) + } + /// Edges with polylines (index = position in [`Body::tessellate`]'s `edges`). pub fn edges(&self, tol: f64) -> Result> { let m = self.tessellate(tol)?; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 58bd0f13..17219801 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -26,7 +26,7 @@ pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus}; pub use measure::{BodyMeasure, measure}; pub use ops::{BoolOp, boolean, transform}; pub use step::step_export; -pub use topo::{TopoCounts, merged_topology}; +pub use topo::{TopoCounts, merged_topology, seam_flags}; #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum KernelError { diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index b7ab6358..a0c58cb5 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -138,6 +138,54 @@ fn find(p: &mut [usize], mut i: usize) -> usize { i } +/// Per edge (in `Body::edges` order): is it a seam between two pieces of the same surface? +pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec { + let solid = &*b.solid; + let nf = solid.face_iter().count(); + let tol = (b.size() * 1e-4).max(1e-6); + let surfs = classify_faces(mesh, nf, tol); + let mut faces_of: HashMap> = HashMap::new(); + let mut order: Vec = Vec::new(); + for (fi, f) in solid.face_iter().enumerate() { + for e in f.edge_iter() { + let k = format!("{:?}", e.id()); + let v = faces_of.entry(k.clone()).or_insert_with(|| { + order.push(k); + Vec::new() + }); + if !v.contains(&fi) { + v.push(fi); + } + } + } + order + .iter() + .map(|k| match faces_of.get(k).map(Vec::as_slice) { + Some([a, b2]) => match (surfs.get(*a), surfs.get(*b2)) { + (Some(sa), Some(sb)) => !matches!(sa, Surf::Plane { .. }) && sa.same(sb, tol * 10.0), + _ => false, + }, + _ => false, + }) + .collect() +} + +fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec { + let mut pts: Vec> = vec![Vec::new(); nf]; + let mut nrm: Vec> = vec![Vec::new(); nf]; + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + for &k in t { + if let (Some(p), Some(n), Some(pv), Some(nv)) = + (mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize)) + { + pv.push(*p); + nv.push(*n); + } + } + } + (0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)).collect() +} + /// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`. pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { let solid = &*b.solid; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index fd7cf24c..598d6289 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -219,6 +219,7 @@ mod tests { triangles: vec![[0, 1, 2]], tri_face: vec![0], edges: vec![], + seams: vec![], }; let scene = Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 }; diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 4774243b..7bcea255 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -295,8 +295,9 @@ pub struct Sketch { pub points: Vec, pub curves: Vec, pub constraints: Vec, + /// Next number per id prefix (`l` → l1, l2…). #[serde(default)] - next_id: u64, + counters: std::collections::BTreeMap, } impl Default for Sketch { @@ -307,14 +308,22 @@ impl Default for Sketch { impl Sketch { pub fn new() -> Self { - Sketch { points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }], curves: Vec::new(), constraints: Vec::new(), next_id: 1 } + Sketch { + points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }], + curves: Vec::new(), + constraints: Vec::new(), + counters: Default::default(), + } } fn fresh(&mut self, prefix: &str) -> String { + let n = self.counters.entry(prefix.to_string()).or_insert(0); loop { - let id = format!("{prefix}{}", self.next_id); - self.next_id += 1; - if !self.id_taken(&id) { + *n += 1; + let id = format!("{prefix}{n}"); + let taken = + self.points.iter().any(|p| p.id == id) || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id); + if !taken { return id; } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index cc50ca19..02d70d7d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -1,8 +1,18 @@ [package] name = "solvecraft-ui-egui" +description = "SolveCraft user interface (egui + wgpu): the swappable front end" version.workspace = true edition.workspace = true license.workspace = true [lints] workspace = true + +[dependencies] +solvecraft-engine = { workspace = true } +egui = { workspace = true } +egui-wgpu = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +image = { workspace = true } diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs new file mode 100644 index 00000000..6acbabe9 --- /dev/null +++ b/crates/ui-egui/src/browser.rs @@ -0,0 +1,116 @@ +//! The browser (left): document settings, origin, bodies and sketches. + +use egui::{Color32, RichText, Sense, vec2}; +use serde_json::json; +use solvecraft_engine::Sel; +use solvecraft_engine::doc::FeatureKind; + +use crate::theme::Tokens; +use crate::{SolveApp, icons}; + +fn row(ui: &mut egui::Ui, icon: &str, label: &str, color: Option, selected: bool) -> egui::Response { + let t = Tokens::get(); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click()); + if selected { + ui.painter().rect_filled(r, 2.0, t.accent_soft); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let ir = egui::Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)); + icons::paint(ui.painter(), ir, icon, t.icon, t.icon_fill, t.accent); + ui.painter().text(r.min + vec2(22.0, 10.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), color.unwrap_or(t.text)); + resp +} + +fn eye(ui: &mut egui::Ui, visible: bool) -> bool { + let t = Tokens::get(); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + icons::paint(ui.painter(), r, "eye", if visible { t.icon } else { t.border }, t.icon_fill, t.accent); + resp.on_hover_text(if visible { "Hide" } else { "Show" }).clicked() +} + +pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Panel::left("sc_browser") + .exact_size(230.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6))) + .show(ui, |ui| { + ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); + ui.add_space(4.0); + let st = app.session.model.state(); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), r, "box", t.icon, t.icon_fill, t.accent); + ui.label(RichText::new(&app.session.doc.name).strong()); + }); + ui.indent("doc", |ui| { + egui::CollapsingHeader::new("Document Settings").default_open(false).show(ui, |ui| { + row(ui, "settings", &format!("Units: {}", app.session.doc.units), None, false); + let n = app.session.doc.params.len(); + if row(ui, "params", &format!("Parameters ({n})"), None, false).clicked() { + app.start("ChangeParameterCommand"); + } + }); + egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| { + ui.horizontal(|ui| { + if eye(ui, app.ui.show_origin) { + app.ui.show_origin = !app.ui.show_origin; + } + ui.label("O, X, Y, Z"); + }); + for p in ["XY", "XZ", "YZ"] { + row(ui, "plane", p, None, false); + } + }); + egui::CollapsingHeader::new(format!("Bodies ({})", st.bodies.len())).default_open(true).show(ui, |ui| { + for b in &st.bodies { + let visible = !app.ui.hidden_bodies.contains(&b.name); + let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)); + ui.horizontal(|ui| { + if eye(ui, visible) { + if visible { + app.ui.hidden_bodies.push(b.name.clone()); + } else { + app.ui.hidden_bodies.retain(|n| n != &b.name); + } + } + if row(ui, "body", &b.name, None, selected).clicked() { + let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b.name}]})); + } + }); + } + }); + let sketches: Vec<(u64, String)> = app + .session + .doc + .features + .iter() + .filter(|f| matches!(f.kind, FeatureKind::Sketch { .. })) + .map(|f| (f.id, f.name.clone())) + .collect(); + egui::CollapsingHeader::new(format!("Sketches ({})", sketches.len())).default_open(true).show(ui, |ui| { + ui.horizontal(|ui| { + if eye(ui, app.ui.show_sketches) { + app.ui.show_sketches = !app.ui.show_sketches; + } + ui.label(RichText::new("show unused sketches").size(11.0).color(t.text_dim)); + }); + for (id, name) in sketches { + let active = app.session.active_sketch == Some(id); + let r = if active { + row(ui, "sketch", &format!("{name} (editing)"), Some(t.sketch_accent), true) + } else { + row(ui, "sketch", &name, None, false) + }; + if r.double_clicked() { + let _ = app.run("SketchActivate", json!({"sketch": id})); + } + r.on_hover_text("Double-click to edit"); + } + }); + }); + }); + }); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 00000000..3d4e0d4c --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,210 @@ +//! Programmatic control of the running app (agents, tests). +//! +//! Methods (JSON lines over the host's transport, `{"id", "method", "params"}`): +//! - `engine.execute {command, params}`: run any command with JSON parameters (no dialogs) +//! - `engine.script {commands: [...]}`: run a command script +//! - `engine.commands`: every command with tab, panel, params and enablement +//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches) +//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog +//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit}` +//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs) +//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real +//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}` +//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless +//! CPU render of the model with the current camera (no window needed) +//! - `ui.resize {width, height}`, `app.quit` + +use std::sync::mpsc::Sender; + +use serde_json::{Value, json}; +use solvecraft_engine::render::{StandardView, render_png}; + +use crate::SolveApp; + +pub type ControlResponse = Value; + +pub struct ControlRequest { + pub method: String, + pub params: Value, + pub reply: Sender, +} + +impl ControlRequest { + pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + (Self { method: method.into(), params, reply: tx }, rx) + } +} + +pub enum Outcome { + Done(Value), + Screenshot { path: Option }, +} + +fn ok(v: Value) -> Outcome { + Outcome::Done(json!({"ok": true, "result": v})) +} +fn err(e: impl std::fmt::Display) -> Outcome { + Outcome::Done(json!({"ok": false, "error": e.to_string()})) +} +fn wrap(r: Result) -> Outcome { + match r { + Ok(v) => ok(v), + Err(e) => err(e), + } +} + +fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => Some(egui::Key::Enter), + "esc" | "escape" => Some(egui::Key::Escape), + "delete" => Some(egui::Key::Delete), + "space" => Some(egui::Key::Space), + "tab" => Some(egui::Key::Tab), + _ => None, + }) +} + +pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()); + let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + match req.method.as_str() { + "engine.execute" | "command" => { + let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + let r = app.run(id, params); + if r.is_ok() && app.session.active_sketch.is_none() && app.session.model.state().bodies.len() == 1 && app.session.undo.len() <= 2 { + app.fit_view(); + } + wrap(r) + } + "engine.script" => wrap(app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string())), + "engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())), + "document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())), + "ui.inspect" => ok(json!({ + "ui": serde_json::to_value(&app.ui).unwrap_or_default(), + "viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])), + "camera": app.cam, + "tool": app.tool.as_ref().map(|t| t.cmd), + "dialog": app.dialog.as_ref().map(|d| format!("{d:?}")), + "hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")), + "renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" }, + "frame_ms": app.frame_ms, + "build_ms": app.viewport.build_ms, + })), + "ui.set" => { + let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({})); + if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) { + for (k, v) in src { + o.insert(k.clone(), v.clone()); + } + } + match serde_json::from_value::(cur) { + Ok(u) => { + app.ui = u; + ok(serde_json::to_value(&app.ui).unwrap_or_default()) + } + Err(e) => err(e), + } + } + "ui.view" => { + match s("view").unwrap_or("home") { + "fit" => app.fit_view(), + "home" => { + app.cam.set_view(StandardView::Iso); + app.fit_view(); + } + v => match StandardView::parse(v) { + Some(sv) => app.cam.set_view(sv), + None => return err(format!("unknown view `{v}`")), + }, + } + ok(json!(app.cam)) + } + "ui.start" => { + let Some(id) = s("command") else { return err("missing `command`") }; + app.start(id); + ok(json!({"tool": app.tool.as_ref().map(|t| t.cmd), "dialog": app.dialog.as_ref().map(|d| format!("{d:?}"))})) + } + "ui.move" | "ui.click" => { + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + let pos = egui::pos2(x as f32, y as f32); + app.synthetic.push(egui::Event::PointerMoved(pos)); + if req.method == "ui.click" { + let button = match s("button") { + Some("right") => egui::PointerButton::Secondary, + Some("middle") => egui::PointerButton::Middle, + _ => egui::PointerButton::Primary, + }; + let modifiers = egui::Modifiers { shift: b("shift"), ..Default::default() }; + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers }); + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers }); + } + ok(Value::Null) + } + "ui.scroll" => { + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32))); + app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, f("delta").unwrap_or(-100.0) as f32), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + ok(Value::Null) + } + "ui.key" => { + let Some(key) = s("key").and_then(key_from) else { return err("unknown key") }; + let modifiers = egui::Modifiers { shift: b("shift"), command: b("cmd"), ctrl: b("cmd") || b("ctrl"), ..Default::default() }; + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + ok(Value::Null) + } + "ui.text" => { + app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); + ok(Value::Null) + } + "ui.resize" => { + let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32))); + ok(Value::Null) + } + "ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) }, + "ui.render" => { + let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize; + let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize; + let scene = solvecraft_engine::view::scene(&app.session, &app.cam); + let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") }; + match s("path") { + Some(path) => match std::fs::write(path, &png) { + Ok(()) => ok(json!({"path": path, "bytes": png.len()})), + Err(e) => err(e), + }, + None => ok(json!({"bytes": png.len()})), + } + } + "app.quit" => { + app.quit_requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ok(Value::Null) + } + other => err(format!("unknown method `{other}`")), + } +} + +/// Save a screenshot PNG. +pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { + let [w, h] = image.size; + let mut buf = Vec::with_capacity(w * h * 4); + for c in &image.pixels { + buf.extend_from_slice(&c.to_array()); + } + let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) }; + let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("solvecraft-shot.png").to_string_lossy().to_string()); + match img.save(&path) { + Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 00000000..c853e7ad --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,454 @@ +//! Command dialogs (shown at the right of the viewport). A dialog collects inputs and picks, +//! then runs its command; it never changes the design itself. + +use std::hash::{Hash, Hasher}; + +use egui::{Color32, RichText, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::Session; +use solvecraft_engine::doc::FeatureKind; +use solvecraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::viewport::Hit; + +const OPS: [&str; 4] = ["new", "join", "cut", "intersect"]; +const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"]; +const DIRS: [&str; 3] = ["positive", "negative", "symmetric"]; +const DIR_LABELS: [&str; 3] = ["One side", "Flip", "Symmetric"]; + +#[derive(Clone, Debug)] +pub enum Dialog { + Sketch { pick_face: bool, picked: Option }, + Extrude { profiles: Vec<(u64, usize)>, distance: String, direction: usize, operation: usize }, + Revolve { profiles: Vec<(u64, usize)>, axis: String, angle: String, operation: usize }, + Fillet { edges: Vec, radius: String, chamfer: bool }, + Primitive { cmd: &'static str, fields: Vec<(&'static str, String)>, operation: usize }, + Combine { target: String, tools: Vec, operation: usize }, + Params { new_name: String, new_expr: String }, + EditParam { name: String, expr: String, error: Option }, +} + +/// Profiles of the sketch a feature would use (active, else the last), when there is just one. +fn default_profiles(s: &Session) -> Vec<(u64, usize)> { + let st = s.model.state(); + let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id)); + match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) { + Some((id, 1)) => vec![(id, 0)], + _ => Vec::new(), + } +} + +impl Dialog { + pub fn for_command(app: &SolveApp, id: &str) -> Option { + let s = &app.session; + Some(match id { + "SketchCreate" => Dialog::Sketch { pick_face: false, picked: None }, + "Extrude" => Dialog::Extrude { + profiles: default_profiles(s), + distance: "10 mm".into(), + direction: 0, + operation: if s.model.state().bodies.is_empty() { 0 } else { 1 }, + }, + "Revolve" => Dialog::Revolve { profiles: default_profiles(s), axis: "y".into(), angle: "360 deg".into(), operation: 0 }, + "FusionFilletEdgesCommand" => Dialog::Fillet { edges: selected_edges(s), radius: "2 mm".into(), chamfer: false }, + "FusionChamferCommand" => Dialog::Fillet { edges: selected_edges(s), radius: "1 mm".into(), chamfer: true }, + "PrimitiveBox" => Dialog::Primitive { + cmd: "PrimitiveBox", + fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())], + operation: 0, + }, + "PrimitiveCylinder" => { + Dialog::Primitive { cmd: "PrimitiveCylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 } + } + "PrimitiveSphere" => Dialog::Primitive { cmd: "PrimitiveSphere", fields: vec![("diameter", "20".into())], operation: 0 }, + "PrimitiveTorus" => { + Dialog::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 } + } + "FusionCombineCommand" => { + let bodies: Vec = s.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + Dialog::Combine { target: bodies.first().cloned().unwrap_or_default(), tools: bodies.iter().skip(1).cloned().collect(), operation: 1 } + } + "ChangeParameterCommand" => Dialog::Params { new_name: String::new(), new_expr: String::new() }, + _ => return None, + }) + } + + pub fn edit_param(s: &Session, name: &str) -> Dialog { + Dialog::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default(), error: None } + } + + pub fn wants_picks(&self) -> bool { + matches!(self, Dialog::Extrude { .. } | Dialog::Revolve { .. } | Dialog::Fillet { .. } | Dialog::Sketch { pick_face: true, .. }) + } + + pub fn profiles(&self) -> Vec<(u64, usize)> { + match self { + Dialog::Extrude { profiles, .. } | Dialog::Revolve { profiles, .. } => profiles.clone(), + _ => Vec::new(), + } + } + + pub fn edges(&self) -> Vec { + match self { + Dialog::Fillet { edges, .. } => edges.clone(), + _ => Vec::new(), + } + } + + pub fn highlight_key(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.profiles().hash(&mut h); + for e in self.edges() { + for v in [e.x, e.y, e.z] { + v.to_bits().hash(&mut h); + } + } + h.finish() + } + + pub fn on_pick(&mut self, hits: &[Hit]) { + match self { + Dialog::Extrude { profiles, .. } | Dialog::Revolve { profiles, .. } => { + if let Some(Hit::Profile { sketch, index }) = hits.iter().find(|h| matches!(h, Hit::Profile { .. })) { + let k = (*sketch, *index); + if let Some(i) = profiles.iter().position(|p| *p == k) { + profiles.remove(i); + } else { + // Profiles from one sketch only. + profiles.retain(|p| p.0 == k.0); + profiles.push(k); + } + } + } + Dialog::Fillet { edges, .. } => { + if let Some(Hit::Edge { mid, .. }) = hits.iter().find(|h| matches!(h, Hit::Edge { .. })) { + if let Some(i) = edges.iter().position(|e| e.dist(*mid) < 1e-6) { + edges.remove(i); + } else { + edges.push(*mid); + } + } + } + Dialog::Sketch { picked, .. } => { + if let Some(Hit::Face { point, .. }) = hits.iter().find(|h| matches!(h, Hit::Face { .. })) { + *picked = Some(*point); + } + } + _ => {} + } + } +} + +fn selected_edges(s: &Session) -> Vec { + s.selection.iter().filter_map(|x| if let solvecraft_engine::Sel::Edge { point, .. } = x { Some(*point) } else { None }).collect() +} + +fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { + egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(150.0).show_ui(ui, |ui| { + for (i, l) in labels.iter().enumerate() { + ui.selectable_value(sel, i, *l); + } + }); +} + +fn profile_param(p: &[(u64, usize)]) -> (Value, Value) { + let sketch = p.first().map(|x| json!(x.0)).unwrap_or(Value::Null); + (sketch, json!(p.iter().map(|x| x.1).collect::>())) +} + +/// Show the active dialog (if any). +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let t = Tokens::get(); + let title = match &d { + Dialog::Sketch { .. } => "CREATE SKETCH", + Dialog::Extrude { .. } => "EXTRUDE", + Dialog::Revolve { .. } => "REVOLVE", + Dialog::Fillet { chamfer: false, .. } => "FILLET", + Dialog::Fillet { chamfer: true, .. } => "CHAMFER", + Dialog::Primitive { cmd, .. } => match *cmd { + "PrimitiveBox" => "BOX", + "PrimitiveCylinder" => "CYLINDER", + "PrimitiveSphere" => "SPHERE", + _ => "TORUS", + }, + Dialog::Combine { .. } => "COMBINE", + Dialog::Params { .. } => "PARAMETERS", + Dialog::EditParam { .. } => "EDIT DIMENSION", + }; + let pos = app.viewport.rect.map(|r| egui::pos2(r.right() - 330.0, r.top() + 150.0)).unwrap_or(egui::pos2(900.0, 200.0)); + let mut keep = true; + let mut ok = false; + let mut cancel = false; + let wide = matches!(d, Dialog::Params { .. }); + egui::Window::new(RichText::new(title).strong().size(13.0)) + .id(egui::Id::new("sc_dialog")) + .default_pos(if wide { egui::pos2(pos.x - 260.0, pos.y - 60.0) } else { pos }) + .resizable(false) + .collapsible(false) + .open(&mut keep) + .show(ctx, |ui| { + ui.set_min_width(if wide { 520.0 } else { 280.0 }); + egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| match &mut d { + Dialog::Sketch { pick_face, .. } => { + ui.label("Plane"); + ui.horizontal(|ui| { + for p in ["XY", "XZ", "YZ"] { + if ui.button(p).clicked() { + if app.run("SketchCreate", json!({"plane": p})).is_ok() { + look_at_sketch(app); + } + cancel = true; + } + } + }); + ui.end_row(); + ui.label("Face"); + ui.toggle_value(pick_face, "Pick a planar face"); + ui.end_row(); + } + Dialog::Extrude { profiles, distance, direction, operation } => { + ui.label("Profiles"); + ui.label(if profiles.is_empty() { + RichText::new("click profiles in the view").color(t.warning) + } else { + RichText::new(format!("{} selected", profiles.len())) + }); + ui.end_row(); + ui.label("Direction"); + combo(ui, "ex_dir", &DIR_LABELS, direction); + ui.end_row(); + ui.label("Distance"); + ui.text_edit_singleline(distance); + ui.end_row(); + ui.label("Operation"); + combo(ui, "ex_op", &OP_LABELS, operation); + ui.end_row(); + } + Dialog::Revolve { profiles, axis, angle, operation } => { + ui.label("Profiles"); + ui.label(if profiles.is_empty() { + RichText::new("click profiles in the view").color(t.warning) + } else { + RichText::new(format!("{} selected", profiles.len())) + }); + ui.end_row(); + ui.label("Axis"); + ui.text_edit_singleline(axis).on_hover_text("sketch line id, x / y (sketch axes) or X / Y / Z"); + ui.end_row(); + ui.label("Angle"); + ui.text_edit_singleline(angle); + ui.end_row(); + ui.label("Operation"); + combo(ui, "rv_op", &OP_LABELS, operation); + ui.end_row(); + } + Dialog::Fillet { edges, radius, chamfer } => { + ui.label("Edges"); + ui.label(if edges.is_empty() { + RichText::new("click edges in the view").color(t.warning) + } else { + RichText::new(format!("{} selected", edges.len())) + }); + ui.end_row(); + ui.label(if *chamfer { "Distance" } else { "Radius" }); + ui.text_edit_singleline(radius); + ui.end_row(); + } + Dialog::Primitive { fields, operation, .. } => { + for (k, v) in fields.iter_mut() { + ui.label(*k); + ui.text_edit_singleline(v); + ui.end_row(); + } + ui.label("Operation"); + combo(ui, "pr_op", &OP_LABELS, operation); + ui.end_row(); + } + Dialog::Combine { target, tools, operation } => { + let bodies: Vec = app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + ui.label("Target body"); + egui::ComboBox::from_id_salt("cb_target").selected_text(target.clone()).show_ui(ui, |ui| { + for b in &bodies { + ui.selectable_value(target, b.clone(), b); + } + }); + ui.end_row(); + ui.label("Tool bodies"); + ui.vertical(|ui| { + for b in bodies.iter().filter(|b| *b != target) { + let mut on = tools.contains(b); + if ui.checkbox(&mut on, b).changed() { + if on { + tools.push(b.clone()); + } else { + tools.retain(|x| x != b); + } + } + } + }); + ui.end_row(); + ui.label("Operation"); + let mut op = operation.saturating_sub(1); + combo(ui, "cb_op", &OP_LABELS[1..], &mut op); + *operation = op + 1; + ui.end_row(); + } + Dialog::Params { new_name, new_expr } => { + ui.label(RichText::new("Name").strong()); + ui.label(RichText::new("Expression · value").strong()); + ui.end_row(); + let (vals, errs) = app.session.doc.param_values(); + let params = app.session.doc.params.clone(); + for p in params { + ui.label(if p.model { RichText::new(&p.name).color(t.text_dim) } else { RichText::new(&p.name) }); + ui.horizontal(|ui| { + let id = egui::Id::new(("pexpr", &p.name)); + let mut e = ui.data(|dd| dd.get_temp::(id)).unwrap_or_else(|| p.expr.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0)); + if r.changed() { + ui.data_mut(|dd| dd.insert_temp(id, e.clone())); + } + if r.lost_focus() && e != p.expr { + let _ = app.run("ChangeParameterCommand", json!({"name": p.name, "expression": e})); + ui.data_mut(|dd| dd.remove::(id)); + } + let v = vals + .get(&p.name) + .map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) }); + match (v, errs.get(&p.name)) { + (_, Some(e)) => ui.label(RichText::new(e).color(t.error)), + (Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)), + _ => ui.label(""), + }; + }); + ui.end_row(); + } + ui.add(egui::TextEdit::singleline(new_name).hint_text("new name").desired_width(110.0)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(new_expr).hint_text("expression, e.g. 25 mm").desired_width(160.0)); + if ui.button("Add").clicked() + && !new_name.is_empty() + && app.run("ChangeParameterCommand", json!({"name": new_name, "expression": new_expr})).is_ok() + { + new_name.clear(); + new_expr.clear(); + } + }); + ui.end_row(); + } + Dialog::EditParam { name, expr, error } => { + ui.label(name.as_str()); + let r = ui.text_edit_singleline(expr); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + ok = true; + } + r.request_focus(); + ui.end_row(); + if let Some(e) = error { + ui.label(""); + ui.label(RichText::new(e.as_str()).color(t.error)); + ui.end_row(); + } + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if !matches!(d, Dialog::Sketch { .. } | Dialog::Params { .. }) + && ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(t.accent).min_size(vec2(70.0, 24.0))).clicked() + { + ok = true; + } + if ui.add(egui::Button::new(if matches!(d, Dialog::Params { .. }) { "Close" } else { "Cancel" }).min_size(vec2(70.0, 24.0))).clicked() + { + cancel = true; + } + }); + }); + if ok { + let r = run_dialog(app, &d); + match r { + Ok(()) => cancel = true, + Err(e) => { + if let Dialog::EditParam { error, .. } = &mut d { + *error = Some(e); + } + } + } + } + // A face picked in the view while the sketch dialog waits for one. + if let Dialog::Sketch { picked: Some(point), .. } = d { + if app.run("SketchCreate", json!({"plane": {"face": [point.x, point.y, point.z]}})).is_ok() { + look_at_sketch(app); + } + cancel = true; + } + if keep && !cancel { + app.dialog = Some(d); + } +} + +fn look_at_sketch(app: &mut SolveApp) { + let st = app.session.model.state(); + if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) { + let n = ss.plane.normal(); + let yaw = (-n.x).atan2(-n.y); + let pitch = n.z.clamp(-1.0, 1.0).asin(); + app.cam.yaw = if n.z.abs() > 0.999 { 0.0 } else { yaw }; + app.cam.pitch = pitch.clamp(-std::f64::consts::FRAC_PI_2 + 1e-4, std::f64::consts::FRAC_PI_2 - 1e-4); + app.cam.target = ss.plane.origin; + } +} + +fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { + match d { + Dialog::Extrude { profiles, distance, direction, operation } => { + if profiles.is_empty() { + return Err("select profiles first".into()); + } + let (sketch, idx) = profile_param(profiles); + app.run( + "Extrude", + json!({"sketch": sketch, "profiles": idx, "distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}), + ) + .map(drop) + } + Dialog::Revolve { profiles, axis, angle, operation } => { + if profiles.is_empty() { + return Err("select profiles first".into()); + } + let (sketch, idx) = profile_param(profiles); + app.run( + "Revolve", + json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}), + ) + .map(drop) + } + Dialog::Fillet { edges, radius, chamfer } => { + if edges.is_empty() { + return Err("select edges first".into()); + } + let pts: Vec<[f64; 3]> = edges.iter().map(|e| [e.x, e.y, e.z]).collect(); + if *chamfer { + app.run("FusionChamferCommand", json!({"edges": pts, "distance": radius})).map(drop) + } else { + app.run("FusionFilletEdgesCommand", json!({"edges": pts, "radius": radius})).map(drop) + } + } + Dialog::Primitive { cmd, fields, operation } => { + let mut p = serde_json::Map::new(); + for (k, v) in fields { + p.insert((*k).into(), json!(v)); + } + p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new"))); + app.run(cmd, Value::Object(p)).map(drop) + } + Dialog::Combine { target, tools, operation } => app + .run("FusionCombineCommand", json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join")})) + .map(drop), + Dialog::EditParam { name, expr, .. } => app.run("ChangeParameterCommand", json!({"name": name, "expression": expr})).map(drop), + Dialog::Sketch { .. } | Dialog::Params { .. } => Ok(()), + } +} diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs new file mode 100644 index 00000000..a201d2e5 --- /dev/null +++ b/crates/ui-egui/src/gpu.rs @@ -0,0 +1,329 @@ +//! GPU viewport: shaded triangles and screen-space wide lines with a depth buffer, drawn in an +//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the +//! camera uniform changes. + +use std::sync::{Arc, Mutex}; + +use egui_wgpu::wgpu; +use egui_wgpu::wgpu::util::DeviceExt; + +/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8). +pub const TRI_SIZE: usize = 28; +/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). +pub const LINE_SIZE: usize = 32; +const UNIFORM_SIZE: u64 = 112; + +/// CPU-side geometry waiting to be uploaded. +#[derive(Default, Clone)] +pub struct GpuScene { + pub tris: Vec, + /// Depth-tested lines. + pub lines: Vec, + /// Lines drawn over everything (active sketch, highlights). + pub overlay: Vec, +} + +impl GpuScene { + pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.tris.extend_from_slice(&v.to_le_bytes()); + } + self.tris.extend_from_slice(&c); + } + pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) { + let out = if on_top { &mut self.overlay } else { &mut self.lines }; + for v in a.iter().chain(&b) { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&c); + out.extend_from_slice(&width.to_le_bytes()); + } +} + +pub type SceneSlot = Arc>>; + +/// Per-frame parameters. +pub struct ViewportCallback { + pub key: u64, + pub slot: SceneSlot, + /// Column-major view-projection. + pub view_proj: [[f32; 4]; 4], + /// Direction toward the eye (for lighting). + pub back: [f32; 3], + /// Viewport size in physical pixels. + pub size_px: [f32; 2], +} + +struct Batch { + buffer: wgpu::Buffer, + count: u32, +} + +struct Resources { + tri: wgpu::RenderPipeline, + line: wgpu::RenderPipeline, + overlay: wgpu::RenderPipeline, + uniform: wgpu::Buffer, + bind: wgpu::BindGroup, + linear_out: bool, + key: Option, + tris: Option, + lines: Option, + overlays: Option, +} + +const SHADER: &str = r#" +struct U { + vp: mat4x4, + back: vec4, // xyz = toward the eye, w = 1 for linear output + screen: vec4, // viewport width, height (px), depth bias +}; +@group(0) @binding(0) var u: U; + +fn to_linear(c: vec3) -> vec3 { + return select(c, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045)); +} + +fn out_color(c: vec4) -> vec4 { + if (u.back.w > 0.5) { + return vec4(to_linear(c.rgb), c.a); + } + return c; +} + +struct TOut { + @builtin(position) pos: vec4, + @location(0) n: vec3, + @location(1) c: vec4, +}; + +@vertex +fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { + var o: TOut; + o.pos = u.vp * vec4(p, 1.0); + o.n = n; + o.c = c; + return o; +} + +@fragment +fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + let v = normalize(u.back.xyz); + var n = normalize(i.n); + if (dot(n, v) < 0.0) { n = -n; } + let key = normalize(v * 0.8 + vec3(-0.3, 0.2, 0.6)); + let diff = max(dot(n, key), 0.0); + let fill = max(dot(n, v), 0.0); + let h = normalize(key + v); + let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25; + let k = 0.42 + 0.38 * diff + 0.25 * fill; + return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), 1.0)); +} + +struct LOut { + @builtin(position) pos: vec4, + @location(0) c: vec4, +}; + +@vertex +fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location(1) b: vec3, @location(2) c: vec4, @location(3) w: f32) -> LOut { + let ca = u.vp * vec4(a, 1.0); + let cb = u.vp * vec4(b, 1.0); + let half = u.screen.xy * 0.5; + let sa = ca.xy / max(ca.w, 1e-6) * half; + let sb = cb.xy / max(cb.w, 1e-6) * half; + var dir = sb - sa; + let len = length(dir); + if (len < 1e-6) { dir = vec2(1.0, 0.0); } else { dir = dir / len; } + let nrm = vec2(-dir.y, dir.x); + // Two triangles: corners (t, side). + var ts = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); + var ss = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let t = ts[vi]; + let s = ss[vi]; + let base = select(ca, cb, t > 0.5); + let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5; + var o: LOut; + o.pos = vec4(base.xy + off / half * base.w, base.z - u.screen.z * base.w, base.w); + o.c = c; + return o; +} + +@fragment +fn fs_line(i: LOut) -> @location(0) vec4 { + return out_color(i.c); +} +"#; + +fn tri_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4]; + wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + +fn line_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32]; + wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } +} + +/// The target configuration: colour format, depth format and MSAA sample count of the app. +#[derive(Clone, Copy, Debug)] +pub struct GpuTarget { + pub format: wgpu::TextureFormat, + pub depth: Option, + pub samples: u32, +} + +impl Resources { + fn new(device: &wgpu::Device, t: GpuTarget) -> Self { + let module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_viewport"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_viewport"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sc_viewport_u"), + size: UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_viewport"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], + }); + let depth = |compare: wgpu::CompareFunction, write: bool| { + t.depth.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: Some(write), + depth_compare: Some(compare), + stencil: Default::default(), + bias: Default::default(), + }) + }; + let pipeline = |label: &str, + vs: &str, + fs: &str, + buf: wgpu::VertexBufferLayout<'static>, + ds: Option, + blend: Option| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: ds, + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + let alpha = Some(wgpu::BlendState::ALPHA_BLENDING); + Resources { + tri: pipeline("sc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), + line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + overlay: pipeline("sc_overlay", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), + uniform, + bind, + linear_out: t.format.is_srgb(), + key: None, + tris: None, + lines: None, + overlays: None, + } + } +} + +fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { + if bytes.len() < stride { + return None; + } + Some(Batch { + buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytes, + usage: wgpu::BufferUsages::VERTEX, + }), + count: u32::try_from(bytes.len() / stride).unwrap_or(0), + }) +} + +/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings. +pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget { + let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples }; + let res = Resources::new(&rs.device, t); + rs.renderer.write().callback_resources.insert(res); + t +} + +impl egui_wgpu::CallbackTrait for ViewportCallback { + fn prepare( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + _screen: &egui_wgpu::ScreenDescriptor, + _encoder: &mut wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + let Some(res) = resources.get_mut::() else { return Vec::new() }; + if res.key != Some(self.key) + && let Some(sc) = self.slot.lock().ok().and_then(|mut s| s.take()) + { + res.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE); + res.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); + res.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); + res.key = Some(self.key); + } + queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); + Vec::new() + } + + fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) { + let Some(res) = resources.get::() else { return }; + let vp = info.viewport_in_pixels(); + if vp.width_px <= 0 || vp.height_px <= 0 { + return; + } + pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0); + pass.set_bind_group(0, &res.bind, &[]); + if let Some(b) = &res.tris { + pass.set_pipeline(&res.tri); + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..b.count, 0..1); + } + if let Some(b) = &res.lines { + pass.set_pipeline(&res.line); + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..6, 0..b.count); + } + if let Some(b) = &res.overlays { + pass.set_pipeline(&res.overlay); + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..6, 0..b.count); + } + } +} + +/// Uniform bytes for a frame. +pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec { + let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); + v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); + v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]); + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 00000000..52a47d2e --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,365 @@ +//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a +//! 24 × 24 grid and scaled to the target rectangle. + +use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; + +struct Pen<'a> { + p: &'a Painter, + r: Rect, + ink: Color32, + fill: Color32, + accent: Color32, + w: f32, +} + +impl Pen<'_> { + fn at(&self, x: f32, y: f32) -> Pos2 { + pos2(self.r.left() + x / 24.0 * self.r.width(), self.r.top() + y / 24.0 * self.r.height()) + } + fn s(&self) -> f32 { + self.r.width() / 24.0 + } + fn line(&self, pts: &[(f32, f32)], c: Color32) { + let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); + self.p.add(Shape::line(v, Stroke::new(self.w, c))); + } + fn poly(&self, pts: &[(f32, f32)], fill: Color32, c: Color32) { + let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); + self.p.add(Shape::convex_polygon(v, fill, Stroke::new(self.w, c))); + } + fn closed(&self, pts: &[(f32, f32)], c: Color32) { + let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); + self.p.add(Shape::closed_line(v, Stroke::new(self.w, c))); + } + fn circle(&self, x: f32, y: f32, r: f32, fill: Color32, c: Color32) { + self.p.circle(self.at(x, y), r * self.s(), fill, Stroke::new(self.w, c)); + } + fn dot(&self, x: f32, y: f32, c: Color32) { + self.p.circle_filled(self.at(x, y), 1.6 * self.s(), c); + } + fn arc(&self, x: f32, y: f32, r: f32, a0: f32, a1: f32, c: Color32) { + let n = 24; + let pts: Vec<(f32, f32)> = (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + r * a.cos(), y - r * a.sin())).collect(); + self.line(&pts, c); + } + fn arrow(&self, from: (f32, f32), to: (f32, f32), c: Color32) { + self.line(&[from, to], c); + let d = vec2(to.0 - from.0, to.1 - from.1).normalized(); + let n = vec2(-d.y, d.x); + let tip = vec2(to.0, to.1); + let a = tip - d * 3.5 + n * 2.2; + let b = tip - d * 3.5 - n * 2.2; + self.poly(&[to, (a.x, a.y), (b.x, b.y)], c, c); + } + /// Isometric box from a 2D footprint height. + fn iso_box(&self, x0: f32, y0: f32, w: f32, d: f32, h: f32) { + let top = [(x0, y0), (x0 + w, y0 - d * 0.5), (x0 + w + d, y0), (x0 + d, y0 + d * 0.5)]; + let front = [(x0, y0), (x0 + d, y0 + d * 0.5), (x0 + d, y0 + d * 0.5 + h), (x0, y0 + h)]; + let side = [(x0 + d, y0 + d * 0.5), (x0 + w + d, y0), (x0 + w + d, y0 + h), (x0 + d, y0 + d * 0.5 + h)]; + self.poly(&front, self.fill.gamma_multiply(0.85), self.ink); + self.poly(&side, self.fill.gamma_multiply(0.7), self.ink); + self.poly(&top, self.fill, self.ink); + } +} + +/// Paint the icon `name` into `r`. `accent` highlights the "action" part. +pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) { + let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) }; + let a = pen.accent; + match name { + "sketch" => { + pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink); + pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a); + } + "finish" => { + pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, pen.accent); + pen.line(&[(7.0, 12.5), (10.5, 16.0), (17.0, 8.5)], a); + } + "extrude" => { + pen.iso_box(4.0, 11.0, 9.0, 7.0, 7.0); + pen.arrow((12.0, 9.0), (12.0, 2.5), a); + } + "revolve" => { + pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); + pen.poly(&[(13.0, 7.0), (19.0, 7.0), (19.0, 17.0), (13.0, 17.0)], pen.fill, ink); + pen.arc(12.0, 12.0, 9.0, 3.6, 5.8, a); + pen.arrow((15.0, 20.4), (18.5, 19.0), a); + } + "box" => pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0), + "cylinder" => { + pen.poly(&[(5.0, 6.0), (19.0, 6.0), (19.0, 18.0), (5.0, 18.0)], pen.fill.gamma_multiply(0.8), Color32::TRANSPARENT); + pen.line(&[(5.0, 6.0), (5.0, 18.0)], ink); + pen.line(&[(19.0, 6.0), (19.0, 18.0)], ink); + let e = |y: f32, fillc: Color32| { + let pts: Vec = + (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 7.0 * t.cos(), y + 2.5 * t.sin())).collect(); + p.add(Shape::convex_polygon(pts, fillc, Stroke::new(pen.w, ink))); + }; + pen.arc(12.0, 18.0, 7.0, std::f32::consts::PI, std::f32::consts::TAU, ink); + e(6.0, pen.fill); + } + "sphere" => { + pen.circle(12.0, 12.0, 8.5, pen.fill, ink); + pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5)); + p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160)); + } + "torus" => { + let pts: Vec = + (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 5.5 * t.sin())).collect(); + p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink))); + let pts: Vec = + (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect(); + p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink))); + } + "fillet" => { + pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT); + pen.line(&[(4.0, 20.0), (4.0, 4.0)], ink); + pen.line(&[(4.0, 20.0), (20.0, 20.0)], ink); + pen.arc(4.0, 4.0, 16.0, -std::f32::consts::FRAC_PI_2, 0.0, ink); + pen.arc(20.0, 4.0, 10.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a); + } + "chamfer" => { + pen.poly(&[(4.0, 20.0), (4.0, 4.0), (12.0, 4.0), (20.0, 12.0), (20.0, 20.0)], pen.fill, ink); + pen.line(&[(12.0, 4.0), (20.0, 12.0)], a); + } + "combine" => { + pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink); + pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a); + } + "move" => { + for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { + pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a); + } + } + "params" => { + pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink); + pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink); + pen.line(&[(14.0, 13.0), (20.0, 20.0)], a); + pen.line(&[(20.0, 13.0), (14.0, 20.0)], a); + } + "compute" => { + pen.arc(12.0, 12.0, 7.5, 0.4, 3.0, ink); + pen.arc(12.0, 12.0, 7.5, 3.5, 6.0, a); + pen.arrow((5.0, 9.0), (4.6, 13.0), ink); + pen.arrow((19.0, 15.0), (19.4, 11.0), a); + } + "delete" => { + pen.line(&[(6.0, 6.0), (18.0, 18.0)], pen.accent); + pen.line(&[(18.0, 6.0), (6.0, 18.0)], pen.accent); + } + "measure" => { + pen.poly(&[(2.0, 15.0), (15.0, 2.0), (22.0, 9.0), (9.0, 22.0)], pen.fill, ink); + for i in 0..5 { + let t = 4.0 + i as f32 * 2.6; + pen.line(&[(t, 15.0 - (t - 2.0)), (t + 2.0, 15.0 - (t - 2.0) + 2.0)], ink); + } + } + "line" => { + pen.line(&[(4.0, 19.0), (20.0, 5.0)], a); + pen.dot(4.0, 19.0, ink); + pen.dot(20.0, 5.0, ink); + } + "rect" | "rect3" | "rect_center" => { + pen.closed(&[(4.0, 6.0), (20.0, 6.0), (20.0, 18.0), (4.0, 18.0)], a); + match name { + "rect" => { + pen.dot(4.0, 18.0, ink); + pen.dot(20.0, 6.0, ink); + } + "rect3" => { + pen.dot(4.0, 18.0, ink); + pen.dot(20.0, 18.0, ink); + pen.dot(20.0, 6.0, ink); + } + _ => { + pen.dot(12.0, 12.0, ink); + pen.dot(20.0, 6.0, ink); + } + } + } + "circle" | "circle2" | "circle3" => { + pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a); + match name { + "circle" => { + pen.dot(12.0, 12.0, ink); + pen.line(&[(12.0, 12.0), (17.6, 6.4)], ink); + } + "circle2" => { + pen.dot(4.0, 12.0, ink); + pen.dot(20.0, 12.0, ink); + } + _ => { + pen.dot(4.0, 12.0, ink); + pen.dot(12.0, 4.0, ink); + pen.dot(20.0, 12.0, ink); + } + } + } + "arc3" | "arc_center" => { + pen.arc(12.0, 16.0, 9.0, 0.0, std::f32::consts::PI, a); + pen.dot(3.0, 16.0, ink); + pen.dot(21.0, 16.0, ink); + if name == "arc3" { + pen.dot(12.0, 7.0, ink); + } else { + pen.dot(12.0, 16.0, ink); + } + } + "polygon" => { + let pts: Vec<(f32, f32)> = + (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU + 0.52).map(|t| (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); + pen.closed(&pts, a); + pen.dot(12.0, 12.0, ink); + } + "slot" => { + pen.arc(8.0, 12.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI * 1.5, a); + pen.arc(16.0, 12.0, 5.0, -std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2, a); + pen.line(&[(8.0, 7.0), (16.0, 7.0)], a); + pen.line(&[(8.0, 17.0), (16.0, 17.0)], a); + pen.dot(8.0, 12.0, ink); + pen.dot(16.0, 12.0, ink); + } + "point" => { + pen.dot(12.0, 12.0, a); + pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, ink); + } + "dimension" => { + pen.line(&[(4.0, 18.0), (4.0, 8.0)], ink); + pen.line(&[(20.0, 18.0), (20.0, 8.0)], ink); + pen.arrow((12.0, 11.0), (4.5, 11.0), a); + pen.arrow((12.0, 11.0), (19.5, 11.0), a); + pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink); + } + "construction" => { + for i in 0..4 { + let t = i as f32 * 4.5; + pen.line(&[(3.0 + t, 19.0 - t), (5.5 + t, 16.5 - t)], pen.accent); + } + } + "c_hv" => { + pen.line(&[(4.0, 18.0), (20.0, 18.0)], a); + pen.line(&[(6.0, 4.0), (6.0, 14.0)], a); + } + "c_coincident" => { + pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink); + pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink); + pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a); + } + "c_tangent" => { + pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink); + pen.line(&[(3.0, 8.0), (21.0, 8.0)], a); + } + "c_equal" => { + pen.line(&[(5.0, 9.0), (19.0, 9.0)], a); + pen.line(&[(5.0, 15.0), (19.0, 15.0)], a); + } + "c_parallel" => { + pen.line(&[(5.0, 19.0), (13.0, 5.0)], a); + pen.line(&[(11.0, 19.0), (19.0, 5.0)], a); + } + "c_perpendicular" => { + pen.line(&[(4.0, 19.0), (20.0, 19.0)], a); + pen.line(&[(12.0, 19.0), (12.0, 5.0)], a); + pen.line(&[(12.0, 15.0), (16.0, 15.0), (16.0, 19.0)], ink); + } + "c_fix" => { + pen.circle(12.0, 9.0, 4.0, Color32::TRANSPARENT, a); + pen.line(&[(12.0, 13.0), (12.0, 21.0)], a); + pen.line(&[(9.0, 21.0), (15.0, 21.0)], ink); + } + "c_midpoint" => { + pen.line(&[(3.0, 15.0), (21.0, 9.0)], ink); + pen.poly(&[(12.0, 9.0), (15.0, 12.0), (12.0, 15.0), (9.0, 12.0)], a, a); + } + "c_concentric" => { + pen.circle(12.0, 12.0, 8.5, Color32::TRANSPARENT, a); + pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, a); + } + "c_collinear" => { + pen.line(&[(3.0, 19.0), (10.0, 12.0)], a); + pen.line(&[(14.0, 8.0), (21.0, 1.0)], a); + } + "c_symmetry" => { + pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink); + pen.poly(&[(4.0, 8.0), (9.0, 12.0), (4.0, 16.0)], a, a); + pen.poly(&[(20.0, 8.0), (15.0, 12.0), (20.0, 16.0)], a, a); + } + "undo" | "redo" => { + let (x0, x1) = if name == "undo" { (19.0, 5.0) } else { (5.0, 19.0) }; + pen.line(&[(x0, 18.0), (x0, 12.0), (12.0, 9.0), (x1, 9.0)], ink); + pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink); + } + "new" => { + pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink); + pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink); + } + "open" => { + pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (20.0, 9.0), (20.0, 19.0), (3.0, 19.0)], pen.fill, ink); + } + "save" => { + pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink); + pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink); + } + "export" => { + pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink); + pen.arrow((10.0, 14.0), (20.0, 4.0), a); + } + "home" => { + pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink); + pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink); + } + "fit" => { + for (cx, cy, dx, dy) in [(4.0, 4.0, 1.0, 1.0), (20.0, 4.0, -1.0, 1.0), (4.0, 20.0, 1.0, -1.0), (20.0, 20.0, -1.0, -1.0)] { + pen.line(&[(cx + dx * 5.0, cy), (cx, cy), (cx, cy + dy * 5.0)], ink); + } + pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 15.0), (9.0, 15.0)], pen.fill, ink); + } + "orbit" => { + pen.arc(12.0, 12.0, 8.0, 0.3, 5.5, ink); + pen.arrow((17.0, 17.5), (19.6, 14.5), a); + pen.dot(12.0, 12.0, ink); + } + "pan" => { + for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { + pen.arrow((12.0 + 3.0 * dx, 12.0 + 3.0 * dy), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), ink); + } + } + "zoom" => { + pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink); + pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink); + } + "perspective" => { + pen.closed(&[(4.0, 6.0), (20.0, 4.0), (20.0, 20.0), (4.0, 18.0)], ink); + pen.closed(&[(9.0, 9.0), (15.0, 8.5), (15.0, 15.5), (9.0, 15.0)], ink); + } + "eye" => { + pen.arc(12.0, 18.0, 10.0, 0.6, 2.54, ink); + pen.arc(12.0, 6.0, 10.0, 3.74, 5.68, ink); + pen.circle(12.0, 12.0, 2.6, ink, ink); + } + "body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0), + "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink), + "origin" => { + pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60)); + pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220)); + pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60)); + } + "plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink), + "settings" => { + pen.circle(12.0, 12.0, 6.5, pen.fill, ink); + pen.circle(12.0, 12.0, 2.5, Color32::WHITE, ink); + for i in 0..8 { + let t = i as f32 / 8.0 * std::f32::consts::TAU; + pen.line(&[(12.0 + 6.5 * t.cos(), 12.0 + 6.5 * t.sin()), (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())], ink); + } + } + "warning" => { + pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink); + pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink); + pen.dot(12.0, 17.0, ink); + } + _ => { + pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b137891..041803e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1 +1,322 @@ +//! The SolveCraft egui front end. +//! +//! A thin shell over [`solvecraft_engine::Session`]: panels read engine state and act through +//! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar +//! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser +//! on the left, the 3D viewport with a view cube and navigation bar, the command dialog on the +//! right of the viewport, the command palette and the timeline at the bottom. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +pub mod browser; +pub mod control; +pub mod dialogs; +pub mod gpu; +pub mod icons; +pub mod palette; +pub mod theme; +pub mod timeline; +pub mod toolbar; +pub mod tools; +pub mod viewport; + +use std::sync::mpsc::Receiver; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use solvecraft_engine::Session; +use solvecraft_engine::render::Camera; + +pub use control::{ControlRequest, ControlResponse}; + +/// UI state that automation can read and set (`ui.set`). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct UiState { + /// Toolbar tab: SOLID, SURFACE, MESH, SHEET METAL, PLASTIC, UTILITIES (SKETCH while sketching). + pub tab: String, + pub show_browser: bool, + pub show_timeline: bool, + pub show_grid: bool, + pub show_origin: bool, + pub perspective: bool, + pub hidden_bodies: Vec, + pub show_sketches: bool, + pub palette_open: bool, +} + +impl Default for UiState { + fn default() -> Self { + UiState { + tab: "SOLID".into(), + show_browser: true, + show_timeline: true, + show_grid: true, + show_origin: true, + perspective: false, + hidden_bodies: Vec::new(), + show_sketches: true, + palette_open: false, + } + } +} + +/// Host services (file pickers) injected by the app so this crate stays portable. +#[derive(Default)] +pub struct Services { + pub pick_open: Option Option>>, + pub pick_save: Option Option>>, +} + +pub struct SolveApp { + pub session: Session, + pub ui: UiState, + pub cam: Camera, + pub services: Services, + pub viewport: viewport::ViewportState, + pub tool: Option, + pub dialog: Option, + pub palette: palette::Palette, + pub status: Option<(String, f64, bool)>, + pub quit_requested: bool, + pub frame_ms: f64, + pub synthetic: Vec, + control_rx: Option>, + pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, + queued_shots: Vec<(u64, f64, u32)>, + shot_token: u64, + styled: bool, + fitted: bool, +} + +impl SolveApp { + pub fn new(session: Session, services: Services) -> Self { + SolveApp { + session, + ui: UiState::default(), + cam: Camera::default(), + services, + viewport: viewport::ViewportState::default(), + tool: None, + dialog: None, + palette: palette::Palette::default(), + status: None, + quit_requested: false, + frame_ms: 0.0, + synthetic: Vec::new(), + control_rx: None, + pending_shots: Vec::new(), + queued_shots: Vec::new(), + shot_token: 0, + styled: false, + fitted: false, + } + } + + /// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer + /// bits and MSAA sample count). Without it the viewport renders on the CPU. + pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) { + self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples)); + } + + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + /// Run a command programmatically (JSON parameters, no dialogs). + pub fn run(&mut self, id: &str, params: Value) -> Result { + let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(_) => { + self.session.echo(format!("{id}: done")); + if matches!(id, "NewDocumentCommand" | "doc.open") { + self.cam = Camera::default(); + self.fit_view(); + } + } + Err(e) => { + self.session.echo(format!("{id}: {e}")); + self.set_status(e.clone(), true); + } + } + if self.session.active_sketch.is_some() && self.ui.tab != "SKETCH" { + self.ui.tab = "SKETCH".into(); + } else if self.session.active_sketch.is_none() && self.ui.tab == "SKETCH" { + self.ui.tab = "SOLID".into(); + self.tool = None; + } + r + } + + /// Start a command the way a toolbar click does: interactive tools and dialogs for commands + /// that have them, otherwise run it with defaults. + pub fn start(&mut self, id: &str) { + self.tool = None; + if let Some(t) = tools::Tool::for_command(id) { + self.tool = Some(t); + self.set_status(tools::hint(id), false); + return; + } + if let Some(d) = dialogs::Dialog::for_command(self, id) { + self.dialog = Some(d); + return; + } + let _ = self.run(id, json!({})); + } + + pub fn set_status(&mut self, s: impl Into, error: bool) { + self.status = Some((s.into(), now_ms(), error)); + } + + pub fn open_path(&mut self, path: &str) { + if self.run("doc.open", json!({ "path": path })).is_ok() { + self.fit_view(); + } + } + + pub fn fit_view(&mut self) { + let b = solvecraft_engine::view::bounds(&self.session); + if b.is_empty() { + self.cam.target = solvecraft_engine::geom::Vec3::ZERO; + self.cam.distance = 150.0; + } else { + self.cam.fit(&b); + } + } + + /// Per-frame logic before layout. + pub fn logic(&mut self, ctx: &egui::Context) { + if !self.styled { + theme::apply(ctx); + self.styled = true; + } + if !self.fitted { + self.fit_view(); + self.fitted = true; + } + self.drain_control(ctx); + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + toolbar::shortcuts(self, ctx); + for f in ctx.input(|i| i.raw.dropped_files.clone()) { + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + self.open_path(&p); + } + } + } + + /// Inject synthetic events (one pointer event per frame). + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if self.synthetic.is_empty() { + return; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + raw.events.extend(self.synthetic.drain(..n)); + } + + /// Lay out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let t0 = now_ms(); + toolbar::app_bar(self, ui); + toolbar::toolbar(self, ui); + if self.ui.show_timeline { + timeline::timeline(self, ui); + } + palette::palette_bar(self, ui); + if self.ui.show_browser { + browser::browser(self, ui); + } + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + viewport::show(self, ui); + }); + dialogs::show(self, ui.ctx()); + palette::popup(self, ui.ctx()); + self.frame_ms = now_ms() - t0; + } + + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + self.shot_token += 1; + let token = self.shot_token; + self.queued_shots.push((token, now_ms() + 150.0, 0)); + self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); + } + } + ctx.request_repaint(); + } + self.control_rx = Some(rx); + } + + fn issue_screenshots(&mut self, ctx: &egui::Context) { + let now = now_ms(); + self.queued_shots.retain_mut(|(token, at, frames)| { + *frames += 1; + if now >= *at && *frames >= 3 { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + false + } else { + true + } + }); + if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + fn collect_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_shots.is_empty() { + return; + } + let events: Vec<_> = ctx.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { user_data, image, .. } => { + let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; + Some((token, image.clone())) + } + _ => None, + }) + .collect() + }); + for (token, image) in events { + if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { + let (_, path, reply, _) = self.pending_shots.remove(i); + let _ = reply.send(control::save_screenshot(&image, path.as_deref())); + } + } + let now = now_ms(); + self.pending_shots.retain(|(_, _, reply, deadline)| { + if now < *deadline { + return true; + } + let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"})); + false + }); + } +} + +pub fn now_ms() -> f64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) +} diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs new file mode 100644 index 00000000..bfb0449f --- /dev/null +++ b/crates/ui-egui/src/palette.rs @@ -0,0 +1,133 @@ +//! The command line / palette: type a command id with JSON parameters +//! (`Extrude {"distance": 20}`), or part of a command name to search (press S anywhere). + +use egui::{RichText, vec2}; +use serde_json::Value; +use solvecraft_engine::command_specs; + +use crate::SolveApp; +use crate::theme::Tokens; + +#[derive(Default)] +pub struct Palette { + pub text: String, + pub history: Vec, + focus: bool, +} + +/// Commands whose id or label matches the query. +pub fn matches(q: &str) -> Vec<(&'static str, &'static str, &'static str)> { + let q = q.trim().to_ascii_lowercase(); + let mut v: Vec<(&'static str, &'static str, &'static str)> = command_specs() + .into_iter() + .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) + .map(|c| (c.id, c.label, c.icon)) + .collect(); + v.sort_by_key(|(id, label, _)| (!label.to_ascii_lowercase().starts_with(&q), !id.to_ascii_lowercase().starts_with(&q), *label)); + v +} + +/// Execute a line of palette input. +pub fn submit(app: &mut SolveApp, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + app.palette.history.push(line.to_string()); + let (id, rest) = match line.find(|c: char| c.is_whitespace() || c == '{') { + Some(i) => (&line[..i], line[i..].trim()), + None => (line, ""), + }; + if solvecraft_engine::find_command(id).is_some() { + if rest.is_empty() { + app.start(id); + } else { + match serde_json::from_str::(rest) { + Ok(p) => { + let _ = app.run(id, p); + } + Err(e) => app.set_status(format!("parameters must be JSON: {e}"), true), + } + } + return; + } + if let Some((id, _, _)) = matches(line).first() { + app.start(id); + } else { + app.set_status(format!("no command matches `{line}`"), true); + } +} + +pub fn palette_bar(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Panel::bottom("sc_palette").exact_size(28.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 3))).show( + ui, + |ui| { + ui.horizontal_centered(|ui| { + ui.label(RichText::new("›").strong().color(t.accent)); + let r = ui.add( + egui::TextEdit::singleline(&mut app.palette.text) + .hint_text("Type a command, e.g. Extrude {\"distance\": 20} — or press S to search") + .desired_width(520.0), + ); + if app.palette.focus { + r.request_focus(); + app.palette.focus = false; + } + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let line = std::mem::take(&mut app.palette.text); + submit(app, &line); + } + if let Some(last) = app.session.log.last() { + ui.label(RichText::new(last).size(11.5).color(t.text_dim)); + } + }); + }, + ); +} + +/// The search popup (S). +pub fn popup(app: &mut SolveApp, ctx: &egui::Context) { + if !app.ui.palette_open { + return; + } + let mut open = true; + let mut picked: Option<&'static str> = None; + let t = Tokens::get(); + let center = ctx.content_rect().center(); + egui::Window::new("Search commands") + .id(egui::Id::new("sc_search")) + .fixed_pos(egui::pos2(center.x - 200.0, 150.0)) + .fixed_size(vec2(400.0, 340.0)) + .collapsible(false) + .open(&mut open) + .show(ctx, |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut app.palette.text).hint_text("command name").desired_width(380.0)); + r.request_focus(); + let list = matches(&app.palette.text); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + picked = list.first().map(|x| x.0); + } + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for (id, label, icon) in list.iter().take(60) { + let enabled = solvecraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled); + ui.horizontal(|ui| { + let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), ir, icon, t.icon, t.icon_fill, t.accent); + if ui.add_enabled(enabled, egui::Button::new(*label).frame(false)).clicked() { + picked = Some(id); + } + ui.label(RichText::new(*id).size(10.5).color(t.text_dim)); + }); + } + }); + }); + if let Some(id) = picked { + app.palette.text.clear(); + app.ui.palette_open = false; + app.start(id); + } else if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.palette_open = false; + app.palette.text.clear(); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 00000000..758dd939 --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,70 @@ +//! Colours and widget style. A light theme in the spirit of desktop CAD tools; every colour +//! the UI uses comes from [`Tokens`]. + +use egui::{Color32, Stroke}; + +#[derive(Clone, Copy, Debug)] +pub struct Tokens { + pub app_bar: Color32, + pub toolbar: Color32, + pub tab_active: Color32, + pub panel: Color32, + pub panel_header: Color32, + pub border: Color32, + pub text: Color32, + pub text_dim: Color32, + pub accent: Color32, + pub accent_soft: Color32, + pub sketch_accent: Color32, + pub error: Color32, + pub warning: Color32, + pub icon: Color32, + pub icon_fill: Color32, + pub timeline: Color32, + pub hover: Color32, +} + +impl Tokens { + pub const fn get() -> Tokens { + Tokens { + app_bar: Color32::from_rgb(52, 56, 64), + toolbar: Color32::from_rgb(246, 247, 249), + tab_active: Color32::from_rgb(255, 255, 255), + panel: Color32::from_rgb(250, 251, 252), + panel_header: Color32::from_rgb(236, 238, 242), + border: Color32::from_rgb(208, 212, 220), + text: Color32::from_rgb(38, 42, 50), + text_dim: Color32::from_rgb(110, 116, 128), + accent: Color32::from_rgb(38, 120, 218), + accent_soft: Color32::from_rgb(214, 230, 250), + sketch_accent: Color32::from_rgb(46, 150, 90), + error: Color32::from_rgb(214, 54, 54), + warning: Color32::from_rgb(222, 150, 30), + icon: Color32::from_rgb(70, 78, 92), + icon_fill: Color32::from_rgb(178, 196, 222), + timeline: Color32::from_rgb(232, 235, 240), + hover: Color32::from_rgb(226, 234, 246), + } + } +} + +pub fn apply(ctx: &egui::Context) { + let t = Tokens::get(); + let mut v = egui::Visuals::light(); + v.panel_fill = t.panel; + v.window_fill = t.panel; + v.extreme_bg_color = Color32::WHITE; + v.selection.bg_fill = t.accent_soft; + v.selection.stroke = Stroke::new(1.0, t.accent); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.hovered.weak_bg_fill = t.hover; + v.widgets.hovered.bg_fill = t.hover; + v.window_corner_radius = egui::CornerRadius::same(6); + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(6.0, 4.0); + s.spacing.button_padding = egui::vec2(6.0, 3.0); + }); +} diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs new file mode 100644 index 00000000..bc3e64bd --- /dev/null +++ b/crates/ui-egui/src/timeline.rs @@ -0,0 +1,129 @@ +//! The timeline (bottom): features in order, the rollback marker, errors and warnings. + +use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; +use solvecraft_engine::doc::FeatureKind; + +use crate::theme::Tokens; +use crate::{SolveApp, icons}; + +fn icon_of(k: &FeatureKind) -> &'static str { + match k { + FeatureKind::Sketch { .. } => "sketch", + FeatureKind::Extrude { .. } => "extrude", + FeatureKind::Revolve { .. } => "revolve", + FeatureKind::Fillet { .. } => "fillet", + FeatureKind::Chamfer { .. } => "chamfer", + FeatureKind::Box { .. } => "box", + FeatureKind::Cylinder { .. } => "cylinder", + FeatureKind::Sphere { .. } => "sphere", + FeatureKind::Torus { .. } => "torus", + FeatureKind::Combine { .. } => "combine", + FeatureKind::Move { .. } => "move", + } +} + +pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| { + let r = ui.max_rect(); + let p = ui.painter().clone(); + p.line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border)); + let n = app.session.doc.features.len(); + let marker = app.session.doc.marker.unwrap_or(n); + // Playback controls. + let mut x = r.left() + 10.0; + let controls = [("⏮", Some(0usize)), ("◀", Some(marker.saturating_sub(1))), ("▶", Some((marker + 1).min(n))), ("⏭", None)]; + for (label, target) in controls { + let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), vec2(22.0, 22.0)); + let resp = ui.interact(br, ui.id().with(("tl", label)), Sense::click()); + if resp.hovered() { + p.rect_filled(br, 3.0, t.hover); + } + p.text(br.center(), Align2::CENTER_CENTER, label, FontId::proportional(13.0), t.icon); + if resp.clicked() { + let pos = match target { + Some(k) if k < n => json!({"position": k}), + _ => json!({}), + }; + let _ = app.run("timeline.rollback", pos); + } + x += 24.0; + } + x += 14.0; + let feats: Vec<_> = + app.session.doc.features.iter().map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name())).collect(); + let mut edit: Option = None; + for (i, (id, name, icon, suppressed, ty)) in feats.iter().enumerate() { + if i == marker { + p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(4.0, r.height() - 10.0)), 1.0, Color32::from_rgb(70, 76, 88)); + x += 8.0; + } + let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); + let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click()); + let res = app.session.model.result(*id); + let err = res.and_then(|r| r.error.clone()); + let warn = res.and_then(|r| r.warning.clone()); + let rolled = i >= marker; + let sel = app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id)); + let bg = if err.is_some() { + Color32::from_rgb(250, 220, 220) + } else if sel { + t.accent_soft + } else if resp.hovered() { + t.hover + } else { + Color32::WHITE + }; + p.rect(br, 3.0, bg, Stroke::new(1.0, if err.is_some() { t.error } else { t.border }), egui::StrokeKind::Inside); + let ink = if rolled || *suppressed { t.border } else { t.icon }; + icons::paint(&p, br.shrink(4.0), icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent }); + if warn.is_some() && err.is_none() { + icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "warning", t.icon, t.warning, t.warning); + } + let mut tip = format!("{name} ({ty})"); + if let Some(e) = &err { + tip += &format!("\nError: {e}"); + } + if let Some(w) = &warn { + tip += &format!("\nWarning: {w}"); + } + if let Some(r) = res { + tip += &format!("\n{:.1} ms", r.ms); + } + let resp = resp.on_hover_text(tip); + if resp.clicked() { + let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); + } + if resp.double_clicked() { + edit = Some(*id); + } + resp.context_menu(|ui| { + if ui.button(if *suppressed { "Unsuppress" } else { "Suppress" }).clicked() { + let _ = app.run("timeline.suppress", json!({"feature": id})); + ui.close(); + } + if ui.button("Roll back to here").clicked() { + let _ = app.run("timeline.rollback", json!({"position": i})); + ui.close(); + } + if ui.button("Delete").clicked() { + let _ = app.run("FusionDeleteCommand", json!({"features": [id.to_string()]})); + ui.close(); + } + }); + x += 32.0; + } + if marker >= feats.len() { + p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(4.0, r.height() - 10.0)), 1.0, Color32::from_rgb(70, 76, 88)); + } + if let Some(id) = edit { + if matches!(app.session.doc.feature(id).map(|f| &f.kind), Some(FeatureKind::Sketch { .. })) { + let _ = app.run("SketchActivate", json!({"sketch": id})); + } else { + app.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); + app.ui.palette_open = true; + } + } + }); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs new file mode 100644 index 00000000..cda5e8f5 --- /dev/null +++ b/crates/ui-egui/src/toolbar.rs @@ -0,0 +1,374 @@ +//! Application bar, the workspace toolbar (tabs and panels) and keyboard shortcuts. + +use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; +use solvecraft_engine::command_specs; + +use crate::theme::Tokens; +use crate::{SolveApp, icons}; + +pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"]; + +/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel). +fn panels(tab: &str) -> &'static [&'static str] { + match tab { + "SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], + "SKETCH" => &["CREATE", "MODIFY", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"], + "SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], + "MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], + "SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], + "PLASTIC" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"], + _ => &["MAKE", "ADD-INS", "UTILITY", "INSPECT", "SELECT"], + } +} + +/// How many commands of a panel get a big button. +fn promoted(tab: &str, panel: &str) -> usize { + match (tab, panel) { + ("SOLID", "CREATE") => 4, + ("SOLID", "MODIFY") => 4, + ("SKETCH", "CREATE") => 6, + ("SKETCH", "CONSTRAINTS") => 6, + (_, "FINISH SKETCH") => 1, + _ => 2, + } +} + +pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| { + let r = ui.max_rect(); + // Brand mark: a small solid block drawn in code. + let logo = Rect::from_center_size(pos2(r.left() + 20.0, r.center().y), vec2(20.0, 20.0)); + icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE); + ui.painter().text( + pos2(r.left() + 36.0, r.center().y), + Align2::LEFT_CENTER, + "SolveCraft", + FontId::proportional(14.0), + Color32::from_rgb(230, 234, 242), + ); + let mut x = r.left() + 128.0; + let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool { + let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0)); + x += 30.0; + let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); + } + icons::paint( + ui.painter(), + br.shrink(5.0), + icon, + Color32::from_rgb(225, 230, 238), + Color32::from_rgb(120, 140, 170), + Color32::from_rgb(120, 190, 255), + ); + resp.on_hover_text(tip).clicked() + }; + let file = click(ui, "folder", "File"); + if click(ui, "save", "Save (Ctrl+S)") { + save(app); + } + if click(ui, "undo", "Undo (Ctrl+Z)") { + let _ = app.run("UndoCommand", json!({})); + } + if click(ui, "redo", "Redo (Ctrl+Y)") { + let _ = app.run("RedoCommand", json!({})); + } + if file { + app.ui.palette_open = false; + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true)); + } + // Document tab. + let name = format!("{}{}", app.session.doc.name, if app.session.is_dirty() { " •" } else { "" }); + let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0)); + ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar); + ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text); + ui.painter().text( + pos2(r.right() - 12.0, r.center().y), + Align2::RIGHT_CENTER, + format!("v{}", env!("CARGO_PKG_VERSION")), + FontId::proportional(11.0), + Color32::from_rgb(160, 168, 182), + ); + }); + file_menu(app, ui.ctx()); +} + +fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { + let id = egui::Id::new("sc_file_menu"); + if !ctx.data(|d| d.get_temp::(id).unwrap_or(false)) { + return; + } + let mut close = false; + let resp = egui::Area::new(egui::Id::new("sc_file_area")).fixed_pos(pos2(100.0, 34.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let item = |ui: &mut egui::Ui, label: &str, key: &str| { + ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked() + }; + if item(ui, "New Design", "Ctrl+N") { + let _ = app.run("NewDocumentCommand", json!({})); + app.fit_view(); + close = true; + } + if item(ui, "Open…", "Ctrl+O") { + if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { + app.open_path(&p); + } + close = true; + } + if item(ui, "Save", "Ctrl+S") { + save(app); + close = true; + } + if item(ui, "Save As…", "") { + save_as(app); + close = true; + } + ui.separator(); + if item(ui, "Export STL…", "") { + export(app, "stl"); + close = true; + } + if item(ui, "Export STEP…", "") { + export(app, "step"); + close = true; + } + if item(ui, "Export OBJ…", "") { + export(app, "obj"); + close = true; + } + ui.separator(); + if item(ui, "Quit", "") { + app.quit_requested = true; + close = true; + } + }); + }); + if close || resp.response.clicked_elsewhere() { + ctx.data_mut(|d| d.insert_temp(id, false)); + } +} + +pub fn save(app: &mut SolveApp) { + if app.session.path.is_some() { + let _ = app.run("SaveDocumentCommand", json!({})); + } else { + save_as(app); + } +} + +fn save_as(app: &mut SolveApp) { + let name = format!("{}.{}", app.session.doc.name, solvecraft_engine::io::DESIGN_EXT); + if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solvecraft_engine::io::DESIGN_EXT])) { + let _ = app.run("SaveDocumentAsCommand", json!({"path": p})); + } +} + +fn export(app: &mut SolveApp, ext: &str) { + let name = format!("{}.{ext}", app.session.doc.name); + if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext])) + && app.run("ExportCommand", json!({"path": p})).is_ok() + { + app.set_status(format!("Exported {p}"), false); + } +} + +pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let sketching = app.session.active_sketch.is_some(); + egui::Panel::top("sc_toolbar").exact_size(92.0).frame(egui::Frame::NONE.fill(t.toolbar)).show(ui, |ui| { + let r = ui.max_rect(); + let painter = ui.painter().clone(); + // Workspace selector and tabs. + let tabs_y = r.top() + 2.0; + let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0)); + painter.rect(ws, 4.0, Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text); + caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text); + painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim); + let mut x = r.left() + 112.0; + let mut tabs: Vec<&str> = Vec::new(); + if sketching { + tabs.push("SKETCH"); + } + tabs.extend(TABS.iter()); + for tab in tabs { + let w = 12.0 + tab.len() as f32 * 7.4; + let tr = Rect::from_min_size(pos2(x, tabs_y), vec2(w, 22.0)); + let active = app.ui.tab == tab; + let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click()); + if active { + painter.rect_filled(tr, egui::CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_active); + let c = if tab == "SKETCH" { t.sketch_accent } else { t.accent }; + painter.line_segment([pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, c)); + } else if resp.hovered() { + painter.rect_filled(tr, 4.0, t.hover); + } + let col = if tab == "SKETCH" { + t.sketch_accent + } else if active { + t.text + } else { + t.text_dim + }; + painter.text(tr.center(), Align2::CENTER_CENTER, tab, FontId::proportional(12.0), col); + if resp.clicked() { + app.ui.tab = tab.to_string(); + } + x += w + 4.0; + } + // Panels. + let tab = app.ui.tab.clone(); + let specs = command_specs(); + let mut px = r.left() + 112.0; + let top = r.top() + 26.0; + for panel in panels(&tab) { + let cmds: Vec<_> = specs.iter().filter(|c| c.tab == tab && c.panel == *panel).collect(); + let n = promoted(&tab, panel).min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 }); + let width = (n as f32 * 40.0).max(64.0) + 8.0; + let enabled_panel = !cmds.is_empty(); + for (i, c) in cmds.iter().take(n).enumerate() { + let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0)); + let info = c.info(&app.session); + let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click()); + if resp.hovered() && info.enabled { + painter.rect_filled(br, 4.0, t.hover); + } + let (ink, fill, acc) = if info.enabled { + (t.icon, t.icon_fill, if tab == "SKETCH" { t.sketch_accent } else { t.accent }) + } else { + (t.border, t.panel_header, t.border) + }; + let icon = if c.id == "SketchStop" { "finish" } else { c.icon }; + icons::paint(&painter, br.shrink(6.0), icon, ink, fill, acc); + let tip = match &info.disabled_reason { + Some(why) => format!("{}\n{why}", c.label), + None => format!("{}{}", c.label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()), + }; + if resp.on_hover_text(tip).clicked() && info.enabled { + let id = c.id; + app.start(id); + } + } + if cmds.is_empty() { + let br = Rect::from_min_size(pos2(px + 4.0, top + 2.0), vec2(width - 8.0, 38.0)); + painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border); + } + // Panel label with drop-down. + let lr = Rect::from_min_size(pos2(px, top + 42.0), vec2(width, 18.0)); + let label = panel.to_string(); + let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click()); + if lresp.hovered() && enabled_panel { + painter.rect_filled(lr, 3.0, t.hover); + } + let lc = if *panel == "FINISH SKETCH" { + t.sketch_accent + } else if enabled_panel { + t.text + } else { + t.text_dim + }; + let g = painter.layout_no_wrap(label, FontId::proportional(10.5), lc); + let gx = lr.center().x - (g.size().x + 9.0) / 2.0; + let gy = lr.center().y - g.size().y / 2.0; + let gw = g.size().x; + painter.galley(pos2(gx, gy), g, lc); + caret(&painter, pos2(gx + gw + 6.0, lr.center().y), lc); + let popup_id = ui.id().with(("panel_menu", panel)); + if lresp.clicked() { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id)); + } + let open = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_panel_open"))) == Some(popup_id); + if open { + let area = egui::Area::new(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + if cmds.is_empty() { + ui.label(egui::RichText::new("Not available yet").color(t.text_dim)); + } + for c in &cmds { + let info = c.info(&app.session); + ui.horizontal(|ui| { + let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent); + let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or(""))); + if b.clicked() { + let id = c.id; + app.start(id); + ui.ctx().data_mut(|d| d.remove::(egui::Id::new("sc_panel_open"))); + } + }); + } + }); + }); + if area.response.clicked_elsewhere() && !lresp.clicked() { + ui.ctx().data_mut(|d| d.remove::(egui::Id::new("sc_panel_open"))); + } + } + px += width; + painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border)); + px += 2.0; + } + painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); + }); +} + +/// A small drop-down caret. +fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) { + p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE)); +} + +/// Global keyboard shortcuts (when no text field has focus). +pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() { + return; + } + let (keys, mods) = ctx.input(|i| { + let keys: Vec = + i.events.iter().filter_map(|e| if let egui::Event::Key { key, pressed: true, .. } = e { Some(*key) } else { None }).collect(); + (keys, i.modifiers) + }); + for k in keys { + use egui::Key; + if mods.command { + match k { + Key::Z => drop(app.run("UndoCommand", json!({}))), + Key::Y => drop(app.run("RedoCommand", json!({}))), + Key::S => save(app), + Key::N => { + let _ = app.run("NewDocumentCommand", json!({})); + app.fit_view(); + } + Key::O => { + if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { + app.open_path(&p); + } + } + _ => {} + } + continue; + } + match k { + Key::Escape => { + if app.tool.is_some() { + app.tool = None; + } else if app.dialog.is_some() { + app.dialog = None; + } else { + let _ = app.run("select.clear", json!({})); + } + } + Key::S => app.ui.palette_open = true, + Key::Delete | Key::Backspace => crate::viewport::delete_selection(app), + _ => { + let name = format!("{k:?}"); + if let Some(c) = command_specs().into_iter().find(|c| c.shortcut == Some(name.as_str()) && c.info(&app.session).enabled) { + app.start(c.id); + } + } + } + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs new file mode 100644 index 00000000..be52416b --- /dev/null +++ b/crates/ui-egui/src/tools.rs @@ -0,0 +1,281 @@ +//! Interactive sketch tools: click points on the sketch plane (or pick sketch entities) and the +//! tool runs the matching command. Tools are UI state only; the work is done by commands. + +use egui::{Color32, Pos2, Shape, Stroke}; +use serde_json::{Value, json}; +use solvecraft_engine::geom::Vec2; + +use crate::SolveApp; +use crate::viewport::{Hit, Proj, pick, sketch_point_at}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// Click points; `n` points complete the shape (0 = chain until finished). + Draw(usize), + /// Pick sketch entities; `n` picks run the command. + Pick(usize), + Dimension, +} + +#[derive(Clone, Debug)] +pub struct Tool { + pub cmd: &'static str, + pub kind: Kind, + /// Clicked points (sketch coordinates) and the sketch point they snapped to. + pub pts: Vec<(Vec2, Option)>, + pub hover: Option<(Vec2, Option)>, + /// Picked entity ids. + pub picks: Vec, +} + +impl Tool { + pub fn for_command(id: &str) -> Option { + let (cmd, kind): (&'static str, Kind) = match id { + "DrawPolyline" => ("DrawPolyline", Kind::Draw(0)), + "ShapeRectangleTwoPoint" => ("ShapeRectangleTwoPoint", Kind::Draw(2)), + "ShapeRectangleCenter" => ("ShapeRectangleCenter", Kind::Draw(2)), + "ShapeRectangleThreePoint" => ("ShapeRectangleThreePoint", Kind::Draw(3)), + "CircleCenterRadius" => ("CircleCenterRadius", Kind::Draw(2)), + "CircleTwoPoint" => ("CircleTwoPoint", Kind::Draw(2)), + "CircleThreePoint" => ("CircleThreePoint", Kind::Draw(3)), + "ArcThreePoint" => ("ArcThreePoint", Kind::Draw(3)), + "ArcCenterTwoPoint" => ("ArcCenterTwoPoint", Kind::Draw(3)), + "ShapePolygonInscribed" => ("ShapePolygonInscribed", Kind::Draw(2)), + "ShapePolygonCircumscribed" => ("ShapePolygonCircumscribed", Kind::Draw(2)), + "ShapePolygonEdge" => ("ShapePolygonEdge", Kind::Draw(2)), + "ShapeSlotCenterToCenter" => ("ShapeSlotCenterToCenter", Kind::Draw(3)), + "ShapeSlotOverall" => ("ShapeSlotOverall", Kind::Draw(3)), + "DrawPoint" => ("DrawPoint", Kind::Draw(1)), + "SketchDimension" => ("SketchDimension", Kind::Dimension), + "ConstraintHorizontalVertical" => ("ConstraintHorizontalVertical", Kind::Pick(1)), + "ConstraintFix" => ("ConstraintFix", Kind::Pick(1)), + "ConstraintCoincident" => ("ConstraintCoincident", Kind::Pick(2)), + "ConstraintTangent" => ("ConstraintTangent", Kind::Pick(2)), + "ConstraintEqual" => ("ConstraintEqual", Kind::Pick(2)), + "ConstraintParallel" => ("ConstraintParallel", Kind::Pick(2)), + "ConstraintPerpendicular" => ("ConstraintPerpendicular", Kind::Pick(2)), + "ConstraintConcentric" => ("ConstraintConcentric", Kind::Pick(2)), + "ConstraintCollinear" => ("ConstraintCollinear", Kind::Pick(2)), + "ConstraintMidPoint" => ("ConstraintMidPoint", Kind::Pick(2)), + "ConstraintSymmetry" => ("ConstraintSymmetry", Kind::Pick(3)), + "sketch.construction" => ("sketch.construction", Kind::Pick(1)), + _ => return None, + }; + Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new() }) + } +} + +pub fn hint(id: &str) -> String { + match id { + "DrawPolyline" => "Line: click points; right-click or Esc to finish".into(), + "ShapeRectangleTwoPoint" => "Rectangle: click two opposite corners".into(), + "ShapeRectangleCenter" => "Center rectangle: click the centre, then a corner".into(), + "ShapeRectangleThreePoint" => "3-point rectangle: click two points of one edge, then the width".into(), + "CircleCenterRadius" => "Circle: click the centre, then a point on the circle".into(), + "ArcThreePoint" => "Arc: click start, end, then a point on the arc".into(), + "ArcCenterTwoPoint" => "Arc: click centre, start, then end (counter-clockwise)".into(), + "SketchDimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(), + "ShapeSlotCenterToCenter" | "ShapeSlotOverall" => "Slot: click both ends, then the width".into(), + _ if id.starts_with("Constraint") => "Constraint: pick the sketch entities".into(), + _ => "Click in the sketch".into(), + } +} + +fn arg(p: &(Vec2, Option)) -> Value { + match &p.1 { + Some(id) => json!(id), + None => json!([p.0.x, p.0.y]), + } +} + +fn xy(p: &(Vec2, Option)) -> Value { + json!([p.0.x, p.0.y]) +} + +pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) { + let h = sketch_point_at(app, proj, pos); + if let Some(t) = app.tool.as_mut() { + t.hover = h; + } +} + +pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { + let Some(mut tool) = app.tool.take() else { return }; + match tool.kind { + Kind::Draw(n) => { + if let Some(p) = sketch_point_at(app, proj, pos) { + tool.pts.push(p); + } + if tool.cmd == "DrawPolyline" { + if tool.pts.len() >= 2 { + let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone()); + if let Ok(v) = app.run("DrawPolyline", json!({"points": [arg(&a), arg(&b)], "infer": true})) { + // Chain: the next segment starts at this one's end point. + if let Some(id) = v["curves"].get(0).and_then(Value::as_str) { + let last = tool.pts.len() - 1; + tool.pts[last].1 = Some(format!("{id}.end")); + } + } + } + } else if n > 0 && tool.pts.len() >= n { + run_shape(app, &tool); + tool.pts.clear(); + } + } + Kind::Pick(n) => { + let hits = pick(app, proj, pos); + if let Some(id) = hits.iter().find_map(|h| match h { + Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()), + _ => None, + }) { + tool.picks.push(id); + } + if tool.picks.len() >= n { + run_pick(app, &tool); + tool.picks.clear(); + } + } + Kind::Dimension => { + let hits = pick(app, proj, pos); + let hit = hits.iter().find_map(|h| match h { + Hit::SketchPoint { id, .. } => Some((id.clone(), true)), + Hit::SketchCurve { id, .. } => Some((id.clone(), false)), + _ => None, + }); + if let Some((id, is_point)) = hit { + tool.picks.push(id.clone()); + let st = app.session.model.state(); + let is_line = app + .session + .active_sketch + .and_then(|s| st.sketch(s)) + .and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned())) + .map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })); + let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2; + let single_line = tool.picks.len() == 1 && is_line == Some(true); + if ready || single_line { + // A single line gets its length right away; a second pick (line or point) replaces it. + if let Ok(v) = app.run("SketchDimension", json!({"entities": tool.picks})) + && let Some(p) = v["param"].as_str() + { + app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, p)); + } + tool.picks.clear(); + } + } + } + } + app.tool = Some(tool); +} + +fn run_shape(app: &mut SolveApp, t: &Tool) { + let p = &t.pts; + let d = |a: usize, b: usize| p[a].0.dist(p[b].0); + let params = match t.cmd { + "ShapeRectangleTwoPoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), + "ShapeRectangleCenter" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}), + "ShapeRectangleThreePoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}), + "CircleCenterRadius" => json!({"center": arg(&p[0]), "radius": d(0, 1)}), + "CircleTwoPoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), + "CircleThreePoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}), + "ArcThreePoint" => json!({"start": arg(&p[0]), "end": arg(&p[1]), "through": xy(&p[2])}), + "ArcCenterTwoPoint" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}), + "ShapePolygonInscribed" | "ShapePolygonCircumscribed" => { + let a = (p[1].0 - p[0].0).angle().to_degrees(); + json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": 6, "angle": a}) + } + "ShapePolygonEdge" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "sides": 6}), + "ShapeSlotCenterToCenter" | "ShapeSlotOverall" => { + let dir = (p[1].0 - p[0].0).normalized().unwrap_or(Vec2::X); + let w = 2.0 * dir.cross(p[2].0 - p[0].0).abs(); + json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)}) + } + "DrawPoint" => json!({"point": xy(&p[0])}), + _ => return, + }; + let _ = app.run(t.cmd, params); +} + +fn run_pick(app: &mut SolveApp, t: &Tool) { + let k = &t.picks; + let params = match t.cmd { + "ConstraintHorizontalVertical" => json!({"line": k[0]}), + "ConstraintFix" => json!({"entity": k[0]}), + "sketch.construction" => json!({"curves": [k[0]]}), + "ConstraintCoincident" => json!({"a": k[0], "b": k[1]}), + "ConstraintMidPoint" => json!({"point": k[0], "line": k[1]}), + "ConstraintSymmetry" => json!({"a": k[0], "b": k[1], "line": k[2]}), + _ => json!({"a": k[0], "b": k[1]}), + }; + let _ = app.run(t.cmd, params); +} + +/// Right click / Esc: end the current chain (line) or cancel the tool. +pub fn finish(app: &mut SolveApp) { + if let Some(t) = app.tool.as_mut() + && !t.pts.is_empty() + { + t.pts.clear(); + t.picks.clear(); + return; + } + app.tool = None; +} + +/// Rubber-band preview of the shape being drawn. +pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { + let Some(sid) = app.session.active_sketch else { return }; + let st = app.session.model.state(); + let Some(ss) = st.sketch(sid) else { return }; + let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); + let stroke = Stroke::new(1.5, Color32::from_rgb(30, 120, 230)); + if let Some((hp, snap)) = &t.hover + && let Some(sp) = to(*hp) + { + painter.circle_stroke(sp, if snap.is_some() { 6.0 } else { 3.0 }, stroke); + painter.text( + sp + egui::vec2(10.0, 10.0), + egui::Align2::LEFT_TOP, + format!("{:.2}, {:.2}", hp.x, hp.y), + egui::FontId::proportional(11.0), + Color32::from_rgb(60, 70, 90), + ); + } + let Some((h, _)) = t.hover.clone() else { return }; + let pts: Vec = t.pts.iter().map(|p| p.0).collect(); + let line = |a: Vec2, b: Vec2| { + if let (Some(x), Some(y)) = (to(a), to(b)) { + painter.line_segment([x, y], stroke); + } + }; + let circle = |c: Vec2, r: f64| { + let v: Vec = (0..=64).filter_map(|i| to(c + Vec2::from_angle(i as f64 / 64.0 * std::f64::consts::TAU) * r)).collect(); + painter.add(Shape::line(v, stroke)); + }; + match (t.cmd, pts.as_slice()) { + ("DrawPolyline", [.., last]) => line(*last, h), + ("ShapeRectangleTwoPoint", [a]) => { + let (b, c) = (Vec2::new(h.x, a.y), Vec2::new(a.x, h.y)); + line(*a, b); + line(b, h); + line(h, c); + line(c, *a); + } + ("ShapeRectangleCenter", [c]) => { + let d = h - *c; + let k = + [Vec2::new(c.x - d.x, c.y - d.y), Vec2::new(c.x + d.x, c.y - d.y), Vec2::new(c.x + d.x, c.y + d.y), Vec2::new(c.x - d.x, c.y + d.y)]; + for i in 0..4 { + line(k[i], k[(i + 1) % 4]); + } + } + ("CircleCenterRadius" | "ShapePolygonInscribed" | "ShapePolygonCircumscribed", [c]) => circle(*c, c.dist(h)), + ("CircleTwoPoint", [a]) => circle((*a + h) * 0.5, a.dist(h) * 0.5), + (_, [a]) => line(*a, h), + (_, [a, b]) => { + line(*a, *b); + line(*b, h); + } + _ => {} + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs new file mode 100644 index 00000000..e18b1f83 --- /dev/null +++ b/crates/ui-egui/src/viewport.rs @@ -0,0 +1,821 @@ +//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view +//! cube and the navigation bar. + +use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; +use serde_json::json; +use solvecraft_engine::Sel; +use solvecraft_engine::geom::{Vec2, Vec3}; +use solvecraft_engine::render::{Camera, Mat4, Rgb, StandardView}; +use solvecraft_engine::sketch::{ConstraintKind, CurveKind}; +use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines}; +use std::hash::{Hash, Hasher}; + +use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; +use crate::theme::Tokens; +use crate::{SolveApp, icons}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NavMode { + Orbit, + Pan, + Zoom, +} + +#[derive(Default)] +pub struct ViewportState { + pub rect: Option, + pub gpu: Option, + slot: SceneSlot, + key: u64, + cpu: Option<(u64, egui::TextureHandle)>, + pub nav: Option, + pub hover: Option, + pub mouse: Option, + pub build_ms: f64, +} + +/// Something under the cursor. +#[derive(Clone, Debug, PartialEq)] +pub enum Hit { + SketchPoint { sketch: u64, id: String, at: Vec2 }, + SketchCurve { sketch: u64, id: String }, + Edge { body: String, index: usize, mid: Vec3 }, + Profile { sketch: u64, index: usize }, + Face { body: String, index: usize, point: Vec3 }, +} + +/// World ↔ screen mapping for the current frame. +#[derive(Clone, Copy)] +pub struct Proj { + pub cam: Camera, + pub vp: Mat4, + pub rect: Rect, +} + +impl Proj { + pub fn to_screen(&self, p: Vec3) -> Option { + let c = self.vp.apply(p); + if c[3] <= 1e-9 { + return None; + } + let (x, y) = (c[0] / c[3], c[1] / c[3]); + Some(pos2( + self.rect.left() + ((x + 1.0) * 0.5 * self.rect.width() as f64) as f32, + self.rect.top() + ((1.0 - y) * 0.5 * self.rect.height() as f64) as f32, + )) + } + pub fn ray(&self, p: Pos2) -> (Vec3, Vec3) { + self.cam.ray((p.x - self.rect.left()) as f64, (p.y - self.rect.top()) as f64, self.rect.width() as f64, self.rect.height() as f64) + } +} + +fn rgba(c: Rgb) -> [u8; 4] { + [c.0, c.1, c.2, 255] +} + +pub fn scene_radius(app: &SolveApp) -> f64 { + let b = solvecraft_engine::view::bounds(&app.session); + let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) }; + r.max(app.cam.half_height() * 2.0).max(10.0) +} + +pub fn projection(app: &SolveApp, rect: Rect) -> Proj { + let mut cam = app.cam; + cam.fov = if app.ui.perspective { 0.6 } else { 0.0 }; + let aspect = rect.width() as f64 / rect.height().max(1.0) as f64; + Proj { cam, vp: cam.view_proj(aspect, scene_radius(app)), rect } +} + +/// Sketches drawn in the viewport: the active one, and finished ones not yet used by a feature. +fn visible_sketches(app: &SolveApp) -> Vec { + let st = app.session.model.state(); + st.sketches + .iter() + .filter(|s| app.session.active_sketch == Some(s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature))) + .map(|s| s.feature) + .collect() +} + +fn scene_key(app: &SolveApp) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + app.session.revision.hash(&mut h); + serde_json::to_string(&app.session.selection).unwrap_or_default().hash(&mut h); + serde_json::to_string(&app.ui).unwrap_or_default().hash(&mut h); + app.session.active_sketch.hash(&mut h); + let (minor, _) = grid_step(app.cam.half_height()); + minor.to_bits().hash(&mut h); + ((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h); + ((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h); + if let Some(d) = &app.dialog { + d.highlight_key().hash(&mut h); + } + h.finish() +} + +fn build_scene(app: &SolveApp) -> GpuScene { + let mut sc = GpuScene::default(); + let s = &app.session; + let st = s.model.state(); + if app.ui.show_grid { + let (minor, major) = grid_step(app.cam.half_height()); + let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; + let c = Vec3::new((app.cam.target.x / major).round() * major, (app.cam.target.y / major).round() * major, 0.0); + let n = ((ext / minor) as i64).clamp(1, 300); + for i in -n..=n { + let t = i as f64 * minor; + let maj = (t / major).round() * major == t; + let col = if maj { rgba(colors::GRID_MAJOR) } else { rgba(colors::GRID) }; + let w = if maj { 1.0 } else { 0.7 }; + sc.line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w, false); + sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false); + } + } + if app.ui.show_origin { + let a = app.cam.half_height() * 0.3; + for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { + sc.line([0.0; 3], (d * a).to_f32(), rgba(col), 2.0, false); + } + } + let dialog_edges: Vec = app.dialog.as_ref().map(|d| d.edges()).unwrap_or_default(); + for b in &st.bodies { + if app.ui.hidden_bodies.contains(&b.name) { + continue; + } + let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)); + let col = rgba(if selected { colors::BODY_SELECTED } else { colors::BODY }); + let m = b.mesh(); + for t in &m.triangles { + for k in t { + let i = *k as usize; + if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { + sc.tri(p.to_f32(), n.to_f32(), col); + } + } + } + for (ei, e) in m.edges.iter().enumerate() { + if m.seams.get(ei).copied().unwrap_or(false) { + continue; + } + let sel = s.selection.iter().any(|x| matches!(x, Sel::Edge { body, index, .. } if *body == b.name && *index == ei)) + || dialog_edges.iter().any(|p| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-6 * b.body.size().max(1.0) + 1e-6)); + for w in e.windows(2) { + if sel { + sc.line(w[0].to_f32(), w[1].to_f32(), [30, 120, 230, 255], 3.5, true); + } else { + sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false); + } + } + } + } + let dialog_profiles = app.dialog.as_ref().map(|d| d.profiles()).unwrap_or_default(); + for sid in visible_sketches(app) { + let Some(ss) = st.sketch(sid) else { continue }; + let active = s.active_sketch == Some(sid); + for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { + for w in pts.windows(2) { + sc.line(w[0].to_f32(), w[1].to_f32(), rgba(col), if cons { 1.2 } else { 2.0 }, active); + } + } + // Selected profiles: outline. + for (pi, p) in ss.profiles.iter().enumerate() { + let picked = dialog_profiles.contains(&(sid, pi)) + || s.selection.iter().any(|x| matches!(x, Sel::Profile { sketch, index } if *sketch == sid && *index == pi)); + if !picked { + continue; + } + for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) { + let poly = lp.polyline(0.02); + let n = poly.len(); + for i in 0..n { + if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) { + sc.line(ss.plane.to_world(*a).to_f32(), ss.plane.to_world(*b).to_f32(), rgba(colors::PROFILE), 4.0, true); + } + } + } + } + } + sc +} + +/// Pick what is under `pos`, most specific first. +pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { + let s = &app.session; + let st = s.model.state(); + let mut hits = Vec::new(); + let (o, d) = proj.ray(pos); + // Sketch points and curves of the active sketch. + if let Some(sid) = s.active_sketch + && let Some(ss) = st.sketch(sid) + { + let mut best: Option<(f32, Hit)> = None; + for p in &ss.sketch.points { + if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) { + let dd = sp.distance(pos); + if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) { + best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos })); + } + } + } + if let Some((_, h)) = best { + hits.push(h); + } + let mut bestc: Option<(f32, Hit)> = None; + for (i, c) in ss.sketch.curves.iter().enumerate() { + for seg in ss.sketch.segs(i) { + let pts: Vec = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); + for w in pts.windows(2) { + let dd = seg_dist(pos, w[0], w[1]); + if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) { + bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone() })); + } + } + } + } + if let Some((_, h)) = bestc { + hits.push(h); + } + } + // Edges. + let mut beste: Option<(f32, Hit)> = None; + for b in &st.bodies { + if app.ui.hidden_bodies.contains(&b.name) { + continue; + } + let m = b.mesh(); + for (ei, e) in m.edges.iter().enumerate() { + if m.seams.get(ei).copied().unwrap_or(false) { + continue; + } + let pts: Vec = e.iter().filter_map(|q| proj.to_screen(*q)).collect(); + for w in pts.windows(2) { + let dd = seg_dist(pos, w[0], w[1]); + if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) { + beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) })); + } + } + } + } + // Faces (nearest along the ray), used to reject edges hidden behind faces. + let mut bestf: Option<(f64, Hit)> = None; + for b in &st.bodies { + if app.ui.hidden_bodies.contains(&b.name) { + continue; + } + let m = b.mesh(); + if let Some((t, ti)) = m.raycast(o, d) + && bestf.as_ref().is_none_or(|(bt, _)| t < *bt) + { + let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize; + bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t })); + } + } + if let Some((_, h)) = beste { + // An edge counts only when it is not hidden behind the first face along the ray. + let visible = match (&h, &bestf) { + (Hit::Edge { mid, .. }, Some((t, _))) => (*mid - o).dot(d) <= *t + scene_radius(app) * 0.05, + _ => true, + }; + if visible { + hits.push(h); + } + } + // Profiles of visible sketches. + let mut bestp: Option<(f64, Hit)> = None; + for sid in visible_sketches(app) { + let Some(ss) = st.sketch(sid) else { continue }; + let Some(w) = ss.plane.intersect_ray(o, d) else { continue }; + let lp = ss.plane.to_local(w); + for (pi, p) in ss.profiles.iter().enumerate() { + if p.region.contains(lp) && bestp.as_ref().is_none_or(|(a, _)| p.area < *a) { + bestp = Some((p.area, Hit::Profile { sketch: sid, index: pi })); + } + } + } + if let Some((_, h)) = bestp { + hits.push(h); + } + if let Some((_, h)) = bestf { + hits.push(h); + } + hits +} + +fn polyline_mid(pts: &[Vec3]) -> Vec3 { + let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); + let mut acc = 0.0; + for w in pts.windows(2) { + let l = w[0].dist(w[1]); + if acc + l >= len * 0.5 && l > 0.0 { + return w[0].lerp(w[1], (len * 0.5 - acc) / l); + } + acc += l; + } + pts.first().copied().unwrap_or_default() +} + +fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let l2 = ab.length_sq(); + let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + ab * t) +} + +/// Point on the active sketch plane under the cursor (sketch coordinates), snapped to sketch +/// points within a few pixels and otherwise to a fine grid. Returns the snapped point and the +/// id of the sketch point it snapped to. +pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, Option)> { + let sid = app.session.active_sketch?; + let st = app.session.model.state(); + let ss = st.sketch(sid)?; + for h in pick(app, proj, pos) { + if let Hit::SketchPoint { id, at, .. } = h { + return Some((at, Some(id))); + } + } + let (o, d) = proj.ray(pos); + let w = ss.plane.intersect_ray(o, d)?; + let lp = ss.plane.to_local(w); + let (minor, _) = grid_step(app.cam.half_height()); + let step = minor / 10.0; + Some((Vec2::new((lp.x / step).round() * step, (lp.y / step).round() * step), None)) +} + +pub fn delete_selection(app: &mut SolveApp) { + let sel = app.session.selection.clone(); + if sel.is_empty() { + return; + } + let mut sketch_ids: Vec = Vec::new(); + let mut features: Vec = Vec::new(); + for x in &sel { + match x { + Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()), + Sel::Feature { id } => features.push(*id), + _ => {} + } + } + if !sketch_ids.is_empty() && app.session.active_sketch.is_some() { + let _ = app.run("sketch.delete", json!({"entities": sketch_ids})); + } + if !features.is_empty() { + let _ = app.run("FusionDeleteCommand", json!({"features": features.iter().map(|f| f.to_string()).collect::>()})); + } + let _ = app.run("select.clear", json!({})); +} + +pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let rect = ui.max_rect(); + app.viewport.rect = Some(rect); + let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); + let (hover, scroll, mods, middle, secondary_down, primary_down, 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.delta(), + ) + }); + let inside = hover.is_some_and(|p| rect.contains(p)); + app.viewport.mouse = hover.filter(|_| inside); + + // ---- navigation ---- + let h = rect.height().max(1.0) as f64; + if inside && scroll != 0.0 { + let f = (-scroll as f64 * 0.0015).exp(); + let anchor = hover.map(|p| { + let proj = projection(app, rect); + 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 } + }); + app.cam.zoom_at(f, anchor); + } + let dragging = resp.dragged() || (inside && (middle || secondary_down)); + if dragging && delta != egui::Vec2::ZERO { + let (dx, dy) = (delta.x as f64, delta.y as f64); + let mode = if middle && (mods.shift) || (secondary_down && !middle) { + Some(NavMode::Orbit) + } else if middle { + Some(NavMode::Pan) + } else if primary_down { + app.viewport.nav + } else { + None + }; + match mode { + Some(NavMode::Orbit) => app.cam.orbit(dx, dy), + Some(NavMode::Pan) => app.cam.pan(dx, dy, h), + Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None), + None => {} + } + } + if !app.cam.is_valid() { + app.cam = Camera::default(); + app.fit_view(); + } + + let proj = projection(app, rect); + let painter = ui.painter_at(rect); + // Background gradient. + let top = Color32::from_rgb(colors::BG_TOP.0, colors::BG_TOP.1, colors::BG_TOP.2); + let bot = Color32::from_rgb(colors::BG_BOTTOM.0, colors::BG_BOTTOM.1, colors::BG_BOTTOM.2); + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(rect.left_top(), top); + mesh.colored_vertex(rect.right_top(), top); + mesh.colored_vertex(rect.right_bottom(), bot); + mesh.colored_vertex(rect.left_bottom(), bot); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(Shape::mesh(mesh)); + + // ---- model ---- + let key = scene_key(app); + let t0 = crate::now_ms(); + if let Some(_g) = app.viewport.gpu { + if key != app.viewport.key { + let sc = build_scene(app); + if let Ok(mut slot) = app.viewport.slot.lock() { + *slot = Some(sc); + } + app.viewport.key = key; + app.viewport.build_ms = crate::now_ms() - t0; + } + let ppp = ui.ctx().pixels_per_point(); + let cb = ViewportCallback { + key: app.viewport.key, + slot: app.viewport.slot.clone(), + view_proj: proj.vp.to_f32(), + back: proj.cam.back().to_f32(), + size_px: [rect.width() * ppp, rect.height() * ppp], + }; + painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); + } else { + cpu_render(app, ui.ctx(), &painter, rect, &proj); + } + + // ---- interaction ---- + let hits = hover.filter(|_| inside).map(|p| pick(app, &proj, p)).unwrap_or_default(); + app.viewport.hover = hits.first().cloned(); + if let Some(p) = hover.filter(|_| inside) { + if app.tool.is_some() { + crate::tools::on_hover(app, &proj, p); + } + if resp.clicked() { + if app.tool.is_some() { + crate::tools::on_click(app, &proj, p); + } else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { + if let Some(d) = app.dialog.as_mut() { + d.on_pick(&hits); + } + } else { + select(app, &hits, mods.shift || mods.command); + } + } + if resp.secondary_clicked() && delta == egui::Vec2::ZERO && app.tool.is_some() { + crate::tools::finish(app); + } + } + overlays(app, ui, &painter, &proj); + hover_highlight(app, &painter, &proj); + if let Some(tl) = app.tool.as_ref() { + crate::tools::preview(app, tl, &painter, &proj); + } + view_cube(app, ui, rect); + nav_bar(app, ui, rect); + // Status chip. + if let Some((msg, at, err)) = app.status.clone() { + if crate::now_ms() - at < 6000.0 { + let pos = pos2(rect.left() + 12.0, rect.bottom() - 46.0); + let galley = painter.layout_no_wrap(msg, FontId::proportional(12.5), if err { t.error } else { t.text }); + let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0)); + painter.rect_filled(r, 4.0, Color32::from_white_alpha(230)); + painter.galley(pos + vec2(8.0, 4.0), galley, t.text); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } else { + app.status = None; + } + } +} + +fn select(app: &mut SolveApp, hits: &[Hit], add: bool) { + let item = hits.first().map(|h| match h { + Hit::SketchPoint { id, .. } => json!({"type": "sketch_point", "id": id}), + Hit::SketchCurve { id, .. } => json!({"type": "sketch_curve", "id": id}), + Hit::Edge { body, index, mid } => json!({"type": "edge", "body": body, "index": index, "point": mid}), + Hit::Profile { sketch, index } => json!({"type": "profile", "sketch": sketch, "index": index}), + Hit::Face { body, .. } => json!({"type": "body", "name": body}), + }); + match item { + Some(i) => { + let _ = app.run("select.set", json!({"items": [i], "add": add})); + } + None => { + let _ = app.run("select.clear", json!({})); + } + } +} + +fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + let col = Color32::from_rgb(40, 140, 255); + let st = app.session.model.state(); + match &app.viewport.hover { + Some(Hit::Edge { body, index, .. }) => { + if let Some(b) = st.body(body) + && let Some(e) = b.mesh().edges.get(*index) + { + let pts: Vec = e.iter().filter_map(|q| proj.to_screen(*q)).collect(); + painter.add(Shape::line(pts, Stroke::new(3.0, col))); + } + } + Some(Hit::SketchCurve { sketch, id }) => { + if let Some(ss) = st.sketch(*sketch) + && let Some(ci) = ss.sketch.curve_index(id) + { + for seg in ss.sketch.segs(ci) { + let pts: Vec = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); + painter.add(Shape::line(pts, Stroke::new(3.0, col))); + } + } + } + Some(Hit::SketchPoint { sketch, at, .. }) => { + if let Some(ss) = st.sketch(*sketch) + && let Some(p) = proj.to_screen(ss.plane.to_world(*at)) + { + painter.circle_stroke(p, 6.0, Stroke::new(2.0, col)); + } + } + Some(Hit::Profile { sketch, index }) if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) => { + if let Some(ss) = st.sketch(*sketch) + && let Some(p) = ss.profiles.get(*index) + { + let pts: Vec = p.region.outer.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); + painter.add(Shape::closed_line(pts, Stroke::new(2.0, Color32::from_rgb(255, 170, 60)))); + } + } + _ => {} + } +} + +/// Sketch points, dimension labels and constraint glyphs of the active sketch. +fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { + let t = Tokens::get(); + let st = app.session.model.state(); + let Some(sid) = app.session.active_sketch else { return }; + let Some(ss) = st.sketch(sid) else { return }; + let sk = &ss.sketch; + for (i, p) in sk.points.iter().enumerate() { + let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue }; + let det = ss.report.point_determined.get(i).copied().unwrap_or(false); + let c = if det { Color32::BLACK } else { Color32::from_rgb(30, 90, 200) }; + if i == 0 { + painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, Color32::BLACK)); + } else { + painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c); + } + } + let mut edit: Option = None; + for c in &sk.constraints { + let Some(param) = &c.param else { + glyph(painter, proj, ss, &c.kind); + continue; + }; + let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default(); + let anchor = dim_anchor(sk, &c.kind); + let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { continue }; + let text = if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.' || ch == ' ' || ch == 'm' || ch == 'd' || ch == 'e' || ch == 'g') { + expr.replace(" mm", "").replace(" deg", "°") + } else { + format!("{param}: {}", expr) + }; + let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text); + let r = Rect::from_center_size(a, galley.size() + vec2(8.0, 4.0)); + painter.rect(r, 2.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.galley(r.min + vec2(4.0, 2.0), galley, t.text); + let resp = ui.interact(r, ui.id().with(("dim", param.as_str())), Sense::click()); + if resp.double_clicked() || resp.clicked() && app.tool.is_none() { + edit = Some(param.clone()); + } + } + if let Some(p) = edit { + app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, &p)); + } + // Sketch status. + if let Some(r) = app.viewport.rect { + let msg = if !ss.report.ok() { + format!("{} — constraints conflict", ss.name) + } else if ss.report.dof == 0 { + format!("{} — fully constrained", ss.name) + } else { + format!("{} — {} degrees of freedom", ss.name, ss.report.dof) + }; + let c = if ss.report.ok() { t.sketch_accent } else { t.error }; + painter.text(pos2(r.left() + 12.0, r.top() + 12.0), Align2::LEFT_TOP, msg, FontId::proportional(13.0), c); + } +} + +fn line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, Vec2)> { + match sk.curves.get(l)?.kind { + CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)), + _ => None, + } +} + +/// Where a dimension label goes (sketch coordinates). +fn dim_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option { + use ConstraintKind::*; + match *k { + Length { l, .. } | PointLineDistance { l, .. } => { + let (a, b) = line_mid(sk, l)?; + let n = (b - a).perp().normalized().unwrap_or(Vec2::Y); + Some((a + b) * 0.5 + n * ((b - a).len() * 0.08 + 1.0)) + } + Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5), + Radius { c, .. } | Diameter { c, .. } => { + let r = sk.radius(c)?; + Some(sk.center(c)? + Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2) * r) + } + Angle { a, b, .. } => { + let (a0, a1) = line_mid(sk, a)?; + let (b0, b1) = line_mid(sk, b)?; + Some((a0 + a1 + b0 + b1) * 0.25) + } + _ => None, + } +} + +/// Small constraint markers next to the constrained geometry. +fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) { + use ConstraintKind::*; + let sk = &ss.sketch; + let (anchor, label) = match *k { + Horizontal { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "H"), + Vertical { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "V"), + Parallel { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "//"), + Perpendicular { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "L"), + Tangent { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "T"), + Equal { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "="), + Fix { p } => (sk.point(p), "F"), + _ => (None, ""), + }; + let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { return }; + let r = Rect::from_center_size(a + vec2(10.0, -10.0), vec2(13.0, 13.0)); + painter.rect_filled(r, 2.0, Color32::from_rgb(255, 255, 255)); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(90, 150, 90)), egui::StrokeKind::Inside); + painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), Color32::from_rgb(40, 110, 40)); +} + +fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) { + let mut h = std::collections::hash_map::DefaultHasher::new(); + scene_key(app).hash(&mut h); + for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] { + v.to_bits().hash(&mut h); + } + (rect.width() as u32, rect.height() as u32).hash(&mut h); + let key = h.finish(); + if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) { + let scene = solvecraft_engine::view::scene(&app.session, &proj.cam); + let w = (rect.width() as usize).clamp(16, 4096); + let hh = (rect.height() as usize).clamp(16, 4096); + let c = solvecraft_engine::render::render(&scene, &proj.cam, w, hh); + let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba); + let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR); + app.viewport.cpu = Some((key, tex)); + } + if let Some((_, tex)) = &app.viewport.cpu { + painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } +} + +/// The view cube (top right): click a face for a standard view; the house resets. +fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { + let t = Tokens::get(); + let c = pos2(rect.right() - 80.0, rect.top() + 80.0); + let s = 32.0f64; + let (r, u, b) = app.cam.basis(); + let to2 = |p: Vec3| -> (Pos2, f64) { (pos2(c.x + (p.dot(r) * s) as f32, c.y - (p.dot(u) * s) as f32), p.dot(b)) }; + let faces: [(Vec3, &str, StandardView); 6] = [ + (Vec3::new(0.0, -1.0, 0.0), "FRONT", StandardView::Front), + (Vec3::new(0.0, 1.0, 0.0), "BACK", StandardView::Back), + (Vec3::new(1.0, 0.0, 0.0), "RIGHT", StandardView::Right), + (Vec3::new(-1.0, 0.0, 0.0), "LEFT", StandardView::Left), + (Vec3::Z, "TOP", StandardView::Top), + (Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom), + ]; + let painter = ui.painter_at(rect); + let mut clicked: Option = None; + let hover = ui.input(|i| i.pointer.hover_pos()); + let mut order: Vec = (0..6).collect(); + order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b))); + for i in order { + let (n, label, v) = faces[i]; + if n.dot(b) <= 1e-3 { + continue; + } + let (e1, e2) = if n.z.abs() > 0.5 { + (Vec3::X, Vec3::Y) + } else if n.x.abs() > 0.5 { + (Vec3::Y, Vec3::Z) + } else { + (Vec3::X, Vec3::Z) + }; + let corners = [n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2]; + let pts: Vec = corners.iter().map(|p| to2(*p).0).collect(); + let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts)); + let shade = (0.75 + 0.25 * n.dot(b)) as f32; + let fill = if poly_hover { t.accent_soft } else { Color32::from_gray((236.0 * shade) as u8) }; + painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(140, 148, 160)))); + let center = to2(n).0; + if n.dot(b) > 0.35 { + painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), t.text); + } + if poly_hover && ui.input(|i| i.pointer.primary_clicked()) { + clicked = Some(v); + } + } + // Axis triad at the cube's corner. + let o = Vec3::new(-1.0, -1.0, -1.0); + for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { + let (a, _) = to2(o); + let (bb, _) = to2(o + d * 2.6); + painter.line_segment([a, bb], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); + } + let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0)); + let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click()); + icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { Color32::WHITE }, t.accent); + if hr.on_hover_text("Home view").clicked() { + app.cam.set_view(StandardView::Iso); + app.fit_view(); + } + if let Some(v) = clicked { + app.cam.set_view(v); + } +} + +fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool { + let n = poly.len(); + let mut inside = false; + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x); + if p.x < x { + inside = !inside; + } + } + } + inside +} + +/// Navigation bar (bottom centre). +fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { + let t = Tokens::get(); + let items: [(&str, &str); 7] = [ + ("orbit", "Orbit (drag; or Shift+middle / right drag)"), + ("pan", "Pan (drag; or middle drag)"), + ("zoom", "Zoom (drag; or wheel)"), + ("fit", "Fit"), + ("home", "Home view"), + ("perspective", "Perspective / orthographic"), + ("settings", "Grid on/off"), + ]; + let w = items.len() as f32 * 30.0 + 10.0; + let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)); + let painter = ui.painter_at(rect); + painter.rect(bar, 6.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + for (i, (icon, tip)) in items.iter().enumerate() { + let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), vec2(26.0, 26.0)); + let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click()); + let active = match *icon { + "orbit" => app.viewport.nav == Some(NavMode::Orbit), + "pan" => app.viewport.nav == Some(NavMode::Pan), + "zoom" => app.viewport.nav == Some(NavMode::Zoom), + "perspective" => app.ui.perspective, + "settings" => app.ui.show_grid, + _ => false, + }; + if active || resp.hovered() { + painter.rect_filled(br, 4.0, if active { t.accent_soft } else { t.hover }); + } + icons::paint(&painter, br.shrink(4.0), icon, t.icon, t.icon_fill, t.accent); + if resp.on_hover_text(*tip).clicked() { + let toggle = |m: NavMode, cur: Option| if cur == Some(m) { None } else { Some(m) }; + match *icon { + "orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav), + "pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav), + "zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav), + "fit" => app.fit_view(), + "home" => { + app.cam.set_view(StandardView::Iso); + app.fit_view(); + } + "perspective" => app.ui.perspective = !app.ui.perspective, + _ => app.ui.show_grid = !app.ui.show_grid, + } + } + } +} diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 3628d757..700af3bd 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -3,7 +3,8 @@ use std::path::Path; -const ASSET_EXT: &[&str] = &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"]; +const ASSET_EXT: &[&str] = + &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"]; const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"]; fn walk(dir: &Path, root: &Path, out: &mut Vec) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ef0f6929..e6bcd9a6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -127,7 +127,8 @@ fn cmd_oracle() -> Result<(), String> { cmd.arg(c); } let out = cmd.output().map_err(|e| format!("solvecraft-cli: {e}"))?; - let results: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?; + let results: serde_json::Value = + serde_json::from_slice(&out.stdout).map_err(|e| format!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?; let list = results.as_array().cloned().unwrap_or_default(); let pass = list.iter().filter(|r| r["pass"].as_bool().unwrap_or(false)).count(); let skipped = list.iter().filter(|r| r["skipped"].as_bool().unwrap_or(false)).count(); diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 55b529d0..e22a638c 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -93,7 +93,9 @@ pub fn run(root: &Path, refresh: bool) -> Result<(), String> { let s = std::fs::read_to_string(&mt).map_err(|e| format!("{}: {e}", mt.display()))?; let v: Value = serde_json::from_str(&s).map_err(|e| format!("menu-tree.json: {e}"))?; let entries = from_menu_tree(&v); - let mut out = String::from("# Fusion Design workspace toolbar: tab, panel, command id, command name (ids and names only).\n# Regenerate with `cargo xtask parity --refresh` (needs the local plan/fusion/menu-tree.json).\n"); + let mut out = String::from( + "# Fusion Design workspace toolbar: tab, panel, command id, command name (ids and names only).\n# Regenerate with `cargo xtask parity --refresh` (needs the local plan/fusion/menu-tree.json).\n", + ); for e in &entries { out += &format!("{}\t{}\t{}\t{}\n", e.tab, e.panel, e.id, e.name); } @@ -109,7 +111,7 @@ pub fn run(root: &Path, refresh: bool) -> Result<(), String> { .map_err(|e| format!("solvecraft-cli: {e}"))?; let cmds: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?; let ids: Vec = cmds.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect(); - let live = |e: &Entry| ids.iter().any(|i| *i == e.id); + let live = |e: &Entry| ids.contains(&e.id); let pct = |a: usize, b: usize| if b == 0 { 0.0 } else { 100.0 * a as f64 / b as f64 }; let head: Vec<&Entry> = catalog.iter().filter(|e| HEADLINE.contains(&e.tab.as_str())).collect(); @@ -146,7 +148,12 @@ pub fn run(root: &Path, refresh: bool) -> Result<(), String> { md += &format!("- {} › {} › {} (`{}`)\n", e.tab, e.panel, e.name, e.id); } std::fs::write(root.join("docs/parity.md"), md).map_err(|e| format!("docs/parity.md: {e}"))?; - println!("parity: SOLID+SKETCH {head_live}/{} ({:.0}%), all tabs {all_live}/{} → docs/parity.md", head.len(), pct(head_live, head.len()), catalog.len()); + println!( + "parity: SOLID+SKETCH {head_live}/{} ({:.0}%), all tabs {all_live}/{} → docs/parity.md", + head.len(), + pct(head_live, head.len()), + catalog.len() + ); Ok(()) }