Craft-family shell, native file dialogs, samples and one-click Solve (#46)
* Native file pickers, background opening, drag and drop and recent files Open (models, SolidCraft designs and tagged meshes in one picker), Save, Save As, Write Deck and Recover go through the host's file pickers (rfd in the desktop app, injected as Services so the UI crate stays portable and tests answer them). Files open on a copy of the session in a worker thread and replace it only on success; files dropped on the window or named on the command line open the same way. A recent-files list, an unsaved-changes prompt, Cmd/Ctrl+O/S/Shift+S. Programmatic calls never open a picker: ui.start opens the form. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Samples and one-click Solve: results reachable from the window file.sample {name} opens a ready-to-run sample (the two parts, the cantilever with static and frequency steps, the bar impact) in place of the model; solve.run solves every step in order. Help > Samples, the empty viewport's start screen and the Tools > Samples entry open them (asking first over unsaved changes); Solve runs in the background like a file opening and the Results tab comes up. --sample [parts|cantilever|impact] on the command line. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * The Craft-family shell: title bar, workspace ribbon, browser, context panel, timeline The window now follows SolidCraft's and VectorCraft's layout and themes: - title bar with the mark, quick actions (File menu, save, undo, redo, Solve, search, theme, help), the model's document tab and the version; integrated under the macOS traffic lights, own caption buttons and resize zones elsewhere (titlebar.rs); - workspaces (Prepare, Model, Analysis, Results) with their tabs, and panels of icon buttons whose labelled drop-downs list the whole catalogue, coming-soon entries greyed with their milestone; panels collapse, then go into a » menu, when narrow; - the browser: geometry and mesh with eye toggles, definitions with counts, results; - no permanent Commands list: command search (S, Cmd/Ctrl+K) and a command line; - the context panel on the right only with context: the command form, results controls, the selected definition's properties with Edit and Delete; - navigation bar and orientation gizmo in the viewport; the journal timeline at the bottom with undo positions; - SolidCraft's light and dark tokens; craft-ui (IconButton, Tab, WidgetPalette). Menus and the search are view state (ui.set {menu, palette}), so captures show them. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Shell polish, docs and licences Menus painted as the family's (flat File menu with recent files, decks and samples), the legend sized to its text, two-line panel labels inside the ribbon, glyphs the UI font has, offscreen captures without fade-in, ui.start runs in-window file actions (samples, recover, solve) but never a native picker, ui.select. Control protocol, architecture, ROADMAP, AGENTS and ATTRIBUTION updated; THIRD-PARTY-LICENSES.txt with craft-ui and rfd. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Title bar mark from the application icon; fluid steps and domains in the shell The title bar draws the app icon's bracket (assets/app-icon/icon-small.svg's faces) as SolidCraft draws its mark; the window keeps its title-bar flags after the rebase. solve.run solves fluid steps too (solve.fluid); the browser lists fluid domains. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify: cached per-frame data, one command per timeline roll, workspace setters - Icon textures are kept per pixel size (the shell draws several sizes a frame; the cache used to clear on every size change and rasterise again). - Opening a model or recovering one starts from an empty session instead of copying the current one; the replaced session is freed off the window thread. - Element counts, timeline items, search results and the selected definition's properties are cached until what they show changes; timeline tooltips are built on hover; long lists in properties are summarised. - edit.undo and edit.redo take a count: a timeline click is one command. - solve.run dispatches on the step's procedure (no string round trip) and reports its name. - The workspace and tab change together through UiState::select_tab/select_workspace (ui.set {workspace} alone now sticks); no per-frame syncing. - One busy guard, Kind::verbs, a fit flag for background jobs, ModelApp::home_view, the title bar's actions as an enum, dead code removed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Title bar: Solve and tools at the right end; no traffic-light inset in full screen The owner's feedback on the title bar: Solve, search, theme and help sit at its right end before the version; on macOS the left group (mark, name, quick actions) moves into the traffic lights' room in full screen, where there are none, and back when leaving it. The layout is a pure function (bar_layout) tested for both states, narrow windows and custom caption buttons. ui.fullscreen {on} for agents. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Caps labels in Inter SemiBold egui has no bold weight of its default font, so the ribbon's tabs and panel labels, the workspace switcher and its menu, menu headings, the browser header, the context panel's headers and the Solve label were thin, especially in the light theme. They now use a label font family: Inter SemiBold 4.1 (SIL OFL 1.1, unmodified, fonts/OFL.txt, ATTRIBUTION.md), with egui's fonts as fallback, installed with the theme on the live and offscreen contexts. theme::label_font falls back to the default font until the family is bound (fonts take effect from the next pass), so nothing can panic. Panels widen to their label's longest word. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: regenerate features.md after rebase Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
No files matched your search
@@ -117,7 +117,7 @@ People trust ModelCraft with their models; a crash loses their work. **This outr
|
||||
- `cargo run -p modelcraft-cli -- commands` lists every command with its parameters.
|
||||
- `cargo run -p modelcraft-cli -- run script.json` runs a script (a JSON array of
|
||||
`{"command", "params"}`) and prints the model summary.
|
||||
- `cargo run --release -p modelcraft -- --sample --control PORT` opens the desktop app with the
|
||||
- `cargo run --release -p modelcraft -- --sample [cantilever|impact] --control PORT` opens the desktop app with a
|
||||
sample model and a control channel on `127.0.0.1:PORT` (docs/control-protocol.md). For UI work,
|
||||
look at the result: `ui.capture {path}` (the whole window drawn offscreen; works with the
|
||||
screen locked), `ui.screenshot {path}` (a presented frame) or `ui.render {path}` (the model
|
||||
|
||||
@@ -116,6 +116,21 @@ is committed and listed here with its exports.
|
||||
| assets/icons/solve.frequency.svg, assets/icons/solve.frequency.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.frequency.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.fsi.svg, assets/icons/solve.fsi.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.fsi.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.static.svg, assets/icons/solve.static.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.static.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/solve.run.svg, assets/icons/solve.run.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.run.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/file.sample.svg, assets/icons/file.sample.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.sample.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.eye.svg, assets/icons/ui.eye.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.eye.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.fit.svg, assets/icons/ui.fit.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.fit.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.help.svg, assets/icons/ui.help.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.help.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.home.svg, assets/icons/ui.home.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.home.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.moon.svg, assets/icons/ui.moon.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.moon.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.orbit.svg, assets/icons/ui.orbit.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.orbit.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.pan.svg, assets/icons/ui.pan.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.pan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.perspective.svg, assets/icons/ui.perspective.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.perspective.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.search.svg, assets/icons/ui.search.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.search.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.style.svg, assets/icons/ui.style.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.style.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.sun.svg, assets/icons/ui.sun.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.sun.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.views.svg, assets/icons/ui.views.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.views.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.zoom.svg, assets/icons/ui.zoom.vectorcraft | ModelCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.zoom.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | ModelCraft contributors | Original application icon (an L bracket with a bolt hole under a structured hexahedral mesh, on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | ModelCraft contributors | Original simplified application icon for 16–32 px (no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/01-floor.vectorcraft, assets/app-icon/ModelCraft.icon/Assets/01-floor.svg | ModelCraft contributors | Original isometric floor grid with a radial fade (Default appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
@@ -136,6 +151,7 @@ is committed and listed here with its exports.
|
||||
| assets/app-icon/icon.iconset/icon_256x256@2x.png | ModelCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.iconset/icon_512x512.png | ModelCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.iconset/icon_512x512@2x.png | ModelCraft contributors | sips export (1024 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/fonts/Inter-SemiBold.ttf | The Inter Project Authors | https://github.com/rsms/inter release v4.1 (Inter-4.1.zip, extras/ttf/Inter-SemiBold.ttf), unmodified; licence text in crates/ui-egui/fonts/OFL.txt; the interface's caps labels (theme::LABEL_FAMILY) | SIL OFL 1.1 |
|
||||
|
||||
## Adapted code
|
||||
|
||||
@@ -146,7 +162,7 @@ is committed and listed here with its exports.
|
||||
| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 |
|
||||
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
|
||||
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs, crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (tabs of panels of buttons) | SolidCraft `crates/ui-egui/src/` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
|
||||
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
|
||||
| apps/modelcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -749,6 +749,14 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -858,6 +866,8 @@ 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",
|
||||
]
|
||||
|
||||
@@ -924,7 +934,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"pollster",
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"static_assertions",
|
||||
@@ -1853,7 +1863,9 @@ dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"modelcraft-engine",
|
||||
"modelcraft-io",
|
||||
"modelcraft-ui-egui",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -2006,9 +2018,11 @@ dependencies = [
|
||||
name = "modelcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"log",
|
||||
"modelcraft-decks",
|
||||
"modelcraft-engine",
|
||||
"modelcraft-explicit",
|
||||
"modelcraft-geometry",
|
||||
@@ -2222,6 +2236,7 @@ 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",
|
||||
@@ -2692,6 +2707,12 @@ dependencies = [
|
||||
"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"
|
||||
@@ -2969,6 +2990,33 @@ dependencies = [
|
||||
"usvg",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rgb"
|
||||
version = "0.8.53"
|
||||
|
||||
@@ -48,6 +48,11 @@ resvg = { version = "0.48", default-features = false }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
# Shared Craft-family egui components (icon buttons, tabs, widget palette); the same rev as
|
||||
# VectorCraft. MIT OR Apache-2.0.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675" }
|
||||
# native file pickers (the desktop app only; the UI crate takes them as services)
|
||||
rfd = "0.17"
|
||||
|
||||
# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it
|
||||
# (plan/adr/0001-libraries.md).
|
||||
|
||||
@@ -34,4 +34,4 @@ hex meshes today and it gives the best elements where stresses are read.
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done), MCP server, browser build, command schema as the public API | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done), MCP server, browser build, command schema as the public API | started |
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in ModelCraft
|
||||
|
||||
ModelCraft (the desktop app, modelcraft-cli and the web app) is built from the 441 crates below.
|
||||
ModelCraft (the desktop app, modelcraft-cli and the web app) is built from the 444 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
ModelCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -74,6 +74,7 @@ core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
|
||||
core-graphics 0.23.2 (MIT OR Apache-2.0)
|
||||
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
|
||||
cpufeatures 0.2.17 (MIT OR Apache-2.0)
|
||||
craft-ui 0.1.0 (MIT OR Apache-2.0)
|
||||
crc32fast 1.5.2 (MIT OR Apache-2.0)
|
||||
crossbeam-deque 0.8.8 (MIT OR Apache-2.0)
|
||||
crossbeam-epoch 0.9.21 (MIT OR Apache-2.0)
|
||||
@@ -238,6 +239,7 @@ piper 0.2.5 (MIT OR Apache-2.0)
|
||||
plain 0.2.3 (MIT/Apache-2.0)
|
||||
png 0.18.1 (MIT OR Apache-2.0)
|
||||
polling 3.11.0 (Apache-2.0 OR MIT)
|
||||
pollster 0.4.0 (Apache-2.0/MIT)
|
||||
pollster 1.0.1 (Apache-2.0/MIT)
|
||||
polycool 0.4.0 (MIT OR Apache-2.0)
|
||||
portable-atomic 1.15.0 (Apache-2.0 OR MIT)
|
||||
@@ -270,6 +272,7 @@ redox_syscall 0.5.18 (MIT)
|
||||
redox_syscall 0.9.4 (MIT)
|
||||
renderdoc-sys 1.1.0 (MIT OR Apache-2.0)
|
||||
resvg 0.48.1 (Apache-2.0 OR MIT)
|
||||
rfd 0.17.2 (MIT)
|
||||
rgb 0.8.53 (MIT)
|
||||
robust 1.2.0 (MIT OR Apache-2.0)
|
||||
roxmltree 0.21.1 (MIT OR Apache-2.0)
|
||||
@@ -753,10 +756,10 @@ flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.1
|
||||
khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
|
||||
num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
|
||||
parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png
|
||||
0.18.1, polling 3.11.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust
|
||||
1.2.0, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, scoped-tls 1.0.1,
|
||||
scopeguard 1.2.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, simplecss 0.2.2, smallvec 1.16.3,
|
||||
smol_str 0.2.2, svgtypes 0.16.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1,
|
||||
0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0,
|
||||
renderdoc-sys 1.1.0, robust 1.2.0, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5,
|
||||
scoped-tls 1.0.1, scopeguard 1.2.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, simplecss 0.2.2,
|
||||
smallvec 1.16.3, smol_str 0.2.2, svgtypes 0.16.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1,
|
||||
unicode-segmentation 1.13.3, unicode-width 0.2.2, uuid 1.28.0, wasi 0.11.1+wasi-snapshot-preview1,
|
||||
wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro
|
||||
0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl
|
||||
@@ -8113,7 +8116,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
pollster 1.0.1
|
||||
pollster 0.4.0, pollster 1.0.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2020-2021 Joshua Barretto
|
||||
@@ -9338,6 +9341,32 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
rfd 0.17.2
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
rgb 0.8.53
|
||||
================================================================================
|
||||
@@ -13012,6 +13041,7 @@ block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
|
||||
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
|
||||
clipboard-win 5.4.1: BSL-1.0 (https://github.com/DoumanAsh/clipboard-win)
|
||||
convert_case 0.4.0: MIT (https://github.com/rutrum/convert-case)
|
||||
craft-ui 0.1.0: MIT OR Apache-2.0 (https://github.com/storytold/craft-libs)
|
||||
dispatch 0.2.0: MIT (http://github.com/SSheldon/rust-dispatch)
|
||||
dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2)
|
||||
ecolor 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
|
||||
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
[dependencies]
|
||||
modelcraft-engine = { workspace = true }
|
||||
modelcraft-ui-egui = { workspace = true }
|
||||
modelcraft-io = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! ModelCraft desktop app.
|
||||
//!
|
||||
//! Usage: `modelcraft [--sample] [--control PORT]`
|
||||
//! Usage: `modelcraft [--sample [NAME]] [--control PORT] [FILE…]` (files open as if dropped on the
|
||||
//! window; NAME is a sample of `modelcraft_engine::sample`, `parts` by default)
|
||||
//!
|
||||
//! `--sample` opens a sample model (`modelcraft_engine::sample`). `--control PORT` (or
|
||||
//! `MODELCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
@@ -14,10 +15,12 @@ mod control_server;
|
||||
|
||||
use modelcraft_engine::Session;
|
||||
use modelcraft_engine::autosave::{self, Autosaver};
|
||||
use modelcraft_ui_egui::ModelApp;
|
||||
use modelcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
|
||||
const USAGE: &str = "usage: modelcraft [--sample] [--control PORT]";
|
||||
const USAGE: &str = "usage: modelcraft [--sample [parts|cantilever|impact]] [--control PORT] [FILE…] (FILE: .modelcraft, .solidcraft or .3mf)";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// The window draws its own caption buttons where the OS ones would sit apart from the app bar.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
/// The window and Dock icon: the 256 px export of the application icon (assets/app-icon).
|
||||
@@ -78,19 +81,42 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native file pickers and the recent-files file, for the UI.
|
||||
fn services() -> Services {
|
||||
let dialog = |filters: &[Filter]| filters.iter().fold(rfd::FileDialog::new(), |d, f| d.add_filter(f.name, f.extensions));
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |filters: &[Filter]| dialog(filters).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_save: Some(Box::new(move |name: &str, filters: &[Filter]| {
|
||||
dialog(filters).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// Beside the recovery folder in the per-user data folder.
|
||||
recent_path: modelcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-line options.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
sample: bool,
|
||||
/// The sample to open (`--sample [NAME]`).
|
||||
sample: Option<String>,
|
||||
control: Option<u16>,
|
||||
/// Files to open (models, designs, tagged meshes).
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Result<Option<Args>, String> {
|
||||
let mut out = Args { control: env_port.and_then(|p| p.trim().parse().ok()), ..Default::default() };
|
||||
let mut args = args.into_iter();
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--sample" => out.sample = true,
|
||||
"--sample" => {
|
||||
// An optional sample name follows.
|
||||
let name = args.peek().filter(|a| modelcraft_engine::sample::find(a).is_some()).cloned();
|
||||
if name.is_some() {
|
||||
args.next();
|
||||
}
|
||||
out.sample = Some(name.unwrap_or_else(|| "parts".into()));
|
||||
}
|
||||
"--control" => {
|
||||
let port = args.next().ok_or("--control needs a port")?;
|
||||
out.control = Some(port.parse().map_err(|_| format!("--control: `{port}` is not a port number"))?);
|
||||
@@ -103,7 +129,8 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
||||
println!("{USAGE}");
|
||||
return Ok(None);
|
||||
}
|
||||
other => return Err(format!("unknown argument `{other}`\n{USAGE}")),
|
||||
other if other.starts_with('-') => return Err(format!("unknown argument `{other}`\n{USAGE}")),
|
||||
file => out.files.push(file.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
@@ -123,7 +150,14 @@ fn main() -> eframe::Result {
|
||||
.with_title("ModelCraft")
|
||||
.with_icon(app_icon())
|
||||
.with_inner_size([1400.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0]),
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
// The app bar is the title bar (as in the other Craft apps): on macOS under the
|
||||
// traffic lights, elsewhere with its own caption buttons and resize zones.
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
multisampling: MSAA,
|
||||
..Default::default()
|
||||
@@ -132,7 +166,9 @@ fn main() -> eframe::Result {
|
||||
"ModelCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
eprintln!("modelcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
|
||||
@@ -141,15 +177,17 @@ fn main() -> eframe::Result {
|
||||
if let Some(port) = args.control {
|
||||
app = app.with_control(control_server::start(port, cc.egui_ctx.clone()));
|
||||
}
|
||||
if args.sample {
|
||||
// The status bar reports a failure; the app still opens.
|
||||
let _ = app.run_script(&modelcraft_engine::sample::script());
|
||||
if let Some(name) = &args.sample {
|
||||
app.open_sample(name);
|
||||
}
|
||||
for f in &args.files {
|
||||
app.open_path(f);
|
||||
}
|
||||
let autosaver = autosave::default_dir().and_then(|dir| {
|
||||
let listed = app.session.execute("file.recovery.list", &serde_json::json!({"dir": dir}));
|
||||
let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0);
|
||||
if recoverable > 0 {
|
||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered (file.recovery.list, file.recover)"), false);
|
||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
||||
}
|
||||
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
|
||||
});
|
||||
@@ -176,12 +214,19 @@ mod tests {
|
||||
#[test]
|
||||
fn arguments() {
|
||||
assert_eq!(parse(&[], None), Ok(Some(Args::default())));
|
||||
assert_eq!(parse(&["--sample", "--control", "7070"], None), Ok(Some(Args { sample: true, control: Some(7070) })));
|
||||
assert_eq!(parse(&[], Some("9000")), Ok(Some(Args { sample: false, control: Some(9000) })));
|
||||
assert_eq!(
|
||||
parse(&["--sample", "--control", "7070"], None),
|
||||
Ok(Some(Args { sample: Some("parts".into()), control: Some(7070), files: vec![] }))
|
||||
);
|
||||
assert_eq!(parse(&[], Some("9000")), Ok(Some(Args { control: Some(9000), ..Default::default() })));
|
||||
assert_eq!(parse(&[], Some("nope")), Ok(Some(Args::default())));
|
||||
assert!(parse(&["--control"], None).is_err());
|
||||
assert!(parse(&["--control", "99999"], None).is_err());
|
||||
assert!(parse(&["--frobnicate"], None).is_err());
|
||||
assert_eq!(parse(&["--sample", "impact"], None).map(|a| a.and_then(|a| a.sample)), Ok(Some("impact".into())));
|
||||
let a = parse(&["--sample", "x.modelcraft"], None).unwrap().unwrap();
|
||||
assert_eq!((a.sample.as_deref(), a.files), (Some("parts"), vec!["x.modelcraft".to_string()]));
|
||||
assert_eq!(parse(&["a.modelcraft", "--sample"], None).map(|a| a.map(|a| a.files)), Ok(Some(vec!["a.modelcraft".to_string()])));
|
||||
assert_eq!(parse(&["--help"], None), Ok(None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>file.sample</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 10 L11 6 L19 10 L19 18 L11 22 L3 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 10 L11 14 L19 10 M11 14 L11 22" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.5 1.5 L19.44 4.206 L22.304 4.264 L20.022 5.994 L20.851 8.736 L18.5 7.1 L16.149 8.736 L16.978 5.994 L14.696 4.264 L17.56 4.206 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 775 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>solve.run</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 12 C3 7.029 7.029 3 12 3 C16.971 3 21 7.029 21 12 C21 16.971 16.971 21 12 21 C7.029 21 3 16.971 3 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 7.8 L16.4 12 L10 16.2 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 609 B |
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 10 L11 6 L19 10 L19 18 L11 22 L3 18 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 10 L11 14 L19 10 M11 14 L11 22", "stroke": "line"}, {"star": [18.5, 5.5, 4, 1.6, 4], "fill": "warm", "stroke": "warm", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [12, 12, 9], "fill": "soft", "stroke": "line"}, {"d": "M10 7.8 L16.4 12 L10 16.2 Z", "fill": "accent", "stroke": "accent", "width": 1.25}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M2.5 12 C5.5 6.5 18.5 6.5 21.5 12 C18.5 17.5 5.5 17.5 2.5 12 Z", "fill": "soft", "stroke": "line"}, {"circle": [12, 12, 3], "fill": "accent", "stroke": "accent", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3.5 8 L3.5 3.5 L8 3.5 M16 3.5 L20.5 3.5 L20.5 8 M20.5 16 L20.5 20.5 L16 20.5 M8 20.5 L3.5 20.5 L3.5 16", "stroke": "line"}, {"rect": [8, 8, 8, 8], "radius": 1, "fill": "soft", "stroke": "accent"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [12, 12, 9], "stroke": "line"}, {"d": "M9.4 9.6 C9.4 6.9 14.6 6.9 14.6 9.9 C14.6 12 12 12.1 12 14.3", "stroke": "accent", "width": 1.9}, {"circle": [12, 17.3, 0.7], "fill": "accent", "stroke": "accent", "width": 1.25}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M5.5 10 L5.5 20 L18.5 20 L18.5 10", "fill": "soft", "stroke": "line"}, {"d": "M3 11.5 L12 4 L21 11.5", "stroke": "line"}, {"rect": [10.25, 14, 3.5, 6], "fill": "accent", "stroke": "accent", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M15 3.5 C9 4 5.5 9 6.5 14 C7.5 19 13 21.5 18 19.5 C13.5 18.5 11 15 11 11 C11 7.5 12.5 5 15 3.5 Z", "fill": "soft", "stroke": "line"}, {"star": [18.5, 7.5, 2.4, 1, 4], "fill": "warm", "stroke": "warm", "width": 0.75}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 12 C3 8.2 21 8.2 21 12 C21 15.8 3 15.8 3 12 Z", "stroke": "line"}, {"d": "M16.5 7.6 L19.2 9.4 L16.4 10.8", "stroke": "accent", "width": 1.5}, {"circle": [12, 12, 3], "fill": "accent", "stroke": "accent", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M12 4 L12 20 M4 12 L20 12", "stroke": "line"}, {"d": "M9.5 6.5 L12 4 L14.5 6.5 M9.5 17.5 L12 20 L14.5 17.5 M6.5 9.5 L4 12 L6.5 14.5 M17.5 9.5 L20 12 L17.5 14.5", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 19.5 L21 19.5 L16 6 L8 6 Z", "fill": "soft", "stroke": "line"}, {"d": "M12 19.5 L12 6 M5.6 12.5 L18.4 12.5", "stroke": "accent", "width": 1.25, "dash": [1.5, 1.5]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [10.5, 10.5, 6.5], "stroke": "line"}, {"line": [15.3, 15.3, 20.5, 20.5], "stroke": "accent", "width": 2.25}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M12 3 L20 7.5 L20 16.5 L12 21 L4 16.5 L4 7.5 Z", "fill": "soft", "stroke": "line"}, {"d": "M12 12 L20 7.5 L20 16.5 L12 21 Z", "fill": "accent", "stroke": "line"}, {"d": "M4 7.5 L12 12 L12 21", "stroke": "line"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [12, 12, 4], "fill": "warm", "stroke": "warm"}, {"d": "M12 2.5 L12 4.5 M12 19.5 L12 21.5 M2.5 12 L4.5 12 M19.5 12 L21.5 12 M5.3 5.3 L6.7 6.7 M17.3 17.3 L18.7 18.7 M5.3 18.7 L6.7 17.3 M17.3 6.7 L18.7 5.3", "stroke": "line"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M12 3 L20 7.5 L20 16.5 L12 21 L4 16.5 L4 7.5 Z", "stroke": "line"}, {"d": "M4 7.5 L12 12 L20 7.5 M12 12 L12 21", "stroke": "line"}, {"d": "M12 3 L20 7.5 L12 12 L4 7.5 Z", "fill": "accent", "stroke": "line"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [10.5, 10.5, 6.5], "fill": "soft", "stroke": "line"}, {"line": [15.3, 15.3, 20.5, 20.5], "stroke": "line", "width": 2.25}, {"d": "M10.5 7.5 L10.5 13.5 M7.5 10.5 L13.5 10.5", "stroke": "accent", "width": 1.5}]}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.eye</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M2.5 12 C5.5 6.5 18.5 6.5 21.5 12 C18.5 17.5 5.5 17.5 2.5 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 12 C9 10.343 10.343 9 12 9 C13.657 9 15 10.343 15 12 C15 13.657 13.657 15 12 15 C10.343 15 9 13.657 9 12 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.fit</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3.5 8 L3.5 3.5 L8 3.5 M16 3.5 L20.5 3.5 L20.5 8 M20.5 16 L20.5 20.5 L16 20.5 M8 20.5 L3.5 20.5 L3.5 16" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 8 L15 8 C15.552 8 16 8.448 16 9 L16 15 C16 15.552 15.552 16 15 16 L9 16 C8.448 16 8 15.552 8 15 L8 9 C8 8.448 8.448 8 9 8 Z" fill="#2d4e70" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 699 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.help</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 12 C3 7.029 7.029 3 12 3 C16.971 3 21 7.029 21 12 C21 16.971 16.971 21 12 21 C7.029 21 3 16.971 3 12 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.4 9.6 C9.4 6.9 14.6 6.9 14.6 9.9 C14.6 12 12 12.1 12 14.3" fill="none" stroke="#4f9ce8" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.3 17.3 C11.3 16.913 11.613 16.6 12 16.6 C12.387 16.6 12.7 16.913 12.7 17.3 C12.7 17.687 12.387 18 12 18 C11.613 18 11.3 17.687 11.3 17.3 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 891 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.home</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M5.5 10 L5.5 20 L18.5 20 L18.5 10" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 11.5 L12 4 L21 11.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.25 14 L13.75 14 L13.75 20 L10.25 20 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.moon</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M15 3.5 C9 4 5.5 9 6.5 14 C7.5 19 13 21.5 18 19.5 C13.5 18.5 11 15 11 11 C11 7.5 12.5 5 15 3.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.5 5.1 L19.088 6.691 L20.783 6.758 L19.451 7.809 L19.911 9.442 L18.5 8.5 L17.089 9.442 L17.549 7.809 L16.217 6.758 L17.912 6.691 Z" fill="#f0b44a" stroke="#f0b44a" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 704 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.orbit</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 12 C3 8.2 21 8.2 21 12 C21 15.8 3 15.8 3 12 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 7.6 L19.2 9.4 L16.4 10.8" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 12 C9 10.343 10.343 9 12 9 C13.657 9 15 10.343 15 12 C15 13.657 13.657 15 12 15 C10.343 15 9 13.657 9 12 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 752 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.pan</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 4 L12 20 M4 12 L20 12" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 6.5 L12 4 L14.5 6.5 M9.5 17.5 L12 20 L14.5 17.5 M6.5 9.5 L4 12 L6.5 14.5 M17.5 9.5 L20 12 L17.5 14.5" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.perspective</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 19.5 L21 19.5 L16 6 L8 6 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 19.5 L12 6 M5.6 12.5 L18.4 12.5" fill="none" stroke="#4f9ce8" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 570 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.search</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M4 10.5 C4 6.91 6.91 4 10.5 4 C14.09 4 17 6.91 17 10.5 C17 14.09 14.09 17 10.5 17 C6.91 17 4 14.09 4 10.5 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.3 15.3 L20.5 20.5" fill="none" stroke="#4f9ce8" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.style</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 3 L20 7.5 L20 16.5 L12 21 L4 16.5 L4 7.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 12 L20 7.5 L20 16.5 L12 21 Z" fill="#4f9ce8" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7.5 L12 12 L12 21" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 685 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.sun</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M8 12 C8 9.791 9.791 8 12 8 C14.209 8 16 9.791 16 12 C16 14.209 14.209 16 12 16 C9.791 16 8 14.209 8 12 Z" fill="#f0b44a" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 2.5 L12 4.5 M12 19.5 L12 21.5 M2.5 12 L4.5 12 M19.5 12 L21.5 12 M5.3 5.3 L6.7 6.7 M17.3 17.3 L18.7 18.7 M5.3 18.7 L6.7 17.3 M17.3 6.7 L18.7 5.3" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 722 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.views</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 3 L20 7.5 L20 16.5 L12 21 L4 16.5 L4 7.5 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7.5 L12 12 L20 7.5 M12 12 L12 21" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 3 L20 7.5 L12 12 L4 7.5 Z" fill="#4f9ce8" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 694 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>ui.zoom</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M4 10.5 C4 6.91 6.91 4 10.5 4 C14.09 4 17 6.91 17 10.5 C17 14.09 14.09 17 10.5 17 C6.91 17 4 14.09 4 10.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.3 15.3 L20.5 20.5" fill="none" stroke="#dde2ea" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.5 7.5 L10.5 13.5 M7.5 10.5 L13.5 10.5" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 753 B |
@@ -35,6 +35,22 @@ impl Tab {
|
||||
pub const ALL: [Tab; 10] =
|
||||
[Tab::Geometry, Tab::Mesh, Tab::Properties, Tab::Assembly, Tab::Steps, Tab::Loads, Tab::Interactions, Tab::Solve, Tab::Results, Tab::Tools];
|
||||
|
||||
/// Its id in JSON (`loads`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Tab::Geometry => "geometry",
|
||||
Tab::Mesh => "mesh",
|
||||
Tab::Properties => "properties",
|
||||
Tab::Assembly => "assembly",
|
||||
Tab::Steps => "steps",
|
||||
Tab::Loads => "loads",
|
||||
Tab::Interactions => "interactions",
|
||||
Tab::Solve => "solve",
|
||||
Tab::Results => "results",
|
||||
Tab::Tools => "tools",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Tab::Geometry => "Geometry",
|
||||
@@ -51,6 +67,70 @@ impl Tab {
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspaces: one per stage of an analysis, each a set of toolbar tabs. Tools is on every
|
||||
/// workspace.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Workspace {
|
||||
/// Geometry and meshing.
|
||||
#[default]
|
||||
Prepare,
|
||||
/// Materials, sections, sets, steps, loads, boundary conditions and interactions.
|
||||
Model,
|
||||
/// Checking, writing decks and solving.
|
||||
Analysis,
|
||||
/// Looking at results.
|
||||
Results,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub const ALL: [Workspace; 4] = [Workspace::Prepare, Workspace::Model, Workspace::Analysis, Workspace::Results];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Workspace::Prepare => "Prepare",
|
||||
Workspace::Model => "Model",
|
||||
Workspace::Analysis => "Analysis",
|
||||
Workspace::Results => "Results",
|
||||
}
|
||||
}
|
||||
|
||||
/// One line: what it is for.
|
||||
pub fn about(self) -> &'static str {
|
||||
match self {
|
||||
Workspace::Prepare => "Open geometry and mesh it",
|
||||
Workspace::Model => "Materials, sections, steps, loads and boundary conditions",
|
||||
Workspace::Analysis => "Check the model, write decks and solve",
|
||||
Workspace::Results => "Deformed shapes, contours, modes and probes",
|
||||
}
|
||||
}
|
||||
|
||||
/// Its tabs, in order.
|
||||
pub fn tabs(self) -> &'static [Tab] {
|
||||
match self {
|
||||
Workspace::Prepare => &[Tab::Geometry, Tab::Mesh, Tab::Tools],
|
||||
Workspace::Model => &[Tab::Properties, Tab::Assembly, Tab::Steps, Tab::Loads, Tab::Interactions, Tab::Tools],
|
||||
Workspace::Analysis => &[Tab::Solve, Tab::Tools],
|
||||
Workspace::Results => &[Tab::Results, Tab::Tools],
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace that shows `tab`: `current` when it does, else the first that does.
|
||||
pub fn showing(tab: Tab, current: Workspace) -> Workspace {
|
||||
if current.tabs().contains(&tab) { current } else { Workspace::ALL.into_iter().find(|w| w.tabs().contains(&tab)).unwrap_or(current) }
|
||||
}
|
||||
|
||||
/// The catalogue entry whose icon stands for the workspace.
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Workspace::Prepare => "mesh.tet.generate",
|
||||
Workspace::Model => "model.material.create",
|
||||
Workspace::Analysis => "solve.run",
|
||||
Workspace::Results => "results.contour",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an entry runs today.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
@@ -332,6 +412,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("interaction.connector", "Connector", Interactions, "Connectors", "M7", "Connector elements between points"),
|
||||
soon("interaction.fsi", "FSI Interface", Interactions, "Coupled", "M13", "The fluid–structure interface"),
|
||||
// ---- Solve ----
|
||||
ready("solve.run", "Solve", Solve, "Run", "Solve every step in order and show the results"),
|
||||
ready("model.check", "Check Model", Solve, "Check", "Name every missing or bad definition before writing or solving"),
|
||||
deck("deck.write.abaqus", "Abaqus Input", "abaqus", "Write an Abaqus input file (.inp)"),
|
||||
deck("deck.write.nastran", "Nastran Deck", "nastran", "Write Nastran bulk data (SOL 101, 103)"),
|
||||
@@ -381,6 +462,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
// ---- Tools ----
|
||||
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
|
||||
ready("file.open", "Open Model", Tools, "File", "Open a .modelcraft model"),
|
||||
ready("file.sample", "Samples", Tools, "File", "Open a sample model, ready to solve"),
|
||||
ready("file.save", "Save", Tools, "File", "Save the model to its .modelcraft file"),
|
||||
ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .modelcraft file"),
|
||||
ready_more(
|
||||
@@ -400,6 +482,12 @@ pub fn find(id: &str) -> Option<&'static Capability> {
|
||||
CATALOGUE.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The entry that claims a command: the one running it, else the one listing it among its
|
||||
/// related commands. Every registered command has one (a test checks it).
|
||||
pub fn entry_of(command: &str) -> Option<&'static Capability> {
|
||||
CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command)))
|
||||
}
|
||||
|
||||
/// The entries of a tab (one run of the catalogue).
|
||||
pub fn tab_entries(tab: Tab) -> &'static [Capability] {
|
||||
let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len());
|
||||
@@ -442,6 +530,24 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{commands, find_command};
|
||||
|
||||
#[test]
|
||||
fn every_tab_is_on_a_workspace_and_every_command_has_an_entry() {
|
||||
for tab in Tab::ALL {
|
||||
assert!(Workspace::ALL.iter().any(|w| w.tabs().contains(&tab)), "{tab:?} is on no workspace");
|
||||
}
|
||||
assert!(Workspace::ALL.iter().all(|w| w.tabs().contains(&Tab::Tools)));
|
||||
assert!(Workspace::ALL.iter().all(|w| find(w.icon()).is_some()));
|
||||
assert_eq!(Workspace::showing(Tab::Results, Workspace::Prepare), Workspace::Results);
|
||||
assert_eq!(Workspace::showing(Tab::Tools, Workspace::Model), Workspace::Model);
|
||||
for c in commands() {
|
||||
assert!(entry_of(c.id).is_some(), "{} has no catalogue entry", c.id);
|
||||
}
|
||||
assert_eq!(entry_of("model.material.edit").map(|c| c.id), Some("model.material.create"));
|
||||
for tab in Tab::ALL {
|
||||
assert_eq!(serde_json::to_value(tab).ok().and_then(|v| v.as_str().map(str::to_string)).as_deref(), Some(tab.id()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_is_claimed_and_every_ready_entry_runs_a_command() {
|
||||
let mut claimed: Vec<&str> = Vec::new();
|
||||
|
||||
@@ -1,19 +1,72 @@
|
||||
//! Undo and redo ([`crate::history`]).
|
||||
|
||||
use crate::{CommandKind, CommandSpec};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// Most steps one undo or redo takes (the undo history holds at most this many).
|
||||
const MAX_COUNT: u64 = crate::history::MAX_UNDO_STEPS as u64;
|
||||
|
||||
/// `count` steps (default 1), each a whole number in 1..=MAX_COUNT.
|
||||
fn count(p: &Value) -> Result<usize> {
|
||||
match p.get("count") {
|
||||
None | Some(Value::Null) => Ok(1),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| (1..=MAX_COUNT).contains(n))
|
||||
.map(|n| n as usize)
|
||||
.ok_or_else(|| EngineError::Params(format!("`count` must be a whole number from 1 to {MAX_COUNT}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `step` `count` times; the reply is the last one's (an error when not even one could).
|
||||
fn repeat(s: &mut Session, p: &Value, step: fn(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let n = count(p)?;
|
||||
let mut reply = step(s)?;
|
||||
for _ in 1..n {
|
||||
match step(s) {
|
||||
Ok(r) => reply = r,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
pub const UNDO: CommandSpec = CommandSpec {
|
||||
id: "edit.undo",
|
||||
label: "Undo",
|
||||
params: "{} — takes back the last command that changed the model (it leaves the journal and can be redone)",
|
||||
params: r#"{"count"?: 1} — takes back the last command that changed the model (it leaves the journal and can be redone); `count` takes back that many, as far as the history reaches"#,
|
||||
kind: CommandKind::Session,
|
||||
run: |s, _| s.undo(),
|
||||
run: |s, p| repeat(s, p, Session::undo),
|
||||
};
|
||||
|
||||
pub const REDO: CommandSpec = CommandSpec {
|
||||
id: "edit.redo",
|
||||
label: "Redo",
|
||||
params: "{} — runs the last undone command's change again",
|
||||
params: r#"{"count"?: 1} — runs the last undone command's change again; `count` redoes that many, as far as there are undone commands"#,
|
||||
kind: CommandKind::Session,
|
||||
run: |s, _| s.redo(),
|
||||
run: |s, p| repeat(s, p, Session::redo),
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_take_a_count_and_stop_at_the_ends() {
|
||||
let mut s = Session::default();
|
||||
for l in 1..=4 {
|
||||
s.execute("mesh.node.add", &json!({"label": l, "xyz": [l, 0, 0]})).unwrap();
|
||||
}
|
||||
s.execute("edit.undo", &json!({"count": 3})).unwrap();
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
s.execute("edit.redo", &json!({"count": 100})).unwrap();
|
||||
assert_eq!(s.journal.len(), 4, "as far as there are undone commands");
|
||||
for bad in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1e9}), json!({"count": "x"}), json!({"count": 2.5})] {
|
||||
assert!(s.execute("edit.undo", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.journal.len(), 4);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,28 @@ pub const RECOVERY_DISCARD: CommandSpec = CommandSpec {
|
||||
run: recovery_discard,
|
||||
};
|
||||
|
||||
pub const SAMPLE: CommandSpec = CommandSpec {
|
||||
id: "file.sample",
|
||||
label: "Open Sample",
|
||||
params: r#"{"name": "parts|cantilever|impact"} — opens a sample model (modelcraft_engine::sample) in place of the current one, unsaved changes discarded; `cantilever` and `impact` are ready to solve (solve.run)"#,
|
||||
kind: CommandKind::Session,
|
||||
run: open_sample,
|
||||
};
|
||||
|
||||
fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name: &str = req(p, "name")?;
|
||||
let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::<Vec<_>>().join(", ");
|
||||
let sample = crate::sample::find(name).ok_or_else(|| EngineError::Params(format!("no sample `{name}` (samples: {})", names())))?;
|
||||
let mut opened = Session::default();
|
||||
opened.run_script(&(sample.script)())?;
|
||||
// A sample just opened has nothing to save: it opens again the same way.
|
||||
opened.saved_revision = Some(opened.revision);
|
||||
s.replace_with(opened);
|
||||
let mut reply = summary(s, &[]);
|
||||
reply["sample"] = json!(sample.name);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working folder (as given where there is none).
|
||||
pub(crate) fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
|
||||
|
||||
@@ -83,9 +83,11 @@ pub static ALL: &[CommandSpec] = &[
|
||||
file::RECOVERY_LIST,
|
||||
file::RECOVER,
|
||||
file::RECOVERY_DISCARD,
|
||||
file::SAMPLE,
|
||||
edit::UNDO,
|
||||
edit::REDO,
|
||||
deck::WRITE,
|
||||
solve::RUN,
|
||||
solve::STATIC,
|
||||
solve::FREQUENCY,
|
||||
solve::EXPLICIT,
|
||||
|
||||
@@ -11,6 +11,14 @@ use serde_json::{Value, json};
|
||||
use super::{labels, opt, points, req};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
|
||||
|
||||
pub const RUN: CommandSpec = CommandSpec {
|
||||
id: "solve.run",
|
||||
label: "Solve All Steps",
|
||||
params: r#"{} — solves every step in order with its procedure's solver (solve.static, solve.frequency, solve.explicit, solve.fluid with their default caps) and keeps the results; stops at the first step that fails, naming it"#,
|
||||
kind: CommandKind::Model,
|
||||
run: run_all,
|
||||
};
|
||||
|
||||
pub const STATIC: CommandSpec = CommandSpec {
|
||||
id: "solve.static",
|
||||
label: "Solve Static Step",
|
||||
@@ -96,6 +104,34 @@ fn largest(values: impl Iterator<Item = (u32, f64)>) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
use modelcraft_model::Procedure;
|
||||
type Solve = fn(&mut Session, &Value) -> Result<Value>;
|
||||
let steps: Vec<(String, &'static str, Solve)> = s
|
||||
.model
|
||||
.steps()
|
||||
.iter()
|
||||
.map(|st| {
|
||||
let solve: Solve = match st.procedure {
|
||||
Procedure::Static {} => run_static,
|
||||
Procedure::Frequency { .. } => run_frequency,
|
||||
Procedure::ExplicitDynamic(_) => run_explicit,
|
||||
Procedure::Fluid { .. } => run_fluid,
|
||||
};
|
||||
(st.name.clone(), st.procedure.name(), solve)
|
||||
})
|
||||
.collect();
|
||||
if steps.is_empty() {
|
||||
return Err(EngineError::Params("the model has no steps to solve: add one (model.step.create)".into()));
|
||||
}
|
||||
let mut done = Vec::new();
|
||||
for (name, procedure, solve) in steps {
|
||||
let summary = solve(s, &json!({"step": name})).map_err(|e| EngineError::Params(format!("step `{name}`: {e}")))?;
|
||||
done.push(json!({"step": name, "procedure": procedure, "summary": summary}));
|
||||
}
|
||||
Ok(json!({"steps": done}))
|
||||
}
|
||||
|
||||
fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
@@ -523,6 +559,31 @@ fn probe_frame(r: &ExplicitResult, step: &str, nodes: &[u32], elements: &[u32],
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
/// `file.sample` opens the cantilever with nothing to save; `solve.run` solves both its steps
|
||||
/// in order and the results view comes up on the first.
|
||||
#[test]
|
||||
fn samples_open_and_solve_run_solves_every_step() {
|
||||
use serde_json::json;
|
||||
let mut s = Session::default();
|
||||
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
|
||||
let r = s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||
assert_eq!(r["sample"], "cantilever");
|
||||
assert!(!s.is_dirty() && s.path.is_none());
|
||||
assert!(s.model.node(1).is_some_and(|n| n.xyz != [0.0, 0.0, 0.0]) || s.model.nodes().len() > 1);
|
||||
assert!(s.execute("file.sample", &json!({"name": "nope"})).unwrap_err().to_string().contains("cantilever"));
|
||||
let r = s.execute("solve.run", &json!({})).unwrap();
|
||||
let steps: Vec<&str> = r["steps"].as_array().unwrap().iter().map(|x| x["step"].as_str().unwrap()).collect();
|
||||
assert_eq!(steps, ["Load", "Modes"]);
|
||||
assert!(s.result("Load").is_some() && s.result("Modes").is_some());
|
||||
assert_eq!(s.results_view.as_ref().map(|v| v.step.as_str()), Some("Load"));
|
||||
s.execute("file.sample", &json!({"name": "impact"})).unwrap();
|
||||
assert!(s.results_view.is_none(), "a new model shows no old results");
|
||||
s.execute("solve.run", &json!({})).unwrap();
|
||||
assert!(s.result("Impact").is_some());
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("solve.run", &json!({})).unwrap_err().to_string().contains("no steps"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A cantilever 10 × 1 × 1 of ten hex8 elements with incompatible modes, clamped at x = 0,
|
||||
|
||||
@@ -205,6 +205,11 @@ impl Session {
|
||||
(self.history.undo.len(), self.history.redo.len())
|
||||
}
|
||||
|
||||
/// The undone commands redo would run, the next one first.
|
||||
pub fn redo_entries(&self) -> impl Iterator<Item = &(String, Value)> {
|
||||
self.history.redo.iter().rev().map(|r| &r.entry)
|
||||
}
|
||||
|
||||
fn history_report(&self, done: &str, command: &str) -> Value {
|
||||
let (undo, redo) = self.undo_depth();
|
||||
json!({done: command, "undo": undo, "redo": redo})
|
||||
|
||||
@@ -12,6 +12,37 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`).
|
||||
pub struct Sample {
|
||||
pub name: &'static str,
|
||||
pub label: &'static str,
|
||||
/// One line: what it shows.
|
||||
pub about: &'static str,
|
||||
pub script: fn() -> Value,
|
||||
}
|
||||
|
||||
/// Every sample, the default first.
|
||||
pub const SAMPLES: &[Sample] = &[
|
||||
Sample { name: "parts", label: "Hex and Tet Parts", about: "A hex8 block beside a tet4 cube: meshes to look at, nothing to solve", script },
|
||||
Sample {
|
||||
name: "cantilever",
|
||||
label: "Cantilever",
|
||||
about: "A clamped steel beam with an end load: a static and a frequency step, ready to solve",
|
||||
script: cantilever,
|
||||
},
|
||||
Sample {
|
||||
name: "impact",
|
||||
label: "Bar Impact",
|
||||
about: "The steel beam flying onto a rigid wall: an explicit dynamic step, ready to solve",
|
||||
script: impact,
|
||||
},
|
||||
];
|
||||
|
||||
/// The sample called `name`.
|
||||
pub fn find(name: &str) -> Option<&'static Sample> {
|
||||
SAMPLES.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
/// Hex block: cells along x, y, z and the cell size (mm).
|
||||
const HEX_CELLS: [u32; 3] = [8, 4, 2];
|
||||
/// Tet cube: cells per side and its offset from the origin (mm).
|
||||
|
||||
@@ -14,7 +14,9 @@ modelcraft-engine = { workspace = true }
|
||||
modelcraft-render = { workspace = true }
|
||||
modelcraft-explicit = { workspace = true }
|
||||
modelcraft-geometry = { workspace = true }
|
||||
modelcraft-decks = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
craft-ui = { workspace = true }
|
||||
egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,365 @@
|
||||
//! The application bar: the window's title bar. The app's mark and name, the quick actions (File
|
||||
//! menu, save, undo, redo, Solve, command search, theme, help), the model's document tab and the
|
||||
//! version; on Windows and Linux the caption buttons ([`crate::titlebar`]).
|
||||
//!
|
||||
//! Layout follows SolidCraft's `toolbar::app_bar` (MIT OR Apache-2.0), see ATTRIBUTION.md; the
|
||||
//! quick-action buttons are craft-ui `IconButton`s and the document tab a craft-ui `Tab`.
|
||||
|
||||
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
|
||||
use egui::{Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke, Vec2, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::icons::{self, Look};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The bar's height.
|
||||
pub const HEIGHT: f32 = 34.0;
|
||||
/// A quick-action button's side, and the room each takes.
|
||||
const BUTTON: f32 = 26.0;
|
||||
const STRIDE: f32 = 30.0;
|
||||
|
||||
/// The title bar's quick actions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Quick {
|
||||
File,
|
||||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Solve,
|
||||
Search,
|
||||
Theme,
|
||||
Help,
|
||||
}
|
||||
|
||||
impl Quick {
|
||||
/// Its region in `ui.inspect`'s layout.
|
||||
fn region(self) -> &'static str {
|
||||
match self {
|
||||
Quick::File => "app_bar:file",
|
||||
Quick::Save => "app_bar:save",
|
||||
Quick::Undo => "app_bar:undo",
|
||||
Quick::Redo => "app_bar:redo",
|
||||
Quick::Solve => "app_bar:solve",
|
||||
Quick::Search => "app_bar:search",
|
||||
Quick::Theme => "app_bar:theme",
|
||||
Quick::Help => "app_bar:help",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick actions' look: flat on the dark bar, lighter under the pointer.
|
||||
fn button_style(t: &Tokens) -> IconButtonStyle {
|
||||
let normal = ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: t.app_bar_text };
|
||||
IconButtonStyle {
|
||||
normal,
|
||||
hovered: ButtonVisuals { fill: Color32::from_white_alpha(30), ..normal },
|
||||
pressed: Some(ButtonVisuals { fill: Color32::from_white_alpha(18), ..normal }),
|
||||
selected: ButtonVisuals { fill: Color32::from_white_alpha(40), ..normal },
|
||||
corner_radius: CornerRadius::same(4),
|
||||
focus_stroke: Stroke::new(1.0, t.accent),
|
||||
}
|
||||
}
|
||||
|
||||
/// ModelCraft's mark: the application icon's bracket (`assets/app-icon/icon-small.svg`) drawn in
|
||||
/// code from the SVG's own coordinates, without its tile, each face in the middle of its gradient.
|
||||
fn mark(p: &egui::Painter, c: egui::Pos2) {
|
||||
// The bracket spans y 141–883 in the SVG; draw it 22 points tall around its centre.
|
||||
let s = 22.0 / 742.0;
|
||||
let at = |x: f32, y: f32| c + vec2((x - 512.0) * s, (y - 512.0) * s);
|
||||
let face = |col: Color32, pts: &[(f32, f32)]| {
|
||||
p.add(egui::Shape::convex_polygon(pts.iter().map(|&(x, y)| at(x, y)).collect(), col, Stroke::NONE));
|
||||
};
|
||||
let (top, front, end) = (Color32::from_rgb(0x69, 0xd4, 0xc2), Color32::from_rgb(0x19, 0xa0, 0x8d), Color32::from_rgb(0x0d, 0x66, 0x5d));
|
||||
face(top, &[(512.0, 141.0), (833.3, 326.5), (730.48, 385.86), (409.19, 200.36)]);
|
||||
face(front, &[(409.19, 200.36), (730.48, 385.86), (730.48, 638.14), (409.19, 452.64)]);
|
||||
face(top, &[(409.19, 452.64), (730.48, 638.14), (512.0, 764.28), (190.7, 578.78)]);
|
||||
face(front, &[(190.7, 578.78), (512.0, 764.28), (512.0, 883.0), (190.7, 697.5)]);
|
||||
face(end, &[(730.48, 385.86), (833.3, 326.5), (833.3, 697.5), (730.48, 638.14)]);
|
||||
face(end, &[(730.48, 638.14), (833.3, 697.5), (512.0, 883.0), (512.0, 764.28)]);
|
||||
// The bolt hole.
|
||||
let hole: Vec<egui::Pos2> = (0..24)
|
||||
.map(|k| {
|
||||
let a = k as f32 / 24.0 * std::f32::consts::TAU;
|
||||
at(460.6 + 86.0 * a.cos(), 608.5 + 49.5 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::convex_polygon(hole, Color32::from_rgb(0x08, 0x46, 0x3f), Stroke::NONE));
|
||||
}
|
||||
|
||||
/// Where the title bar's parts go.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BarLayout {
|
||||
/// The mark's centre, and where the app's name starts (none when compact).
|
||||
pub mark: egui::Pos2,
|
||||
pub name: Option<egui::Pos2>,
|
||||
/// File, Save, Undo, Redo: after the mark, at the left.
|
||||
pub quick: [Rect; 4],
|
||||
/// Solve, then Search, Theme and Help: at the right end, before the version.
|
||||
pub solve: Rect,
|
||||
pub tools: [Rect; 3],
|
||||
/// The document tab's room, between the two groups.
|
||||
pub tab_room: (f32, f32),
|
||||
/// The version's right edge.
|
||||
pub version_right: f32,
|
||||
/// Too narrow for labels: no name, and Solve without its word.
|
||||
pub compact: bool,
|
||||
}
|
||||
|
||||
/// The title bar's layout in `bar`. `traffic_lights`: the macOS window buttons sit over its left
|
||||
/// end (an integrated title bar out of full screen, where there are none). `captions`: the width
|
||||
/// of the custom caption buttons at its right end; `version_w`: the version text's width.
|
||||
pub fn bar_layout(bar: Rect, traffic_lights: bool, captions: f32, version_w: f32) -> BarLayout {
|
||||
let left = bar.left() + if traffic_lights { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
|
||||
let compact = bar.right() - left < 760.0;
|
||||
let cy = bar.center().y;
|
||||
let button = |x: f32| Rect::from_center_size(pos2(x + BUTTON / 2.0, cy), Vec2::splat(BUTTON));
|
||||
let x0 = left + if compact { 44.0 } else { 132.0 };
|
||||
let quick = [0.0, 1.0, 2.0, 3.0].map(|i| button(x0 + i * STRIDE));
|
||||
let version_right = bar.right() - 12.0 - captions;
|
||||
let tools_end = version_right - version_w - 14.0;
|
||||
let tools = [3.0, 2.0, 1.0].map(|i| button(tools_end - i * STRIDE));
|
||||
let solve_w = if compact { 30.0 } else { 74.0 };
|
||||
let solve = Rect::from_min_size(pos2(tools[0].left() - 10.0 - solve_w, cy - 12.0), vec2(solve_w, 24.0));
|
||||
BarLayout {
|
||||
mark: pos2(left + 20.0, cy),
|
||||
name: (!compact).then(|| pos2(left + 36.0, cy)),
|
||||
quick,
|
||||
solve,
|
||||
tools,
|
||||
tab_room: (quick[3].right() + 16.0, solve.left() - 16.0),
|
||||
version_right,
|
||||
compact,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the window is in full screen (no traffic lights to make room for).
|
||||
fn fullscreen(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().fullscreen.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn app_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let style = button_style(&t);
|
||||
let mut anchors: Vec<(Quick, Rect)> = Vec::new();
|
||||
egui::Panel::top("mc_appbar").exact_size(HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
app.place("app_bar", r);
|
||||
if app.custom_titlebar || app.integrated_titlebar {
|
||||
crate::titlebar::drag_area(ui, r);
|
||||
}
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
let version = format!("v{}", crate::VERSION);
|
||||
let version_w = ui.painter().layout_no_wrap(version.clone(), FontId::proportional(11.0), t.text_dim).size().x;
|
||||
let lay = bar_layout(r, app.integrated_titlebar && !fullscreen(ui.ctx()), captions, version_w);
|
||||
mark(ui.painter(), lay.mark);
|
||||
if let Some(at) = lay.name {
|
||||
ui.painter().text(at, Align2::LEFT_CENTER, "ModelCraft", FontId::proportional(14.0), t.app_bar_text);
|
||||
}
|
||||
let (undo, redo) = app.session.undo_depth();
|
||||
let busy = app.busy().is_some();
|
||||
let file_open = app.ui.menu.as_deref() == Some("file");
|
||||
let actions: [(Quick, &'static str, String, bool, bool); 4] = [
|
||||
(Quick::File, "file.open", "File: open, save, decks, samples".to_string(), true, file_open),
|
||||
(Quick::Save, "file.save", format!("Save ({})", shortcut("S")), !busy, false),
|
||||
(Quick::Undo, "edit.undo", format!("Undo ({})", shortcut("Z")), undo > 0 && !busy, false),
|
||||
(Quick::Redo, "edit.redo", format!("Redo ({})", shortcut("Shift+Z")), redo > 0 && !busy, false),
|
||||
];
|
||||
let tools: [(Quick, &'static str, String, bool, bool); 3] = [
|
||||
(Quick::Search, "ui.search", format!("Search commands (S or {})", shortcut("K")), true, app.ui.palette),
|
||||
(
|
||||
Quick::Theme,
|
||||
if app.ui.dark { "ui.sun" } else { "ui.moon" },
|
||||
if app.ui.dark { "Light theme".into() } else { "Dark theme".into() },
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(Quick::Help, "ui.help", "Help: samples, navigation, about".into(), true, app.ui.menu.as_deref() == Some("help")),
|
||||
];
|
||||
let mut clicked = None;
|
||||
for ((q, icon, tip, enabled, selected), rect) in actions.into_iter().zip(lay.quick).chain(tools.into_iter().zip(lay.tools)) {
|
||||
if quick_button(app, ui, &style, q.region(), icon, rect, &tip, enabled, selected) {
|
||||
clicked = Some(q);
|
||||
}
|
||||
anchors.push((q, rect));
|
||||
}
|
||||
// Solve: the one prominent action, a filled button with its label.
|
||||
let solve = lay.solve;
|
||||
let resp = ui.interact(solve, ui.id().with("mc_solve"), Sense::click());
|
||||
let can = !app.session.model.steps().is_empty() && !busy;
|
||||
let fill = if resp.hovered() && can {
|
||||
t.accent.gamma_multiply(1.15)
|
||||
} else if can {
|
||||
t.accent
|
||||
} else {
|
||||
Color32::from_white_alpha(24)
|
||||
};
|
||||
ui.painter().rect_filled(solve, 5.0, fill);
|
||||
// A white play mark: the accent fill is the button's colour.
|
||||
let c = pos2(solve.left() + 15.0, solve.center().y);
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(-3.5, -5.5), c + vec2(5.5, 0.0), c + vec2(-3.5, 5.5)],
|
||||
Color32::WHITE,
|
||||
Stroke::NONE,
|
||||
));
|
||||
if !lay.compact {
|
||||
ui.painter().text(
|
||||
pos2(solve.left() + 28.0, solve.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"Solve",
|
||||
crate::theme::label_font(ui.ctx(), 13.0),
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
let tip = if app.session.model.steps().is_empty() {
|
||||
"Solve: the model has no steps yet (try Help › Samples)"
|
||||
} else {
|
||||
"Solve every step and show the results"
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Solve"));
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
clicked = Some(Quick::Solve);
|
||||
}
|
||||
anchors.push((Quick::Solve, solve));
|
||||
// The model's document tab, joined to the ribbon below it, centred in its room.
|
||||
let label = format!("{}{}", app.model_name(), if app.session.is_dirty() { " •" } else { "" });
|
||||
let text_w = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(13.0), t.text).size().x;
|
||||
let (room_l, room_r) = lay.tab_room;
|
||||
let tab_w = (text_w + 40.0).clamp(120.0, 260.0).min((room_r - room_l).max(0.0));
|
||||
let tab_x = (r.center().x - tab_w / 2.0).clamp(room_l, (room_r - tab_w).max(room_l));
|
||||
if tab_w > 40.0 {
|
||||
let tab = Rect::from_min_size(pos2(tab_x, r.top() + 5.0), vec2(tab_w, r.height() - 5.0));
|
||||
let path = app.session.path.clone();
|
||||
craft_ui::tabs::Tab::new(egui::Id::new("mc_document_tab"), &label, true).show_at(ui, tab, |ui, resp| {
|
||||
ui.painter().rect_filled(tab, CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar);
|
||||
let clip = tab.shrink2(vec2(10.0, 0.0));
|
||||
ui.painter().with_clip_rect(clip).text(
|
||||
pos2(tab.left() + 14.0, tab.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&label,
|
||||
FontId::proportional(13.0),
|
||||
t.text,
|
||||
);
|
||||
let _ = resp.clone().on_hover_text(path.as_deref().unwrap_or("Not saved yet"));
|
||||
});
|
||||
app.place("document_tab", tab);
|
||||
}
|
||||
ui.painter().text(
|
||||
pos2(lay.version_right, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
version,
|
||||
FontId::proportional(11.0),
|
||||
Color32::from_rgb(160, 168, 182),
|
||||
);
|
||||
if app.custom_titlebar {
|
||||
crate::titlebar::caption_buttons(app, ui, r);
|
||||
}
|
||||
match clicked {
|
||||
Some(Quick::File) => app.toggle_menu("file"),
|
||||
Some(Quick::Help) => app.toggle_menu("help"),
|
||||
Some(Quick::Save) => {
|
||||
app.save();
|
||||
}
|
||||
Some(Quick::Undo) => {
|
||||
let _ = app.run("edit.undo", json!({}));
|
||||
}
|
||||
Some(Quick::Redo) => {
|
||||
let _ = app.run("edit.redo", json!({}));
|
||||
}
|
||||
Some(Quick::Solve) => app.solve_all(),
|
||||
Some(Quick::Search) => {
|
||||
app.ui.palette = !app.ui.palette;
|
||||
app.ui.menu = None;
|
||||
}
|
||||
Some(Quick::Theme) => app.ui.dark = !app.ui.dark,
|
||||
None => {}
|
||||
}
|
||||
for (q, rect) in &anchors {
|
||||
app.place(q.region(), *rect);
|
||||
}
|
||||
let anchor = |q: Quick| anchors.iter().find(|(k, _)| *k == q).map_or(r, |(_, a)| *a);
|
||||
crate::frame::menu(app, ui, "file", anchor(Quick::File), crate::files::file_menu);
|
||||
crate::frame::menu(app, ui, "help", anchor(Quick::Help), help_menu);
|
||||
});
|
||||
}
|
||||
|
||||
/// One quick action: its icon on the dark bar, dimmed when it cannot run now. `true` when clicked.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn quick_button(
|
||||
app: &mut ModelApp,
|
||||
ui: &mut egui::Ui,
|
||||
style: &IconButtonStyle,
|
||||
key: &str,
|
||||
icon: &'static str,
|
||||
rect: Rect,
|
||||
tip: &str,
|
||||
enabled: bool,
|
||||
selected: bool,
|
||||
) -> bool {
|
||||
let resp = ui
|
||||
.scope_builder(egui::UiBuilder::new().max_rect(rect).id_salt(("mc_quick", key)), |ui| {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
IconButton::new(tip, rect.size(), style).selected(selected).show(ui, |_, _, _| {}).on_hover_text(tip).on_disabled_hover_text(tip)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let tint = if enabled { Color32::WHITE } else { Color32::from_white_alpha(90) };
|
||||
icons::paint_tinted(app, ui, icon, rect.shrink(4.0), Look::AppBar, tint);
|
||||
enabled && resp.clicked()
|
||||
}
|
||||
|
||||
/// The shortcut text with this platform's command key.
|
||||
pub fn shortcut(key: &str) -> String {
|
||||
format!("{}+{key}", if cfg!(target_os = "macos") { "Cmd" } else { "Ctrl" })
|
||||
}
|
||||
|
||||
/// Help: the samples (ready to solve), how to navigate, keys, and the version.
|
||||
fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
ui.set_max_width(340.0);
|
||||
crate::frame::menu_heading(ui, &t, "SAMPLES");
|
||||
let mut open = None;
|
||||
crate::files::sample_rows(ui, &t, &mut open);
|
||||
ui.separator();
|
||||
crate::frame::menu_heading(ui, &t, "NAVIGATION");
|
||||
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
|
||||
ui.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(format!("ModelCraft {}", crate::VERSION)).color(t.text_dim));
|
||||
if let Some(n) = open {
|
||||
app.ui.menu = None;
|
||||
app.open_sample(n);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Solve and the tools sit at the right end; the left group starts after the traffic lights,
|
||||
/// at the very left in full screen (where there are none).
|
||||
#[test]
|
||||
fn the_title_bar_keeps_solve_right_and_drops_the_traffic_light_inset_in_full_screen() {
|
||||
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, HEIGHT));
|
||||
let windowed = bar_layout(bar, true, 0.0, 30.0);
|
||||
let full = bar_layout(bar, false, 0.0, 30.0);
|
||||
assert_eq!(windowed.mark.x - full.mark.x, crate::titlebar::TRAFFIC_LIGHTS);
|
||||
assert!(full.mark.x < 30.0 && full.quick[0].left() < 150.0, "{full:?}");
|
||||
for l in [windowed, full] {
|
||||
// Left to right: mark, quick actions, (tab), Solve, search, theme, help, version.
|
||||
assert!(l.quick.windows(2).all(|w| w[0].right() <= w[1].left()));
|
||||
assert!(l.quick[3].right() < l.tab_room.0 && l.tab_room.1 < l.solve.left());
|
||||
assert!(l.solve.right() <= l.tools[0].left() && l.tools.windows(2).all(|w| w[0].right() <= w[1].left()));
|
||||
assert!(l.tools[2].right() < l.version_right - 30.0 && l.version_right <= 1400.0);
|
||||
assert!(l.solve.left() > 1100.0, "Solve is at the right end: {:?}", l.solve);
|
||||
}
|
||||
// The right group does not move with full screen; custom caption buttons push it left.
|
||||
assert_eq!(windowed.solve, full.solve);
|
||||
let captions = bar_layout(bar, false, crate::titlebar::WIDTH, 30.0);
|
||||
assert!((full.tools[2].right() - captions.tools[2].right() - crate::titlebar::WIDTH).abs() < 0.01);
|
||||
// Narrow: compact, still in order.
|
||||
let narrow = bar_layout(Rect::from_min_size(egui::Pos2::ZERO, vec2(700.0, HEIGHT)), true, 0.0, 30.0);
|
||||
assert!(narrow.compact && narrow.name.is_none() && narrow.quick[3].right() < narrow.solve.left());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
//! The browser (left): the model as a tree. Geometry and Mesh with eye toggles, then the model's
|
||||
//! definitions (sets, materials, sections, steps, loads and boundary conditions, interactions)
|
||||
//! with counts, and the solved steps' results. Clicking a definition selects it (its properties
|
||||
//! show on the right), a double click opens its edit form, and clicking a result step shows it.
|
||||
//!
|
||||
//! Row drawing follows SolidCraft's `crates/ui-egui/src/browser.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2, pos2, vec2};
|
||||
use modelcraft_model::Model;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::icons::{self, Look};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The browser's width.
|
||||
pub const WIDTH: f32 = 250.0;
|
||||
const ROW_H: f32 = 22.0;
|
||||
const INDENT: f32 = 14.0;
|
||||
|
||||
/// A kind of model definition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Def {
|
||||
Set,
|
||||
Material,
|
||||
Section,
|
||||
Step,
|
||||
BoundaryCondition,
|
||||
Load,
|
||||
ReferencePoint,
|
||||
Coupling,
|
||||
FluidDomain,
|
||||
}
|
||||
|
||||
impl Def {
|
||||
/// The commands' stem: `model.<stem>.create|edit|delete`.
|
||||
pub fn stem(self) -> &'static str {
|
||||
match self {
|
||||
Def::Set => "set",
|
||||
Def::Material => "material",
|
||||
Def::Section => "section",
|
||||
Def::Step => "step",
|
||||
Def::BoundaryCondition => "boundary_condition",
|
||||
Def::Load => "load",
|
||||
Def::ReferencePoint => "reference_point",
|
||||
Def::Coupling => "coupling",
|
||||
Def::FluidDomain => "fluid_domain",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Def::Set => "Set",
|
||||
Def::Material => "Material",
|
||||
Def::Section => "Section",
|
||||
Def::Step => "Step",
|
||||
Def::BoundaryCondition => "Boundary Condition",
|
||||
Def::Load => "Load",
|
||||
Def::ReferencePoint => "Reference Point",
|
||||
Def::Coupling => "Coupling",
|
||||
Def::FluidDomain => "Fluid Domain",
|
||||
}
|
||||
}
|
||||
|
||||
/// Its icon: the catalogue entry that creates it.
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Def::Set => "model.set.create",
|
||||
Def::Material => "model.material.create",
|
||||
Def::Section => "model.section.create",
|
||||
Def::Step => "model.step.create",
|
||||
Def::BoundaryCondition => "model.boundary_condition.create",
|
||||
Def::Load => "model.load.create",
|
||||
Def::ReferencePoint => "model.reference_point.create",
|
||||
Def::Coupling => "model.coupling.create",
|
||||
Def::FluidDomain => "model.fluid_domain.create",
|
||||
}
|
||||
}
|
||||
|
||||
/// The command of this kind with `verb` (`edit`, `delete`).
|
||||
pub fn command(self, verb: &str) -> String {
|
||||
format!("model.{}.{verb}", self.stem())
|
||||
}
|
||||
|
||||
/// How many definitions of this kind the model has.
|
||||
pub fn count(self, m: &Model) -> usize {
|
||||
match self {
|
||||
Def::Set => m.sets().len(),
|
||||
Def::Material => m.materials().len(),
|
||||
Def::Section => m.sections().len(),
|
||||
Def::Step => m.steps().len(),
|
||||
Def::BoundaryCondition => m.boundary_conditions().len(),
|
||||
Def::Load => m.loads().len(),
|
||||
Def::ReferencePoint => m.reference_points().len(),
|
||||
Def::Coupling => m.couplings().len(),
|
||||
Def::FluidDomain => m.fluid_domains().len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the model has a definition of this kind called `name`.
|
||||
pub fn exists(self, m: &Model, name: &str) -> bool {
|
||||
match self {
|
||||
Def::Set => m.sets().contains_key(name),
|
||||
Def::Material => m.materials().contains_key(name),
|
||||
Def::Section => m.sections().contains_key(name),
|
||||
Def::Step => m.step(name).is_some(),
|
||||
Def::BoundaryCondition => m.boundary_conditions().contains_key(name),
|
||||
Def::Load => m.loads().contains_key(name),
|
||||
Def::ReferencePoint => m.reference_points().contains_key(name),
|
||||
Def::Coupling => m.couplings().contains_key(name),
|
||||
Def::FluidDomain => m.fluid_domain(name).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of this kind's definitions, in the model's order.
|
||||
pub fn names(self, m: &Model) -> Vec<String> {
|
||||
match self {
|
||||
Def::Set => m.sets().keys().cloned().collect(),
|
||||
Def::Material => m.materials().keys().cloned().collect(),
|
||||
Def::Section => m.sections().keys().cloned().collect(),
|
||||
Def::Step => m.steps().iter().map(|s| s.name.clone()).collect(),
|
||||
Def::BoundaryCondition => m.boundary_conditions().keys().cloned().collect(),
|
||||
Def::Load => m.loads().keys().cloned().collect(),
|
||||
Def::ReferencePoint => m.reference_points().keys().cloned().collect(),
|
||||
Def::Coupling => m.couplings().keys().cloned().collect(),
|
||||
Def::FluidDomain => m.fluid_domains().keys().cloned().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A definition as JSON (its command parameters' shape, with its name); `None` when it is gone.
|
||||
pub fn definition(m: &Model, kind: Def, name: &str) -> Option<Value> {
|
||||
let v = match kind {
|
||||
Def::Set => serde_json::to_value(m.sets().get(name)?),
|
||||
Def::Material => serde_json::to_value(m.materials().get(name)?),
|
||||
Def::Section => serde_json::to_value(m.sections().get(name)?),
|
||||
Def::Step => serde_json::to_value(m.step(name)?),
|
||||
Def::BoundaryCondition => serde_json::to_value(m.boundary_conditions().get(name)?),
|
||||
Def::Load => serde_json::to_value(m.loads().get(name)?),
|
||||
Def::ReferencePoint => serde_json::to_value(m.reference_points().get(name)?),
|
||||
Def::Coupling => serde_json::to_value(m.couplings().get(name)?),
|
||||
Def::FluidDomain => serde_json::to_value(m.fluid_domain(name)?),
|
||||
}
|
||||
.ok()?;
|
||||
let mut v = if v.is_object() { v } else { json!({"value": v}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("name".into(), json!(name));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// One row's look.
|
||||
#[derive(Default)]
|
||||
struct Row<'a> {
|
||||
depth: usize,
|
||||
/// Some(open) for rows that fold.
|
||||
fold: Option<bool>,
|
||||
/// Some(visible) for rows with an eye.
|
||||
eye: Option<bool>,
|
||||
icon: Option<&'static str>,
|
||||
/// A colour swatch instead of an icon (element kinds).
|
||||
swatch: Option<Color32>,
|
||||
label: &'a str,
|
||||
/// Dim text after the label (a count).
|
||||
note: Option<String>,
|
||||
selected: bool,
|
||||
dim: bool,
|
||||
}
|
||||
|
||||
/// What happened on a row.
|
||||
#[derive(Default)]
|
||||
struct RowResp {
|
||||
clicked: bool,
|
||||
double: bool,
|
||||
fold: bool,
|
||||
eye: bool,
|
||||
}
|
||||
|
||||
fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover());
|
||||
if !ui.is_rect_visible(r) {
|
||||
return RowResp::default();
|
||||
}
|
||||
let resp = ui.interact(r, id, Sense::click());
|
||||
if row.selected {
|
||||
ui.painter().rect_filled(r, 3.0, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let mut x = r.left() + 2.0 + row.depth as f32 * INDENT;
|
||||
let cy = r.center().y;
|
||||
let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom()));
|
||||
let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height()));
|
||||
let at = |p: Pos2| -> u8 {
|
||||
if row.fold.is_some() && fold_r.contains(p) {
|
||||
1
|
||||
} else if row.eye.is_some() && eye_r.contains(p) {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let click_on = resp.interact_pointer_pos().map_or(0, at);
|
||||
let hover_on = resp.hover_pos().map_or(0, at);
|
||||
let mut out = RowResp::default();
|
||||
if let Some(open) = row.fold {
|
||||
let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0));
|
||||
let pts = if open {
|
||||
vec![pos2(ar.left() + 2.0, ar.top() + 4.0), pos2(ar.right() - 2.0, ar.top() + 4.0), pos2(ar.center().x, ar.bottom() - 3.0)]
|
||||
} else {
|
||||
vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, if hover_on == 1 { t.text } else { t.text_dim }, Stroke::NONE));
|
||||
out.fold = resp.clicked() && click_on == 1;
|
||||
}
|
||||
x += 14.0;
|
||||
if let Some(vis) = row.eye {
|
||||
let er = Rect::from_center_size(pos2(x + 8.0, cy), Vec2::splat(15.0));
|
||||
icons::paint(app, ui, "ui.eye", er, Look::of(vis));
|
||||
if !vis {
|
||||
ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim));
|
||||
}
|
||||
out.eye = resp.clicked() && click_on == 2;
|
||||
x += 20.0;
|
||||
}
|
||||
if let Some(icon) = row.icon {
|
||||
icons::paint(app, ui, icon, Rect::from_min_size(pos2(x, cy - 8.0), Vec2::splat(16.0)), Look::of(!row.dim));
|
||||
x += 21.0;
|
||||
} else if let Some(c) = row.swatch {
|
||||
ui.painter().rect_filled(Rect::from_center_size(pos2(x + 6.0, cy), Vec2::splat(10.0)), 2.0, c);
|
||||
x += 18.0;
|
||||
}
|
||||
let col = if row.dim { t.text_dim } else { t.text };
|
||||
let galley = ui.painter().layout_no_wrap(row.label.to_string(), FontId::proportional(12.5), col);
|
||||
let lw = galley.size().x;
|
||||
ui.painter().with_clip_rect(r).galley(pos2(x, cy - galley.size().y / 2.0), galley, col);
|
||||
if let Some(n) = &row.note {
|
||||
ui.painter().with_clip_rect(r).text(pos2(x + lw + 6.0, cy), egui::Align2::LEFT_CENTER, n, FontId::proportional(11.5), t.text_dim);
|
||||
}
|
||||
let tip = match hover_on {
|
||||
1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }),
|
||||
2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }),
|
||||
_ => None,
|
||||
};
|
||||
let resp = match tip {
|
||||
Some(tip) => resp.on_hover_text(tip),
|
||||
None => resp,
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, row.label));
|
||||
out.clicked = resp.clicked() && click_on == 0;
|
||||
out.double = resp.double_clicked() && click_on == 0;
|
||||
out
|
||||
}
|
||||
|
||||
/// What the browser asks for after drawing (it cannot run commands while it borrows the model).
|
||||
enum Act {
|
||||
Fold(String),
|
||||
Select(Def, String),
|
||||
Edit(Def, String),
|
||||
ShowResult(String),
|
||||
}
|
||||
|
||||
pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let mut acts: Vec<Act> = Vec::new();
|
||||
egui::Panel::left("mc_browser")
|
||||
.exact_size(WIDTH)
|
||||
.resizable(false)
|
||||
.show_separator_line(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6)))
|
||||
.show(ui, |ui| {
|
||||
app.place("browser", ui.max_rect());
|
||||
ui.label(RichText::new("BROWSER").font(crate::theme::label_font(ui.ctx(), 10.5)).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
egui::ScrollArea::vertical().id_salt("mc_browser_scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
rows(app, ui, &mut acts);
|
||||
});
|
||||
});
|
||||
for a in acts {
|
||||
match a {
|
||||
Act::Fold(key) => {
|
||||
if !app.tree_open.remove(&key) {
|
||||
app.tree_open.insert(key);
|
||||
}
|
||||
}
|
||||
Act::Select(kind, name) => {
|
||||
app.selected = if app.selected.as_ref() == Some(&(kind, name.clone())) { None } else { Some((kind, name)) };
|
||||
}
|
||||
Act::Edit(kind, name) => {
|
||||
if let Some(v) = definition(&app.session.model, kind, &name) {
|
||||
app.selected = Some((kind, name));
|
||||
app.open_form(&kind.command("edit"), v);
|
||||
}
|
||||
}
|
||||
Act::ShowResult(step) => {
|
||||
// Errors show in the status bar.
|
||||
let _ = app.run("results.deformed", json!({"step": step}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let open = |app: &ModelApp, key: &str| app.tree_open.contains(key);
|
||||
let name = app.model_name();
|
||||
let dirty = app.session.is_dirty();
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_root"),
|
||||
&Row { icon: Some("ui.views"), label: &name, note: dirty.then(|| "unsaved".into()), ..Default::default() },
|
||||
);
|
||||
// Geometry.
|
||||
let design = app.session.design.clone();
|
||||
let surfaces: Vec<String> = app.session.surfaces.iter().map(|s| s.name.clone()).collect();
|
||||
let bodies = design.as_ref().map_or(0, |d| d.bodies.len());
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_geometry"),
|
||||
&Row {
|
||||
depth: 1,
|
||||
fold: Some(open(app, "geometry")),
|
||||
eye: Some(app.ui.show_geometry),
|
||||
icon: Some("geometry.open"),
|
||||
label: "Geometry",
|
||||
note: Some(format!("{}", bodies + surfaces.len())),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.eye {
|
||||
app.ui.show_geometry = !app.ui.show_geometry;
|
||||
} else if r.fold || r.clicked {
|
||||
acts.push(Act::Fold("geometry".into()));
|
||||
}
|
||||
if open(app, "geometry") {
|
||||
if let Some(d) = &design {
|
||||
let label = d.name.clone();
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_design"),
|
||||
&Row { depth: 2, icon: Some("geometry.open"), label: &label, note: Some(format!("{bodies} bodies")), ..Default::default() },
|
||||
);
|
||||
for (i, b) in d.bodies.iter().enumerate() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("mc_row_body", i)),
|
||||
&Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() },
|
||||
);
|
||||
}
|
||||
}
|
||||
for (i, s) in surfaces.iter().enumerate() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("mc_row_surface", i)),
|
||||
&Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() },
|
||||
);
|
||||
}
|
||||
if design.is_none() && surfaces.is_empty() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_no_geometry"),
|
||||
&Row { depth: 2, label: "No geometry: Open Design", dim: true, ..Default::default() },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Mesh.
|
||||
let (nodes, elements) = (app.session.model.nodes().len(), app.session.model.elements().len());
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_mesh"),
|
||||
&Row {
|
||||
depth: 1,
|
||||
fold: Some(open(app, "mesh")),
|
||||
eye: Some(app.ui.show_mesh),
|
||||
icon: Some("mesh.tet.generate"),
|
||||
label: "Mesh",
|
||||
note: Some(format!("{elements}")),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.eye {
|
||||
app.ui.show_mesh = !app.ui.show_mesh;
|
||||
} else if r.fold || r.clicked {
|
||||
acts.push(Act::Fold("mesh".into()));
|
||||
}
|
||||
if open(app, "mesh") {
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_nodes"),
|
||||
&Row {
|
||||
depth: 2,
|
||||
eye: Some(app.ui.show_nodes),
|
||||
icon: Some("mesh.node.add"),
|
||||
label: "Nodes",
|
||||
note: Some(format!("{nodes}")),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.eye {
|
||||
app.ui.show_nodes = !app.ui.show_nodes;
|
||||
}
|
||||
let revision = app.session.revision();
|
||||
if app.element_counts.as_ref().is_none_or(|(r, _)| *r != revision) {
|
||||
app.element_counts = Some((revision, app.session.model.element_counts()));
|
||||
}
|
||||
for (kind, n) in app.element_counts.as_ref().map(|(_, c)| c.clone()).unwrap_or_default() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("mc_row_kind", kind.name())),
|
||||
&Row { depth: 2, swatch: Some(t.element(kind)), label: kind.name(), note: Some(format!("{n}")), ..Default::default() },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Definitions.
|
||||
let groups: [(&str, &str, &[Def]); 7] = [
|
||||
("sets", "Sets", &[Def::Set]),
|
||||
("materials", "Materials", &[Def::Material]),
|
||||
("sections", "Sections", &[Def::Section]),
|
||||
("steps", "Steps", &[Def::Step]),
|
||||
("loads", "Loads & BCs", &[Def::BoundaryCondition, Def::Load]),
|
||||
("interactions", "Interactions", &[Def::ReferencePoint, Def::Coupling]),
|
||||
("fluids", "Fluid Domains", &[Def::FluidDomain]),
|
||||
];
|
||||
for (key, label, kinds) in groups {
|
||||
let items: Vec<(Def, String)> = kinds.iter().flat_map(|k| k.names(&app.session.model).into_iter().map(move |n| (*k, n))).collect();
|
||||
let icon = kinds.first().map(|k| k.icon());
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("mc_row_group", key)),
|
||||
&Row {
|
||||
depth: 1,
|
||||
fold: Some(open(app, key)),
|
||||
icon,
|
||||
label,
|
||||
note: Some(format!("{}", items.len())),
|
||||
dim: items.is_empty(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
acts.push(Act::Fold(key.into()));
|
||||
}
|
||||
if open(app, key) {
|
||||
for (kind, name) in items {
|
||||
let selected = app.selected.as_ref() == Some(&(kind, name.clone()));
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("mc_row_def", kind.stem(), &name)),
|
||||
&Row { depth: 2, icon: Some(kind.icon()), label: &name, selected, ..Default::default() },
|
||||
);
|
||||
if r.double {
|
||||
acts.push(Act::Edit(kind, name));
|
||||
} else if r.clicked {
|
||||
acts.push(Act::Select(kind, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Results.
|
||||
let steps: Vec<(String, bool)> = app.session.result_steps().map(|(n, c)| (n.to_string(), c)).collect();
|
||||
let shown = app.session.results_view.as_ref().map(|v| v.step.clone());
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new("mc_row_results"),
|
||||
&Row {
|
||||
depth: 1,
|
||||
fold: Some(open(app, "results")),
|
||||
icon: Some("results.contour"),
|
||||
label: "Results",
|
||||
note: Some(format!("{}", steps.len())),
|
||||
dim: steps.is_empty(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
acts.push(Act::Fold("results".into()));
|
||||
}
|
||||
if open(app, "results") {
|
||||
for (step, current) in steps {
|
||||
let label = if current { step.clone() } else { format!("{step} (stale)") };
|
||||
let selected = shown.as_deref() == Some(step.as_str());
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("mc_row_result", &step)),
|
||||
&Row { depth: 2, icon: Some("results.deformed"), label: &label, selected, dim: !current, ..Default::default() },
|
||||
);
|
||||
if r.clicked {
|
||||
acts.push(Act::ShowResult(step));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use modelcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn definitions_read_back_as_their_parameters() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.run_script(&modelcraft_engine::sample::cantilever()).unwrap();
|
||||
let m = &app.session.model;
|
||||
assert_eq!(Def::Step.names(m), ["Load", "Modes"]);
|
||||
let steel = definition(m, Def::Material, "Steel").unwrap();
|
||||
assert_eq!(steel["name"], "Steel");
|
||||
assert!(steel.get("elastic").is_some(), "{steel}");
|
||||
assert!(definition(m, Def::Load, "nope").is_none());
|
||||
for kind in [
|
||||
Def::Set,
|
||||
Def::Material,
|
||||
Def::Section,
|
||||
Def::Step,
|
||||
Def::BoundaryCondition,
|
||||
Def::Load,
|
||||
Def::ReferencePoint,
|
||||
Def::Coupling,
|
||||
Def::FluidDomain,
|
||||
] {
|
||||
for verb in ["create", "edit", "delete"] {
|
||||
assert!(modelcraft_engine::find_command(&kind.command(verb)).is_some(), "{kind:?} {verb}");
|
||||
}
|
||||
assert!(modelcraft_engine::catalogue::find(kind.icon()).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,11 @@ impl ModelApp {
|
||||
std::mem::swap(&mut self.icons, &mut off.icons);
|
||||
std::mem::swap(&mut self.viewport, &mut off.viewport);
|
||||
let toolbar = std::mem::take(&mut self.toolbar_rects);
|
||||
let layout = std::mem::take(&mut self.layout);
|
||||
let panels = std::mem::take(&mut self.panel_rects);
|
||||
crate::theme::apply(&off.ctx, self.ui.dark);
|
||||
// A capture is one still frame: windows and menus appear without fading in.
|
||||
off.ctx.global_style_mut(|s| s.animation_time = 0.0);
|
||||
let mut shapes = Vec::new();
|
||||
let mut ppp = size.scale;
|
||||
// Textures freed by the frame painted, released after it is painted.
|
||||
@@ -111,6 +115,8 @@ impl ModelApp {
|
||||
std::mem::swap(&mut self.icons, &mut off.icons);
|
||||
std::mem::swap(&mut self.viewport, &mut off.viewport);
|
||||
self.toolbar_rects = toolbar;
|
||||
self.layout = layout;
|
||||
self.panel_rects = panels;
|
||||
let primitives = off.ctx.tessellate(shapes, ppp);
|
||||
let mut canvas = Canvas::new(w, h, Tokens::get(self.ui.dark).panel)?;
|
||||
for p in &primitives {
|
||||
@@ -329,17 +335,17 @@ mod tests {
|
||||
let image = app.capture(size(900.0, 600.0)).unwrap();
|
||||
assert_eq!(image.size, [900, 600]);
|
||||
let t = Tokens::dark();
|
||||
// The menu bar along the top, the status bar along the bottom (their own colour).
|
||||
let bar = pixel(&image, 450, 595);
|
||||
assert_eq!(bar, t.app_bar, "status bar");
|
||||
// The title bar along the top, the timeline along the bottom (their own colours).
|
||||
assert_eq!(pixel(&image, 450, 595), t.timeline, "timeline");
|
||||
assert_eq!(pixel(&image, 899, 1), t.app_bar, "title bar");
|
||||
// The viewport shows the model: the middle is neither panel nor background.
|
||||
let middle = pixel(&image, 450, 330);
|
||||
assert!(middle != t.panel && middle != t.app_bar, "{middle:?}");
|
||||
// Text was drawn: the menu bar has pixels brighter than its fill.
|
||||
let bright = (0..300).any(|x| (2..20).any(|y| pixel(&image, x, y).r() > 150));
|
||||
assert!(bright, "menu text");
|
||||
// Text was drawn: the title bar has pixels brighter than its fill.
|
||||
let bright = (0..300).any(|x| (2..30).any(|y| pixel(&image, x, y).r() > 150));
|
||||
assert!(bright, "title text");
|
||||
// The live window's state is untouched.
|
||||
assert!(app.viewport.rect.is_none() && app.toolbar_rects.is_empty());
|
||||
assert!(app.viewport.rect.is_none() && app.toolbar_rects.is_empty() && app.layout.is_empty());
|
||||
// A second capture reuses the offscreen context; at scale 2 it doubles the pixels.
|
||||
let twice = app.capture(CaptureSize { width: 450.0, height: 300.0, scale: 2.0 }).unwrap();
|
||||
assert_eq!(twice.size, [900, 600]);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! The context panel (right): shown only while there is context, as in the other Craft apps.
|
||||
//! From the top: the open command's form, the results controls while results are shown, and the
|
||||
//! properties of the definition selected in the browser (with Edit and Delete). Each section has
|
||||
//! a heading band; everything acts through commands.
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::browser::{self, Def};
|
||||
use crate::frame::{Level, heading};
|
||||
use crate::theme::{Tokens, label_font};
|
||||
|
||||
/// The panel's width.
|
||||
pub const WIDTH: f32 = 310.0;
|
||||
|
||||
/// Whether the panel has anything to show.
|
||||
pub fn has_context(app: &mut ModelApp) -> bool {
|
||||
app.dialog.is_some() || app.selected.is_some() || app.plot().is_some()
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
if !has_context(app) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
egui::Panel::right("mc_context")
|
||||
.exact_size(WIDTH)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.dialog_bg).inner_margin(egui::Margin::symmetric(8, 8)))
|
||||
.show(ui, |ui| {
|
||||
app.place("context", ui.max_rect());
|
||||
egui::ScrollArea::vertical().id_salt("mc_context_scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.set_width(WIDTH - 16.0);
|
||||
if app.dialog.is_some() {
|
||||
form(app, ui, &t);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
if let Some(plot) = app.plot() {
|
||||
let mut close = false;
|
||||
heading(ui, &t, Level::Title, |ui| {
|
||||
ui.label(RichText::new("RESULTS").font(label_font(ui.ctx(), 12.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = ui.small_button("Close").on_hover_text("Back to the model (results.clear)").clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
crate::results_panel::controls(app, &plot, ui, &t);
|
||||
if close {
|
||||
let _ = app.run("results.clear", json!({}));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
if let Some((kind, name)) = app.selected.clone() {
|
||||
properties(app, ui, &t, kind, &name);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The open command's parameters as JSON, with the command's parameter documentation; OK runs it.
|
||||
fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let Some(d) = app.dialog.as_mut() else { return };
|
||||
let Some(spec) = modelcraft_engine::find_command(d.command) else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let (mut run, mut cancel) = (false, false);
|
||||
heading(ui, t, Level::Title, |ui| {
|
||||
ui.label(RichText::new(spec.label.to_uppercase()).font(label_font(ui.ctx(), 12.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel = ui.small_button("×").on_hover_text("Cancel (Esc)").clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(spec.id).small().color(t.text_dim));
|
||||
heading(ui, t, Level::Section, |ui| {
|
||||
ui.label(RichText::new("Parameters").size(12.0));
|
||||
});
|
||||
ui.add(egui::TextEdit::multiline(&mut d.text).code_editor().desired_rows(6).desired_width(f32::INFINITY));
|
||||
if let Some(e) = &d.error {
|
||||
ui.label(RichText::new(e).color(t.error));
|
||||
}
|
||||
ui.collapsing(RichText::new("What it takes").size(12.0).color(t.text_dim), |ui| {
|
||||
ui.label(RichText::new(spec.params).small().color(t.text_dim));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel |= ui.button("Cancel").clicked();
|
||||
run = ui.add(egui::Button::new(RichText::new("OK").color(egui::Color32::WHITE)).fill(t.accent).min_size(vec2(64.0, 0.0))).clicked();
|
||||
});
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) && app.ui.menu.is_none() && !app.ui.palette {
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
} else if run {
|
||||
let _ = app.run_dialog();
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected definition's fields as (name, short text), read again only when the model changes.
|
||||
fn property_rows(app: &mut ModelApp, kind: Def, name: &str) -> Option<std::sync::Arc<Vec<(String, String)>>> {
|
||||
let key = (app.session.revision(), kind, name.to_string());
|
||||
if let Some((k, rows)) = &app.properties
|
||||
&& *k == key
|
||||
{
|
||||
return Some(rows.clone());
|
||||
}
|
||||
let v = browser::definition(&app.session.model, kind, name)?;
|
||||
let rows: Vec<(String, String)> = v
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(k, _)| k.as_str() != "name")
|
||||
.map(|(k, field)| (k.replace('_', " "), value_text(field)))
|
||||
.collect();
|
||||
let rows = std::sync::Arc::new(rows);
|
||||
app.properties = Some((key, rows.clone()));
|
||||
Some(rows)
|
||||
}
|
||||
|
||||
/// The selected definition's fields, with Edit (its form, filled in) and Delete.
|
||||
fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name: &str) {
|
||||
let Some(rows) = property_rows(app, kind, name) else { return };
|
||||
let mut deselect = false;
|
||||
heading(ui, t, Level::Title, |ui| {
|
||||
ui.label(RichText::new(kind.label().to_uppercase()).font(label_font(ui.ctx(), 12.0)));
|
||||
ui.label(RichText::new(name).size(12.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
deselect = ui.small_button("×").on_hover_text("Deselect").clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("mc_properties").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||||
for (k, text) in rows.iter() {
|
||||
ui.label(RichText::new(k).color(t.text_dim));
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let (mut edit, mut delete) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
edit = ui.button("Edit…").on_hover_text(kind.command("edit")).clicked();
|
||||
delete = ui.button("Delete").on_hover_text(kind.command("delete")).clicked();
|
||||
});
|
||||
if deselect {
|
||||
app.selected = None;
|
||||
} else if edit && let Some(v) = browser::definition(&app.session.model, kind, name) {
|
||||
app.open_form(&kind.command("edit"), v);
|
||||
} else if delete && app.run(&kind.command("delete"), json!({"name": name})).is_ok() {
|
||||
app.selected = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's value as short text: numbers and strings as they are, long lists as their length
|
||||
/// and first items, other lists and objects as compact JSON (long ones shortened).
|
||||
fn value_text(v: &Value) -> String {
|
||||
const MAX: usize = 160;
|
||||
/// Lists longer than this show their length and first items.
|
||||
const LIST: usize = 12;
|
||||
let s = match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "—".into(),
|
||||
Value::Array(a) if a.len() > LIST => {
|
||||
let first: Vec<String> = a.iter().take(LIST).map(Value::to_string).collect();
|
||||
format!("{} items: {}, …", a.len(), first.join(", "))
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
if s.chars().count() > MAX { format!("{}…", s.chars().take(MAX).collect::<String>()) } else { s }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn long_values_are_shortened() {
|
||||
assert_eq!(value_text(&json!("Steel")), "Steel");
|
||||
assert_eq!(value_text(&json!([1, 2])), "[1,2]");
|
||||
assert_eq!(value_text(&Value::Null), "—");
|
||||
let long = json!((0..500).collect::<Vec<_>>());
|
||||
assert!(value_text(&long).starts_with("500 items: 0, 1, 2"));
|
||||
let text = json!({"a": "x".repeat(400)});
|
||||
assert!(value_text(&text).ends_with('…') && value_text(&text).chars().count() == 161);
|
||||
}
|
||||
}
|
||||
@@ -115,9 +115,37 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"model": {"nodes": app.session.model.nodes().len(), "elements": app.session.model.elements().len()},
|
||||
"results": app.session.results_report(),
|
||||
"probe": app.probe,
|
||||
"busy": app.busy(),
|
||||
"selected": app.selected.as_ref().map(|(k, n)| json!({"kind": k, "name": n})),
|
||||
"nav": app.nav,
|
||||
"layout": app.layout.iter().map(|(k, r)| (k.to_string(), json!([r.left(), r.top(), r.width(), r.height()]))).collect::<serde_json::Map<String, Value>>(),
|
||||
"prompt": app.prompt.as_ref().map(|p| match p {
|
||||
crate::files::Prompt::Unsaved(next) => json!({"unsaved": match next {
|
||||
crate::files::Replace::File(p) => p.clone(),
|
||||
crate::files::Replace::Sample(n) => format!("sample {n}"),
|
||||
}}),
|
||||
crate::files::Prompt::Recover => json!("recover"),
|
||||
crate::files::Prompt::Samples => json!("samples"),
|
||||
}),
|
||||
"recent": app.recent.paths,
|
||||
"version": crate::VERSION,
|
||||
})),
|
||||
"ui.toolbar" => ok(crate::toolbar::describe(app)),
|
||||
"ui.select" => {
|
||||
// Select a definition as a browser click does (its properties show on the right).
|
||||
let kind = p.get("kind").cloned().and_then(|k| serde_json::from_value::<crate::browser::Def>(k).ok());
|
||||
match (kind, s("name")) {
|
||||
(Some(kind), Some(name)) if crate::browser::definition(&app.session.model, kind, name).is_some() => {
|
||||
app.selected = Some((kind, name.to_string()));
|
||||
ok(Value::Null)
|
||||
}
|
||||
(Some(_), Some(name)) => err(format!("no such definition `{name}`")),
|
||||
_ => {
|
||||
app.selected = None;
|
||||
ok(Value::Null)
|
||||
}
|
||||
}
|
||||
}
|
||||
"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()) {
|
||||
@@ -129,7 +157,14 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
}
|
||||
match serde_json::from_value::<UiState>(cur) {
|
||||
Ok(u) => {
|
||||
Ok(mut u) => {
|
||||
// The workspace and its tab stay together: a tab brings its workspace, a
|
||||
// workspace alone its first tab.
|
||||
if p.get("tab").is_some() {
|
||||
u.select_tab(u.tab);
|
||||
} else if p.get("workspace").is_some() {
|
||||
u.select_workspace(u.workspace);
|
||||
}
|
||||
app.ui = u;
|
||||
ok(json!(app.ui))
|
||||
}
|
||||
@@ -140,8 +175,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
match s("view").unwrap_or("iso") {
|
||||
"fit" => app.fit_view(),
|
||||
"home" => {
|
||||
app.set_view(StandardView::Iso);
|
||||
app.fit_view();
|
||||
app.home_view();
|
||||
}
|
||||
v => match StandardView::parse(v) {
|
||||
Some(sv) => app.set_view(sv),
|
||||
@@ -152,7 +186,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"ui.start" => {
|
||||
let Some(id) = s("command") else { return err("missing `command`") };
|
||||
match app.start(id) {
|
||||
match app.start_programmatic(id) {
|
||||
Ok(()) => ok(json!({"dialog": app.dialog.as_ref().map(|d| d.command)})),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
@@ -173,6 +207,42 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error}))))
|
||||
}
|
||||
"ui.prompt" => {
|
||||
let Some(prompt) = app.prompt.clone() else { return err("no prompt is open") };
|
||||
match (prompt, s("answer")) {
|
||||
(crate::files::Prompt::Unsaved(_), Some(a @ ("save" | "discard" | "cancel"))) => {
|
||||
let answer = match a {
|
||||
"save" => crate::files::Answer::Save,
|
||||
"discard" => crate::files::Answer::Discard,
|
||||
_ => crate::files::Answer::Cancel,
|
||||
};
|
||||
app.answer_unsaved(answer);
|
||||
ok(json!({"busy": app.busy()}))
|
||||
}
|
||||
(crate::files::Prompt::Samples, Some("open")) => match s("name") {
|
||||
Some(name) => {
|
||||
app.prompt = None;
|
||||
app.open_sample(name);
|
||||
ok(json!({"prompt": app.prompt.is_some()}))
|
||||
}
|
||||
None => err("missing `name` (a sample)"),
|
||||
},
|
||||
(crate::files::Prompt::Recover | crate::files::Prompt::Samples, Some("close" | "cancel")) => {
|
||||
app.prompt = None;
|
||||
ok(Value::Null)
|
||||
}
|
||||
(crate::files::Prompt::Recover, Some("recover")) => match s("id") {
|
||||
Some(id) => {
|
||||
app.recover(id);
|
||||
ok(json!({"busy": app.busy()}))
|
||||
}
|
||||
None => err("missing `id` (from file.recovery.list)"),
|
||||
},
|
||||
(_, a) => err(format!(
|
||||
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; recover: recover with id, close; samples: open with name, close)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
"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);
|
||||
@@ -243,6 +313,12 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
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.fullscreen" => {
|
||||
// Into or out of full screen (`on`, default true); the title bar follows next frame.
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(true);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
|
||||
ok(json!({"fullscreen": on}))
|
||||
}
|
||||
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
|
||||
"ui.capture" => {
|
||||
let live = window_size(ctx);
|
||||
@@ -432,6 +508,9 @@ mod tests {
|
||||
"ui.render",
|
||||
"ui.capture",
|
||||
"ui.toolbar",
|
||||
"ui.prompt",
|
||||
"ui.select",
|
||||
"ui.fullscreen",
|
||||
] {
|
||||
for p in &hostile {
|
||||
let _ = call(&mut app, method, p.clone());
|
||||
@@ -451,6 +530,11 @@ mod tests {
|
||||
let soon = entries.iter().find(|e| e["id"] == "mesh.order.quadratic").unwrap();
|
||||
assert_eq!((soon["coming_soon"].as_str(), soon["command"].is_null()), (Some("M2"), true));
|
||||
assert_eq!(call(&mut app, "ui.set", json!({"tab": "nope"}))["ok"], false);
|
||||
// A workspace brings its first tab; a tab brings its workspace.
|
||||
let r = call(&mut app, "ui.set", json!({"workspace": "model"}));
|
||||
assert_eq!((r["result"]["workspace"].as_str(), r["result"]["tab"].as_str()), (Some("model"), Some("properties")));
|
||||
let r = call(&mut app, "ui.set", json!({"tab": "solve"}));
|
||||
assert_eq!(r["result"]["workspace"], "analysis");
|
||||
let r = call(&mut app, "ui.start", json!({"command": "mesh.order.quadratic"}));
|
||||
assert_eq!((r["ok"].as_bool(), r["error"].as_str()), (Some(false), Some("Quadratic is coming soon (M2)")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
//! Files the user opens and saves: the host's native file pickers ([`Services`]), opening off the
|
||||
//! window thread, drag and drop, the recent-files list, and the prompts around them (unsaved
|
||||
//! changes, autosaved models to recover).
|
||||
//!
|
||||
//! Only user actions reach a picker: a click (toolbar, title bar, File menu), a shortcut or a drop.
|
||||
//! Programmatic calls (`engine.execute`, the control channel's `ui.start`) run commands with the
|
||||
//! paths they are given and never open a native dialog. Without a picker service (tests, the
|
||||
//! offscreen capture, the web), the same entries open their command's parameter form instead.
|
||||
//!
|
||||
//! A file opens on a copy of the session in a worker thread; the window keeps drawing and shows
|
||||
//! the file's progress with Cancel. The finished copy replaces the session only when the command
|
||||
//! succeeded, so a failed or cancelled open leaves the model as it was. Structure follows
|
||||
//! SolidCraft's `crates/ui-egui/src/file_load.rs` and `documents.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use modelcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ModelApp;
|
||||
|
||||
/// A file type a picker offers: a name and its extensions (without the dot).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
pub name: &'static str,
|
||||
pub extensions: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// The host picks a file to open from the filters (the first is selected), or `None` when the
|
||||
/// user cancelled.
|
||||
pub type PickOpen = Box<dyn Fn(&[Filter]) -> Option<String>>;
|
||||
/// The host picks where to save, starting from a file name; `None` when the user cancelled.
|
||||
pub type PickSave = Box<dyn Fn(&str, &[Filter]) -> Option<String>>;
|
||||
|
||||
/// What the host provides: native file pickers and where the recent-files list is kept. The
|
||||
/// desktop app installs them; tests answer the pickers with closures.
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
pub pick_open: Option<PickOpen>,
|
||||
pub pick_save: Option<PickSave>,
|
||||
/// The recent-files list's file (JSON); `None` keeps the list for this run only.
|
||||
pub recent_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const MODEL: Filter = Filter { name: "ModelCraft model", extensions: &["modelcraft"] };
|
||||
const DESIGN: Filter = Filter { name: "SolidCraft design", extensions: &["solidcraft"] };
|
||||
const MESH: Filter = Filter { name: "Face-tagged mesh (3MF)", extensions: &["3mf", "3MF"] };
|
||||
const ANY: Filter = Filter { name: "Models, designs and tagged meshes", extensions: &["modelcraft", "solidcraft", "3mf", "3MF"] };
|
||||
|
||||
/// The Open picker's filters: everything ModelCraft opens, then each type.
|
||||
pub const OPEN_FILTERS: &[Filter] = &[ANY, MODEL, DESIGN, MESH];
|
||||
|
||||
/// What opening a file does with it, from its extension.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// A `.modelcraft` model replaces the session (`file.open`).
|
||||
Model,
|
||||
/// A `.solidcraft` design becomes the geometry (`geometry.open`).
|
||||
Design,
|
||||
/// A `.3mf` face-tagged mesh is added as a surface (`geometry.import.mesh`).
|
||||
Mesh,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// The kind of a file by its extension (any case); `None` for a file ModelCraft does not open.
|
||||
pub fn of(path: &str) -> Option<Kind> {
|
||||
let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"modelcraft" => Some(Kind::Model),
|
||||
"solidcraft" => Some(Kind::Design),
|
||||
"3mf" => Some(Kind::Mesh),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Model => "file.open",
|
||||
Kind::Design => "geometry.open",
|
||||
Kind::Mesh => "geometry.import.mesh",
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker's filters when the user asked for this kind (its own type first).
|
||||
fn filters(self) -> &'static [Filter] {
|
||||
match self {
|
||||
Kind::Model => &[MODEL, ANY],
|
||||
Kind::Design => &[DESIGN, ANY],
|
||||
Kind::Mesh => &[MESH, ANY],
|
||||
}
|
||||
}
|
||||
|
||||
/// What the window says while it opens and once it has.
|
||||
fn verbs(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Kind::Model | Kind::Design => ("Opening", "Opened"),
|
||||
Kind::Mesh => ("Importing", "Imported"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a command opens, if it is one the File menu handles with a picker.
|
||||
fn kind_of_command(command: &str) -> Option<Kind> {
|
||||
[Kind::Model, Kind::Design, Kind::Mesh].into_iter().find(|k| k.command() == command)
|
||||
}
|
||||
|
||||
/// Most entries the recent-files list keeps, and the longest path it keeps.
|
||||
pub const MAX_RECENT: usize = 10;
|
||||
const MAX_RECENT_PATH: usize = 4096;
|
||||
/// Largest recent-files file read.
|
||||
const MAX_RECENT_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// Recently opened and saved files, newest first.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Recent {
|
||||
pub paths: Vec<String>,
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Recent {
|
||||
/// The list kept in `file` (an unreadable or malformed file gives an empty list).
|
||||
pub fn load(file: Option<PathBuf>) -> Recent {
|
||||
let paths = file
|
||||
.as_ref()
|
||||
.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_RECENT_BYTES))
|
||||
.and_then(|f| std::fs::read(f).ok())
|
||||
.and_then(|b| serde_json::from_slice::<Vec<String>>(&b).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|p| !p.is_empty() && p.len() <= MAX_RECENT_PATH && Kind::of(p).is_some())
|
||||
.take(MAX_RECENT)
|
||||
.collect();
|
||||
Recent { paths, file }
|
||||
}
|
||||
|
||||
/// Put `path` first and save the list.
|
||||
pub fn add(&mut self, path: &str) {
|
||||
if path.is_empty() || path.len() > MAX_RECENT_PATH {
|
||||
return;
|
||||
}
|
||||
self.paths.retain(|p| p != path);
|
||||
self.paths.insert(0, path.to_string());
|
||||
self.paths.truncate(MAX_RECENT);
|
||||
self.save();
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
let Some(file) = &self.file else { return };
|
||||
let write = || -> std::io::Result<()> {
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(file, serde_json::to_vec_pretty(&self.paths).unwrap_or_default())
|
||||
};
|
||||
if let Err(e) = write() {
|
||||
log::warn!("recent files {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command running in the background on a copy of the session: a file opening, a solve.
|
||||
pub struct Loading {
|
||||
/// What the window shows while it runs ("Opening beam.modelcraft").
|
||||
pub title: String,
|
||||
/// The status when it succeeded ("Opened beam.modelcraft").
|
||||
done: String,
|
||||
/// The file it opens, for the recent list.
|
||||
recent: Option<String>,
|
||||
/// Whether it brings a different model or new geometry, which the view then frames.
|
||||
fit: bool,
|
||||
/// The finished session copy and the command's reply.
|
||||
rx: Receiver<(Box<Session>, Result<Value, String>)>,
|
||||
}
|
||||
|
||||
/// What waits for an answer about unsaved changes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Replace {
|
||||
/// A `.modelcraft` file.
|
||||
File(String),
|
||||
/// A sample (`file.sample`).
|
||||
Sample(&'static str),
|
||||
}
|
||||
|
||||
impl Replace {
|
||||
fn name(&self) -> String {
|
||||
match self {
|
||||
Replace::File(p) => file_name(p),
|
||||
Replace::Sample(n) => modelcraft_engine::sample::find(n).map_or_else(|| (*n).to_string(), |s| format!("the {} sample", s.label)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A question the window asks before going on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Prompt {
|
||||
/// Opening a model or a sample discards the current one's unsaved changes.
|
||||
Unsaved(Replace),
|
||||
/// Autosaved models to recover (`file.recovery.list`).
|
||||
Recover,
|
||||
/// The samples to open (`file.sample`).
|
||||
Samples,
|
||||
}
|
||||
|
||||
/// The answer to [`Prompt::Unsaved`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
Save,
|
||||
Discard,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The file name a model is saved under by default.
|
||||
fn model_name(session: &Session) -> String {
|
||||
session.path.as_deref().and_then(|p| Path::new(p).file_stem()).and_then(|s| s.to_str()).map_or_else(|| "Untitled".to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// `path` with `ext` added when it has no extension of that name (some pickers leave it off).
|
||||
fn with_extension(path: String, ext: &str) -> String {
|
||||
let has = Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case(ext));
|
||||
if has { path } else { format!("{path}.{ext}") }
|
||||
}
|
||||
|
||||
/// Run `work` off the window thread (inline on the web, which has no threads).
|
||||
fn off_thread(name: String, work: impl FnOnce() + Send + 'static) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Err(e) = std::thread::Builder::new().name(name.clone()).spawn(work) {
|
||||
// The work went with the closure: a job then reports that it could not start.
|
||||
log::warn!("{name}: no worker thread: {e}");
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = name;
|
||||
work();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `command` on `session` off the window thread.
|
||||
fn spawn(session: Session, command: &'static str, params: Value) -> Receiver<(Box<Session>, Result<Value, String>)> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
off_thread(format!("modelcraft {command}"), move || {
|
||||
let mut s = session;
|
||||
let r = s.execute(command, ¶ms).map_err(|e| e.to_string());
|
||||
let _ = tx.send((Box::new(s), r));
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
impl ModelApp {
|
||||
/// The model's name: its file's stem, or "Untitled".
|
||||
pub fn model_name(&self) -> String {
|
||||
model_name(&self.session)
|
||||
}
|
||||
|
||||
/// What is running in the background ("Opening beam.modelcraft", "Solving"), if anything.
|
||||
pub fn busy(&self) -> Option<&str> {
|
||||
self.loading.as_ref().map(|l| l.title.as_str())
|
||||
}
|
||||
|
||||
/// `Err` (and the status says so) while a background job runs: commands wait for it.
|
||||
pub fn ensure_idle(&mut self) -> Result<(), String> {
|
||||
let Some(busy) = self.busy() else { return Ok(()) };
|
||||
let e = format!("{busy}: wait for it or cancel it");
|
||||
self.set_status(e.clone(), true);
|
||||
Err(e)
|
||||
}
|
||||
|
||||
/// Run `command` in the background; the session it ran on replaces this one when it
|
||||
/// succeeds. A command that brings a whole model (`fresh`: opening a model, recovering one)
|
||||
/// starts from an empty session, the others from a copy of this one. One at a time.
|
||||
fn begin_job(&mut self, command: &'static str, params: Value, fresh: bool, title: String, done: String, recent: Option<String>) -> bool {
|
||||
if self.ensure_idle().is_err() {
|
||||
return false;
|
||||
}
|
||||
let base = if fresh { Session::default() } else { self.session.clone() };
|
||||
let rx = spawn(base, command, params);
|
||||
self.set_status(format!("{title}…"), false);
|
||||
let fit = fresh || recent.is_some();
|
||||
self.loading = Some(Loading { title, done, recent, fit, rx });
|
||||
true
|
||||
}
|
||||
|
||||
/// File > Open: pick a model, design or tagged mesh and open it. `None` asks for any of them.
|
||||
/// Without a picker service the command's form opens instead.
|
||||
pub fn open_dialog(&mut self, kind: Option<Kind>) {
|
||||
let Some(pick) = self.services.pick_open.as_ref() else {
|
||||
self.open_form(kind.unwrap_or(Kind::Model).command(), json!({}));
|
||||
return;
|
||||
};
|
||||
if let Some(path) = pick(kind.map_or(OPEN_FILTERS, Kind::filters)) {
|
||||
self.open_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether replacing the model would lose work: unsaved changes made by commands.
|
||||
fn has_unsaved_work(&self) -> bool {
|
||||
self.session.is_dirty() && !self.session.journal.is_empty()
|
||||
}
|
||||
|
||||
/// Open a file the user chose (picked, dropped or from the recent list) by its type. A model
|
||||
/// replacing unsaved changes asks first.
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
let Some(kind) = Kind::of(path) else {
|
||||
self.set_status(format!("{path}: ModelCraft opens .modelcraft models, .solidcraft designs and .3mf tagged meshes"), true);
|
||||
return;
|
||||
};
|
||||
if kind == Kind::Model && self.has_unsaved_work() {
|
||||
self.prompt = Some(Prompt::Unsaved(Replace::File(path.to_string())));
|
||||
return;
|
||||
}
|
||||
self.load(kind, path.to_string());
|
||||
}
|
||||
|
||||
/// Open `path` in the background; files dropped while another opens wait their turn.
|
||||
fn load(&mut self, kind: Kind, path: String) {
|
||||
if self.loading.is_some() {
|
||||
self.queued.push_back(path);
|
||||
return;
|
||||
}
|
||||
let name = file_name(&path);
|
||||
let (doing, done) = kind.verbs();
|
||||
self.begin_job(kind.command(), json!({"path": path}), kind == Kind::Model, format!("{doing} {name}"), format!("{done} {name}"), Some(path));
|
||||
}
|
||||
|
||||
/// Help › Samples (and the empty viewport): open a sample model, asking first when that
|
||||
/// would discard unsaved changes.
|
||||
pub fn open_sample(&mut self, name: &str) {
|
||||
let Some(sample) = modelcraft_engine::sample::find(name) else {
|
||||
self.set_status(format!("no sample `{name}`"), true);
|
||||
return;
|
||||
};
|
||||
if self.has_unsaved_work() {
|
||||
self.prompt = Some(Prompt::Unsaved(Replace::Sample(sample.name)));
|
||||
return;
|
||||
}
|
||||
self.load_sample(sample.name);
|
||||
}
|
||||
|
||||
fn load_sample(&mut self, name: &str) {
|
||||
let Some(sample) = modelcraft_engine::sample::find(name) else { return };
|
||||
if self.run("file.sample", json!({"name": sample.name})).is_ok() {
|
||||
self.model_changed();
|
||||
self.fit_view();
|
||||
let next = if self.session.model.steps().is_empty() { "" } else { " — Solve shows its results" };
|
||||
self.set_status(format!("Opened the {} sample{next}", sample.label), false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve every step (`solve.run`) in the background; the results come up when it is done.
|
||||
pub fn solve_all(&mut self) {
|
||||
if self.session.model.steps().is_empty() {
|
||||
self.set_status("Nothing to solve: the model has no steps (Steps › Step, or open a sample from Help › Samples)", true);
|
||||
return;
|
||||
}
|
||||
let n = self.session.model.steps().len();
|
||||
let done = format!("Solved {n} step{}", if n == 1 { "" } else { "s" });
|
||||
self.begin_job("solve.run", json!({}), false, "Solving".into(), done, None);
|
||||
}
|
||||
|
||||
/// Recover an autosaved model (`file.recover`) in the background.
|
||||
pub fn recover(&mut self, id: &str) {
|
||||
self.prompt = None;
|
||||
self.begin_job(
|
||||
"file.recover",
|
||||
json!({"id": id}),
|
||||
true,
|
||||
"Recovering the autosaved model".into(),
|
||||
"Recovered the autosaved model".into(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop waiting for the background job; the model stays as it was.
|
||||
pub fn cancel_loading(&mut self) {
|
||||
if let Some(l) = self.loading.take() {
|
||||
self.queued.clear();
|
||||
self.set_status(format!("Cancelled: {}", l.title), false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in a finished background job (called every frame); `true` while one is running.
|
||||
pub fn poll_loading(&mut self) -> bool {
|
||||
let Some(l) = &self.loading else { return false };
|
||||
let outcome = match l.rx.try_recv() {
|
||||
Ok(done) => Some(done),
|
||||
Err(TryRecvError::Empty) => return true,
|
||||
Err(TryRecvError::Disconnected) => None,
|
||||
};
|
||||
let Some(l) = self.loading.take() else { return false };
|
||||
match outcome {
|
||||
Some((session, Ok(reply))) => {
|
||||
// The session it replaces may be large: it is freed off the window thread.
|
||||
let old = std::mem::replace(&mut self.session, *session);
|
||||
off_thread("modelcraft drop".into(), move || drop(old));
|
||||
self.model_changed();
|
||||
if l.fit {
|
||||
self.fit_view();
|
||||
}
|
||||
if let Some(p) = &l.recent {
|
||||
self.recent.add(p);
|
||||
}
|
||||
let warnings: Vec<&str> = reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
if warnings.is_empty() {
|
||||
self.set_status(l.done, false);
|
||||
} else {
|
||||
self.set_status(format!("{} — {}", l.done, warnings.join("; ")), true);
|
||||
}
|
||||
}
|
||||
Some((_, Err(e))) => {
|
||||
let what = l.recent.as_deref().map_or_else(|| l.title.clone(), file_name);
|
||||
self.set_status(format!("{what}: {e}"), true);
|
||||
}
|
||||
None => self.set_status(format!("{}: stopped unexpectedly", l.title), true),
|
||||
}
|
||||
if let Some(next) = self.queued.pop_front() {
|
||||
self.open_path(&next);
|
||||
}
|
||||
self.loading.is_some()
|
||||
}
|
||||
|
||||
/// Block until the background open finishes (tests and the offscreen harness).
|
||||
pub fn wait_loaded(&mut self) {
|
||||
for _ in 0..6000 {
|
||||
if !self.poll_loading() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// File > Save: to the model's file, or Save As when it has none. Returns whether it saved.
|
||||
pub fn save(&mut self) -> bool {
|
||||
if self.session.path.is_none() {
|
||||
return self.save_as();
|
||||
}
|
||||
let saved = self.run("file.save", json!({})).is_ok();
|
||||
self.saved_to_recent(saved)
|
||||
}
|
||||
|
||||
/// After a save: the model's file goes first in the recent list.
|
||||
fn saved_to_recent(&mut self, saved: bool) -> bool {
|
||||
if saved && let Some(p) = self.session.path.clone() {
|
||||
self.recent.add(&p);
|
||||
}
|
||||
saved
|
||||
}
|
||||
|
||||
/// File > Save As: pick a new file for the model. Without a picker the command's form opens.
|
||||
pub fn save_as(&mut self) -> bool {
|
||||
let Some(pick) = self.services.pick_save.as_ref() else {
|
||||
self.open_form("file.save_as", json!({}));
|
||||
return false;
|
||||
};
|
||||
let Some(path) = pick(&format!("{}.modelcraft", self.model_name()), &[MODEL]) else { return false };
|
||||
let path = with_extension(path, "modelcraft");
|
||||
let saved = self.run("file.save_as", json!({"path": path})).is_ok();
|
||||
self.saved_to_recent(saved)
|
||||
}
|
||||
|
||||
/// Write Deck: pick where to write the model as a `format` deck. Without a picker the
|
||||
/// command's form opens with the format filled in.
|
||||
pub fn write_deck(&mut self, format: &str) {
|
||||
let Some(f) = modelcraft_decks::format(format) else {
|
||||
self.set_status(format!("no {format} deck writer yet"), true);
|
||||
return;
|
||||
};
|
||||
let Some(pick) = self.services.pick_save.as_ref() else {
|
||||
self.open_form("deck.write", json!({"format": format}));
|
||||
return;
|
||||
};
|
||||
let exts: &'static [&'static str] = match f.extension {
|
||||
"inp" => &["inp"],
|
||||
"bdf" => &["bdf", "dat", "nas"],
|
||||
"k" => &["k", "key", "dyn"],
|
||||
_ => &[],
|
||||
};
|
||||
let filter = Filter { name: "Solver deck", extensions: exts };
|
||||
let Some(path) = pick(&format!("{}.{}", self.model_name(), f.extension), &[filter]) else { return };
|
||||
let path = if exts.iter().any(|e| Path::new(&path).extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case(e))) {
|
||||
path
|
||||
} else {
|
||||
with_extension(path, f.extension)
|
||||
};
|
||||
let _ = self.run("deck.write", json!({"format": format, "path": path}));
|
||||
}
|
||||
|
||||
/// The autosaved models that can be recovered (`file.recovery.list`), newest first.
|
||||
pub fn recoverable(&mut self) -> Vec<Value> {
|
||||
self.session.execute("file.recovery.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Answer the unsaved-changes prompt.
|
||||
pub fn answer_unsaved(&mut self, answer: Answer) {
|
||||
let Some(Prompt::Unsaved(next)) = self.prompt.clone() else { return };
|
||||
self.prompt = None;
|
||||
let go = match answer {
|
||||
Answer::Save => self.save(),
|
||||
Answer::Discard => true,
|
||||
Answer::Cancel => false,
|
||||
};
|
||||
if !go {
|
||||
return;
|
||||
}
|
||||
match next {
|
||||
Replace::File(path) => {
|
||||
if let Some(kind) = Kind::of(&path) {
|
||||
self.load(kind, path);
|
||||
}
|
||||
}
|
||||
Replace::Sample(name) => self.load_sample(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click on a file entry does, when it is one: open, save, write a deck or recover
|
||||
/// (through the host's pickers when `pickers`), choose a sample, or solve in the background.
|
||||
/// `false` for every other command, and for picker entries without pickers (their form
|
||||
/// opens instead).
|
||||
pub(crate) fn file_action(&mut self, command: &str, preset: &Value, pickers: bool) -> bool {
|
||||
let can_save = pickers && self.services.pick_save.is_some();
|
||||
match command {
|
||||
"file.save" if self.session.path.is_some() || can_save => {
|
||||
self.save();
|
||||
}
|
||||
"file.save_as" if can_save => {
|
||||
self.save_as();
|
||||
}
|
||||
"file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover),
|
||||
"file.sample" => self.prompt = Some(Prompt::Samples),
|
||||
"solve.run" => self.solve_all(),
|
||||
"deck.write" if can_save => match preset.get("format").and_then(Value::as_str) {
|
||||
Some(f) => self.write_deck(f),
|
||||
None => return false,
|
||||
},
|
||||
other => match kind_of_command(other) {
|
||||
Some(kind) if pickers && self.services.pick_open.is_some() => self.open_dialog(Some(kind)),
|
||||
_ => return false,
|
||||
},
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Files dropped on the window open in turn; a hovered drop shows where it goes.
|
||||
pub(crate) fn dropped_files(&mut self, ctx: &egui::Context) {
|
||||
let dropped: Vec<String> = ctx
|
||||
.input(|i| i.raw.dropped_files.iter().filter_map(|f| Some(f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty())).collect());
|
||||
// A model first: it replaces the session, then designs and meshes go into it.
|
||||
let (models, rest): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| Kind::of(p) == Some(Kind::Model));
|
||||
for p in models.into_iter().take(1).chain(rest) {
|
||||
self.open_path(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last part of a path, for messages.
|
||||
pub fn file_name(path: &str) -> String {
|
||||
Path::new(path).file_name().and_then(|n| n.to_str()).map_or_else(|| path.to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// The prompts (unsaved changes, recover), the loading overlay and the drop hint.
|
||||
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
let t = crate::theme::Tokens::get(app.ui.dark);
|
||||
let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty());
|
||||
if hovering || app.loading.is_some() {
|
||||
let text = match app.loading.as_ref() {
|
||||
Some(l) => format!("{}…", l.title),
|
||||
None => "Drop to open: .modelcraft models, .solidcraft designs, .3mf tagged meshes".into(),
|
||||
};
|
||||
let mut cancel = false;
|
||||
egui::Area::new(egui::Id::new("mc_loading")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.overlay).inner_margin(egui::Margin::symmetric(16, 12)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if app.loading.is_some() {
|
||||
ui.spinner();
|
||||
}
|
||||
ui.label(egui::RichText::new(text).color(t.overlay_text));
|
||||
if app.loading.is_some() {
|
||||
cancel = ui.button("Cancel").clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
if cancel {
|
||||
app.cancel_loading();
|
||||
}
|
||||
if app.loading.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
match app.prompt.clone() {
|
||||
Some(Prompt::Unsaved(next)) => {
|
||||
let mut answer = None;
|
||||
crate::frame::window(ctx, "Unsaved changes", crate::frame::Width::Normal).id(egui::Id::new("mc_unsaved")).show(ctx, |ui| {
|
||||
ui.label(format!("Save the changes to {} before opening {}?", app.model_name(), next.name()));
|
||||
ui.label(egui::RichText::new("Without saving, they are lost.").color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
answer = Some(Answer::Save);
|
||||
}
|
||||
if ui.button("Don't Save").clicked() {
|
||||
answer = Some(Answer::Discard);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
answer = Some(Answer::Cancel);
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
answer = Some(Answer::Cancel);
|
||||
}
|
||||
if let Some(a) = answer {
|
||||
app.answer_unsaved(a);
|
||||
}
|
||||
}
|
||||
Some(Prompt::Recover) => {
|
||||
let entries = app.recover_list(ctx);
|
||||
let (mut recover, mut discard, mut close) = (None, None, false);
|
||||
crate::frame::window(ctx, "Recover", crate::frame::Width::Wide).id(egui::Id::new("mc_recover")).show(ctx, |ui| {
|
||||
if entries.is_empty() {
|
||||
ui.label(egui::RichText::new("No autosaved models to recover.").color(t.text_dim));
|
||||
}
|
||||
egui::Grid::new("mc_recover_grid").num_columns(3).spacing(egui::vec2(10.0, 4.0)).show(ui, |ui| {
|
||||
for e in &entries {
|
||||
let id = e.get("id").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let name = e.get("path").and_then(Value::as_str).map_or_else(|| "Untitled".to_string(), file_name);
|
||||
let n = |k: &str| e.get(k).and_then(Value::as_u64).unwrap_or(0);
|
||||
ui.label(name);
|
||||
ui.label(egui::RichText::new(format!("{} nodes, {} elements", n("nodes"), n("elements"))).color(t.text_dim));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Recover").clicked() {
|
||||
recover = Some(id.clone());
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
discard = Some(id.clone());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
close = ui.button("Close").clicked();
|
||||
});
|
||||
if let Some(id) = recover {
|
||||
app.recover(&id);
|
||||
} else if let Some(id) = discard {
|
||||
let _ = app.run("file.recovery.discard", json!({"id": id}));
|
||||
ctx.data_mut(|d| d.remove::<Vec<Value>>(egui::Id::new("mc_recover_entries")));
|
||||
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.prompt = None;
|
||||
ctx.data_mut(|d| d.remove::<Vec<Value>>(egui::Id::new("mc_recover_entries")));
|
||||
}
|
||||
}
|
||||
Some(Prompt::Samples) => {
|
||||
let (mut open, mut close) = (None, false);
|
||||
crate::frame::window(ctx, "Samples", crate::frame::Width::Normal).id(egui::Id::new("mc_samples")).show(ctx, |ui| {
|
||||
sample_rows(ui, &t, &mut open);
|
||||
ui.add_space(6.0);
|
||||
close = ui.button("Close").clicked();
|
||||
});
|
||||
if let Some(name) = open {
|
||||
app.prompt = None;
|
||||
app.open_sample(name);
|
||||
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.prompt = None;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row per sample: its name, what it shows and an Open button; `open` gets the one clicked.
|
||||
pub fn sample_rows(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>) {
|
||||
let width = ui.available_width().clamp(280.0, 420.0);
|
||||
for sample in modelcraft_engine::sample::SAMPLES {
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width - 70.0);
|
||||
ui.label(egui::RichText::new(sample.label).strong());
|
||||
ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap());
|
||||
});
|
||||
if ui.button("Open").clicked() {
|
||||
*open = Some(sample.name);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelApp {
|
||||
/// The recover window's entries, read from disk once while it is open.
|
||||
fn recover_list(&mut self, ctx: &egui::Context) -> Vec<Value> {
|
||||
let key = egui::Id::new("mc_recover_entries");
|
||||
if let Some(v) = ctx.data(|d| d.get_temp::<Vec<Value>>(key)) {
|
||||
return v;
|
||||
}
|
||||
let v = self.recoverable();
|
||||
ctx.data_mut(|d| d.insert_temp(key, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// The File menu's rows: open, recent files, save, decks, samples, recover. The title bar's
|
||||
/// File menu.
|
||||
pub fn file_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get(app.ui.dark);
|
||||
let key = |k: &str| crate::appbar::shortcut(k);
|
||||
let row = |ui: &mut egui::Ui, label: &str, right: Option<&str>| crate::frame::menu_row(ui, &t, label, right, true).clicked();
|
||||
let mut act: Option<Box<dyn FnOnce(&mut ModelApp)>> = None;
|
||||
if row(ui, "Open…", Some(&key("O"))) {
|
||||
act = Some(Box::new(|app| app.open_dialog(None)));
|
||||
}
|
||||
if row(ui, "Samples…", None) {
|
||||
act = Some(Box::new(|app| app.prompt = Some(Prompt::Samples)));
|
||||
}
|
||||
if !app.recent.paths.is_empty() {
|
||||
crate::frame::menu_heading(ui, &t, "RECENT");
|
||||
for p in app.recent.paths.iter().take(6) {
|
||||
let name = file_name(p);
|
||||
if crate::frame::menu_row(ui, &t, &name, None, true).on_hover_text(p).clicked() {
|
||||
let p = p.clone();
|
||||
act = Some(Box::new(move |app| app.open_path(&p)));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if row(ui, "Save", Some(&key("S"))) {
|
||||
act = Some(Box::new(|app| {
|
||||
app.save();
|
||||
}));
|
||||
}
|
||||
if row(ui, "Save As…", Some(&key("Shift+S"))) {
|
||||
act = Some(Box::new(|app| {
|
||||
app.save_as();
|
||||
}));
|
||||
}
|
||||
ui.separator();
|
||||
crate::frame::menu_heading(ui, &t, "WRITE DECK");
|
||||
for c in modelcraft_engine::catalogue::CATALOGUE.iter().filter(|c| c.panel == "Decks") {
|
||||
let format = c.preset().get("format").and_then(Value::as_str).map(str::to_string);
|
||||
let soon = c.milestone().map(|m| format!("soon · {m}"));
|
||||
let enabled = format.is_some() && soon.is_none();
|
||||
if crate::frame::menu_row(ui, &t, &format!("{}…", c.label), soon.as_deref(), enabled).on_hover_text(c.tip).clicked()
|
||||
&& let Some(f) = format
|
||||
{
|
||||
act = Some(Box::new(move |app| app.write_deck(&f)));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if crate::frame::menu_row(ui, &t, "Recover…", None, true)
|
||||
.on_hover_text("Models autosaved by an app that crashed or closed with unsaved changes")
|
||||
.clicked()
|
||||
{
|
||||
act = Some(Box::new(|app| app.prompt = Some(Prompt::Recover)));
|
||||
}
|
||||
if let Some(f) = act {
|
||||
app.ui.menu = None;
|
||||
ui.close();
|
||||
f(app);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("modelcraft-files-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A picker that answers `path` and records the filters it was offered.
|
||||
fn answering(path: Option<String>, offered: Rc<RefCell<Vec<Vec<Filter>>>>) -> PickOpen {
|
||||
Box::new(move |f: &[Filter]| {
|
||||
offered.borrow_mut().push(f.to_vec());
|
||||
path.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_types_dispatch_to_their_command() {
|
||||
assert_eq!(Kind::of("a/b.modelcraft"), Some(Kind::Model));
|
||||
assert_eq!(Kind::of("B.SOLIDCRAFT"), Some(Kind::Design));
|
||||
assert_eq!(Kind::of("part.3MF").map(Kind::command), Some("geometry.import.mesh"));
|
||||
assert_eq!(Kind::of("part.step"), None);
|
||||
assert_eq!(Kind::of("noext"), None);
|
||||
assert_eq!(with_extension("a/b".into(), "modelcraft"), "a/b.modelcraft");
|
||||
assert_eq!(with_extension("a/b.MODELCRAFT".into(), "modelcraft"), "a/b.MODELCRAFT");
|
||||
}
|
||||
|
||||
/// Open (picker) → background open → the model, the recent list; Save As (picker) and Save.
|
||||
#[test]
|
||||
fn the_pickers_open_and_save_models() {
|
||||
let dir = temp("models");
|
||||
let saved = dir.join("beam.modelcraft").to_string_lossy().to_string();
|
||||
let mut first = ModelApp::new(Session::default());
|
||||
first.run_script(&modelcraft_engine::sample::script()).unwrap();
|
||||
// Save As through the save picker: the extension is added, the file becomes the model's.
|
||||
let bare = dir.join("beam").to_string_lossy().to_string();
|
||||
first.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| {
|
||||
assert_eq!((name, f.first().map(|f| f.extensions)), ("Untitled.modelcraft", Some(&["modelcraft"][..])));
|
||||
Some(bare.clone())
|
||||
}));
|
||||
first.start("file.save_as").unwrap();
|
||||
assert_eq!(first.session.path.as_deref(), Some(saved.as_str()));
|
||||
assert!(!first.session.is_dirty());
|
||||
assert_eq!(first.recent.paths, vec![saved.clone()]);
|
||||
|
||||
let offered = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.services.recent_path = None;
|
||||
app.services.pick_open = Some(answering(Some(saved.clone()), offered.clone()));
|
||||
app.start("file.open").unwrap();
|
||||
assert!(app.dialog.is_none(), "a picker, not the form");
|
||||
assert_eq!(offered.borrow()[0][0], MODEL, "Open Model offers models first");
|
||||
assert_eq!(app.busy(), Some("Opening beam.modelcraft"));
|
||||
app.wait_loaded();
|
||||
assert_eq!(app.session.model.elements().len(), first.session.model.elements().len());
|
||||
assert_eq!(app.session.path.as_deref(), Some(saved.as_str()));
|
||||
assert_eq!(app.recent.paths, vec![saved.clone()]);
|
||||
assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Opened beam.modelcraft", false)));
|
||||
// Save goes to the model's own file without asking.
|
||||
app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option<String> { panic!("Save asked for a file") }));
|
||||
app.run("mesh.node.add", serde_json::json!({"label": 99999, "xyz": [0, 0, 0]})).unwrap();
|
||||
assert!(app.save());
|
||||
assert!(!app.session.is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_pickers_and_failed_opens_leave_the_model() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.run("mesh.node.add", serde_json::json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
|
||||
let offered = Rc::new(RefCell::new(Vec::new()));
|
||||
app.services.pick_open = Some(answering(None, offered.clone()));
|
||||
app.open_dialog(None);
|
||||
assert_eq!(offered.borrow()[0], OPEN_FILTERS.to_vec(), "File > Open offers every type");
|
||||
assert!(app.loading.is_none());
|
||||
// A file that does not open: the model stays, the error is shown.
|
||||
let dir = temp("broken");
|
||||
let broken = dir.join("broken.solidcraft");
|
||||
std::fs::write(&broken, b"not a design").unwrap();
|
||||
app.open_path(&broken.to_string_lossy());
|
||||
app.wait_loaded();
|
||||
assert!(app.session.model.node(1).is_some());
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.starts_with("broken.solidcraft:")), "{:?}", app.status);
|
||||
assert!(app.recent.paths.is_empty());
|
||||
app.open_path("drawing.dxf");
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.error));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_model_over_unsaved_changes_asks_first() {
|
||||
let dir = temp("unsaved");
|
||||
let path = dir.join("other.modelcraft").to_string_lossy().to_string();
|
||||
let mut other = ModelApp::new(Session::default());
|
||||
other.run("mesh.node.add", serde_json::json!({"label": 5, "xyz": [0, 0, 0]})).unwrap();
|
||||
other.run("file.save_as", serde_json::json!({"path": path})).unwrap();
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.run("mesh.node.add", serde_json::json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
|
||||
app.open_path(&path);
|
||||
assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::File(path.clone()))));
|
||||
assert!(app.loading.is_none());
|
||||
app.answer_unsaved(Answer::Cancel);
|
||||
assert!(app.prompt.is_none() && app.loading.is_none() && app.session.model.node(1).is_some());
|
||||
app.open_path(&path);
|
||||
app.answer_unsaved(Answer::Discard);
|
||||
app.wait_loaded();
|
||||
assert!(app.session.model.node(5).is_some() && app.session.model.node(1).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decks_are_written_where_the_picker_says() {
|
||||
let dir = temp("decks");
|
||||
let target = dir.join("cantilever").to_string_lossy().to_string();
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.run_script(&modelcraft_engine::sample::cantilever()).unwrap();
|
||||
let t = target.clone();
|
||||
app.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| {
|
||||
assert_eq!((name, f.first().map(|f| f.extensions)), ("Untitled.inp", Some(&["inp"][..])));
|
||||
Some(t.clone())
|
||||
}));
|
||||
app.start("deck.write.abaqus").unwrap();
|
||||
assert!(app.dialog.is_none());
|
||||
let text = std::fs::read_to_string(format!("{target}.inp")).unwrap();
|
||||
assert!(text.contains("*NODE") || text.contains("*Node"), "{}", &text[..text.len().min(200)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recent_list_is_kept_newest_first_and_survives_bad_files() {
|
||||
let dir = temp("recent");
|
||||
let file = dir.join("recent.json");
|
||||
let mut r = Recent::load(Some(file.clone()));
|
||||
assert!(r.paths.is_empty());
|
||||
for i in 0..12 {
|
||||
r.add(&format!("/m/{i}.modelcraft"));
|
||||
}
|
||||
r.add("/m/3.modelcraft");
|
||||
let again = Recent::load(Some(file.clone()));
|
||||
assert_eq!(again.paths.len(), MAX_RECENT);
|
||||
assert_eq!(again.paths.first().map(String::as_str), Some("/m/3.modelcraft"));
|
||||
assert_eq!(again.paths.iter().filter(|p| *p == "/m/3.modelcraft").count(), 1);
|
||||
std::fs::write(&file, b"{not json").unwrap();
|
||||
assert!(Recent::load(Some(file.clone())).paths.is_empty());
|
||||
std::fs::write(&file, br#"["/x/a.modelcraft", "/x/b.exe", ""]"#).unwrap();
|
||||
assert_eq!(Recent::load(Some(file)).paths, vec!["/x/a.modelcraft".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A sample opens with nothing to save; Solve runs its steps in the background and the
|
||||
/// Results tab comes up with the first step's result.
|
||||
#[test]
|
||||
fn a_sample_solves_into_the_results_view() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.start("file.sample").unwrap();
|
||||
assert_eq!(app.prompt, Some(Prompt::Samples));
|
||||
app.prompt = None;
|
||||
app.open_sample("cantilever");
|
||||
assert!(!app.session.is_dirty() && !app.session.model.steps().is_empty());
|
||||
app.start("solve.run").unwrap();
|
||||
assert_eq!(app.busy(), Some("Solving"));
|
||||
assert!(app.run("model.info", serde_json::json!({})).is_err(), "commands wait for the solve");
|
||||
app.wait_loaded();
|
||||
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Solved 2 steps"));
|
||||
app.logic(&egui::Context::default());
|
||||
assert_eq!(app.ui.tab, modelcraft_engine::catalogue::Tab::Results);
|
||||
assert!(app.plot().is_some());
|
||||
// Over unsaved changes, opening a sample asks first.
|
||||
app.run("mesh.node.add", serde_json::json!({"label": 900000, "xyz": [0, 0, 0]})).unwrap();
|
||||
app.open_sample("impact");
|
||||
assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::Sample("impact"))));
|
||||
app.answer_unsaved(Answer::Discard);
|
||||
assert!(app.session.model.step("Impact").is_some());
|
||||
// A model without steps says so instead of solving nothing.
|
||||
let mut empty = ModelApp::new(Session::default());
|
||||
empty.solve_all();
|
||||
assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error));
|
||||
}
|
||||
|
||||
/// The control channel's `ui.start` never opens a picker: it opens the form.
|
||||
#[test]
|
||||
fn programmatic_starts_never_open_a_picker() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.services.pick_open = Some(Box::new(|_: &[Filter]| -> Option<String> { panic!("a picker opened") }));
|
||||
app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option<String> { panic!("a picker opened") }));
|
||||
app.start_programmatic("file.open").unwrap();
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("file.open"));
|
||||
app.start_programmatic("deck.write.abaqus").unwrap();
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("deck.write"));
|
||||
// In-window choices are not pickers: they behave as a click.
|
||||
app.start_programmatic("file.sample").unwrap();
|
||||
assert_eq!(app.prompt, Some(Prompt::Samples));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The one place windows and panel headings are made, so every prompt and panel shares the same
|
||||
//! width limits and heading bands.
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/ui-egui/src/frame.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use egui::{Align2, Ui, WidgetText, vec2};
|
||||
|
||||
/// Floating windows: prompts and panels, and those holding a table.
|
||||
pub const WINDOW_MAX_W: f32 = 460.0;
|
||||
pub const WINDOW_WIDE_MAX_W: f32 = 760.0;
|
||||
|
||||
/// How wide a window may get.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Width {
|
||||
Normal,
|
||||
Wide,
|
||||
}
|
||||
|
||||
/// A floating window, centred on the screen, shrink-wrapped to its content up to its width limit.
|
||||
pub fn window<'a>(ctx: &egui::Context, title: impl Into<WidgetText>, width: Width) -> egui::Window<'a> {
|
||||
let max = match width {
|
||||
Width::Normal => WINDOW_MAX_W,
|
||||
Width::Wide => WINDOW_WIDE_MAX_W,
|
||||
};
|
||||
let c = ctx.content_rect().center();
|
||||
egui::Window::new(title).collapsible(false).resizable(false).pivot(Align2::CENTER_CENTER).default_pos(c - vec2(0.0, 60.0)).max_width(max)
|
||||
}
|
||||
|
||||
/// Heading levels inside a panel: its title, and the sections below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
/// The panel's title row (`Tokens::heading_bg`).
|
||||
Title,
|
||||
/// A section heading (`Tokens::subheading_bg`).
|
||||
Section,
|
||||
}
|
||||
|
||||
/// Horizontal inner margin of a heading band, on each side.
|
||||
pub const HEADING_MARGIN: f32 = 4.0;
|
||||
|
||||
/// A heading row drawn on a band as wide as the panel; `add` lays out the row left to right.
|
||||
pub fn heading<R>(ui: &mut Ui, t: &crate::theme::Tokens, level: Level, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let fill = match level {
|
||||
Level::Title => t.heading_bg,
|
||||
Level::Section => t.subheading_bg,
|
||||
};
|
||||
let inner = (ui.available_width() - 2.0 * HEADING_MARGIN).max(0.0);
|
||||
egui::Frame::new()
|
||||
.fill(fill)
|
||||
.corner_radius(3)
|
||||
.inner_margin(egui::Margin::symmetric(HEADING_MARGIN as i8, 2))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(inner);
|
||||
ui.horizontal(add).inner
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A menu below `anchor`, open while [`crate::UiState::menu`] is `key` (so `ui.set {menu}` and
|
||||
/// offscreen captures open it too). A click on one of its rows, a click outside or Escape closes
|
||||
/// it. Returns where it was drawn.
|
||||
pub fn menu<R>(
|
||||
app: &mut crate::ModelApp,
|
||||
ui: &Ui,
|
||||
key: &str,
|
||||
anchor: egui::Rect,
|
||||
add: impl FnOnce(&mut crate::ModelApp, &mut Ui) -> R,
|
||||
) -> Option<egui::Rect> {
|
||||
popup(app, ui, key, anchor, egui::PopupCloseBehavior::CloseOnClick, add)
|
||||
}
|
||||
|
||||
/// As [`menu`], but clicks inside keep it open (settings with checkboxes).
|
||||
pub fn settings_menu<R>(
|
||||
app: &mut crate::ModelApp,
|
||||
ui: &Ui,
|
||||
key: &str,
|
||||
anchor: egui::Rect,
|
||||
add: impl FnOnce(&mut crate::ModelApp, &mut Ui) -> R,
|
||||
) -> Option<egui::Rect> {
|
||||
popup(app, ui, key, anchor, egui::PopupCloseBehavior::CloseOnClickOutside, add)
|
||||
}
|
||||
|
||||
fn popup<R>(
|
||||
app: &mut crate::ModelApp,
|
||||
ui: &Ui,
|
||||
key: &str,
|
||||
anchor: egui::Rect,
|
||||
close: egui::PopupCloseBehavior,
|
||||
add: impl FnOnce(&mut crate::ModelApp, &mut Ui) -> R,
|
||||
) -> Option<egui::Rect> {
|
||||
let mut open = app.ui.menu.as_deref() == Some(key);
|
||||
if !open {
|
||||
return None;
|
||||
}
|
||||
let id = egui::Id::new(("mc_menu", key));
|
||||
let shown =
|
||||
egui::Popup::new(id, ui.ctx().clone(), anchor, ui.layer_id()).kind(egui::PopupKind::Menu).open_bool(&mut open).close_behavior(close).show(
|
||||
|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
add(app, ui)
|
||||
},
|
||||
);
|
||||
if (!open || ui.input(|i| i.key_pressed(egui::Key::Escape))) && app.ui.menu.as_deref() == Some(key) {
|
||||
app.ui.menu = None;
|
||||
}
|
||||
shown.map(|r| r.response.rect)
|
||||
}
|
||||
|
||||
/// A menu row's width and height.
|
||||
pub const MENU_W: f32 = 240.0;
|
||||
const MENU_ROW_H: f32 = 24.0;
|
||||
|
||||
/// A menu row: its label, and its shortcut (or a note) at the right; dimmed and inert when
|
||||
/// disabled. Painted like the toolbar's drop-down rows.
|
||||
pub fn menu_row(ui: &mut Ui, t: &crate::theme::Tokens, label: &str, right: Option<&str>, enabled: bool) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(MENU_W, MENU_ROW_H), if enabled { egui::Sense::click() } else { egui::Sense::hover() });
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let clip = rect.shrink2(vec2(8.0, 0.0));
|
||||
ui.painter().with_clip_rect(clip).text(
|
||||
egui::pos2(rect.left() + 10.0, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
font.clone(),
|
||||
if enabled { t.text } else { t.text_dim },
|
||||
);
|
||||
if let Some(r) = right {
|
||||
ui.painter().text(egui::pos2(rect.right() - 10.0, rect.center().y), Align2::RIGHT_CENTER, r, egui::FontId::proportional(12.0), t.text_dim);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A menu section's small heading.
|
||||
pub fn menu_heading(ui: &mut Ui, t: &crate::theme::Tokens, text: &str) {
|
||||
ui.add_space(2.0);
|
||||
ui.label(egui::RichText::new(text).font(crate::theme::label_font(ui.ctx(), 10.5)).color(t.text_dim));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Windows are made in this file only, so none can skip the width limits.
|
||||
#[test]
|
||||
fn windows_come_from_the_frame_helper() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut bad = Vec::new();
|
||||
for e in std::fs::read_dir(&dir).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.extension().is_some_and(|x| x == "rs") && p.file_name().is_some_and(|n| n != "frame.rs") {
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
if text.contains(concat!("Window", "::new(")) {
|
||||
bad.push(p.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "use crate::frame::window instead of egui::Window::new in {bad:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Toolbar icons: original artwork drawn with VectorCraft (`assets/icons`, `cargo xtask icons`),
|
||||
//! embedded at build time and rasterised with resvg.
|
||||
//!
|
||||
//! Icons are drawn in four palette roles, each in one fixed colour (the dark theme's). Before
|
||||
//! rasterising, every role colour is replaced by the theme token it stands for, so the same SVG
|
||||
//! serves the dark and light themes and the greyed "coming soon" state. Textures are cached per
|
||||
//! Icons are drawn in four palette roles (line, accent, soft fill, warm), each in one fixed colour.
|
||||
//! Before rasterising, every role colour is replaced by the theme token it stands for (`icon`,
|
||||
//! `accent`, `icon_fill`, `warm`, as SolidCraft paints its icons), so the same SVG serves the dark
|
||||
//! and light themes and the greyed "coming soon" state. Icons named `ui.*` belong to the window
|
||||
//! itself (navigation bar, title bar), the others to catalogue entries. Textures are cached per
|
||||
//! icon, theme, state and pixel size.
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -30,7 +32,7 @@ pub fn svg(id: &str) -> Option<&'static str> {
|
||||
/// The colours the palette roles (line, accent, soft, warm) take in a theme, greyed when the
|
||||
/// entry is not available yet.
|
||||
pub fn colours(t: &Tokens, enabled: bool) -> [Color32; 4] {
|
||||
if enabled { [t.text, t.accent, t.accent_soft, t.warm] } else { [t.text_dim, t.text_dim, t.panel_header, t.text_dim] }
|
||||
if enabled { [t.icon, t.accent, t.icon_fill, t.warm] } else { [t.border, t.border, t.panel_header, t.border] }
|
||||
}
|
||||
|
||||
/// The SVG with each palette colour replaced by its role's colour.
|
||||
@@ -54,32 +56,79 @@ pub fn rasterise(svg: &str, px: u32) -> Result<egui::ColorImage, String> {
|
||||
Ok(egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pixmap.data()))
|
||||
}
|
||||
|
||||
/// Icon textures by (id, enabled) for one theme and pixel size; `None` when the icon is missing
|
||||
/// or does not rasterise, so it is not tried again every frame. A change of theme or size
|
||||
/// (zoom) drops the old textures.
|
||||
/// How an icon is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Look {
|
||||
/// In the theme's colours.
|
||||
Normal,
|
||||
/// Greyed: coming soon or unavailable.
|
||||
Greyed,
|
||||
/// On the title bar, which is dark in both themes: the dark theme's colours.
|
||||
AppBar,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
pub fn of(enabled: bool) -> Look {
|
||||
if enabled { Look::Normal } else { Look::Greyed }
|
||||
}
|
||||
|
||||
fn colours(self, dark: bool) -> [Color32; 4] {
|
||||
match self {
|
||||
Look::Normal => colours(&Tokens::get(dark), true),
|
||||
Look::Greyed => colours(&Tokens::get(dark), false),
|
||||
Look::AppBar => colours(&Tokens::dark(), true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon textures by (id, look, pixel size) for one theme; `None` when the icon is missing or
|
||||
/// does not rasterise, so it is not tried again every frame. The window draws icons at several
|
||||
/// sizes at once (ribbon, browser, menus), so each size keeps its own texture; a change of theme
|
||||
/// drops them all.
|
||||
#[derive(Default)]
|
||||
pub struct IconCache {
|
||||
theme_px: Option<(bool, u32)>,
|
||||
textures: HashMap<(&'static str, bool), Option<TextureHandle>>,
|
||||
dark: Option<bool>,
|
||||
textures: HashMap<(&'static str, Look, u32), Option<TextureHandle>>,
|
||||
}
|
||||
|
||||
impl IconCache {
|
||||
pub fn get(&mut self, ctx: &egui::Context, id: &'static str, dark: bool, enabled: bool, px: u32) -> Option<TextureHandle> {
|
||||
if self.theme_px != Some((dark, px)) {
|
||||
pub fn get(&mut self, ctx: &egui::Context, id: &'static str, dark: bool, look: Look, px: u32) -> Option<TextureHandle> {
|
||||
if self.dark != Some(dark) {
|
||||
self.textures.clear();
|
||||
self.theme_px = Some((dark, px));
|
||||
self.dark = Some(dark);
|
||||
}
|
||||
self.textures
|
||||
.entry((id, enabled))
|
||||
.entry((id, look, px))
|
||||
.or_insert_with(|| {
|
||||
let svg = svg(id)?;
|
||||
let image = rasterise(&recolour(svg, colours(&Tokens::get(dark), enabled)), px).map_err(|e| log::warn!("icon {id}: {e}")).ok()?;
|
||||
Some(ctx.load_texture(format!("icon:{id}:{dark}:{enabled}:{px}"), image, egui::TextureOptions::LINEAR))
|
||||
let image = rasterise(&recolour(svg, look.colours(dark)), px).map_err(|e| log::warn!("icon {id}: {e}")).ok()?;
|
||||
Some(ctx.load_texture(format!("icon:{id}:{dark}:{look:?}:{px}"), image, egui::TextureOptions::LINEAR))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint icon `id` into `rect` (a square); a missing icon draws a thin box so nothing is blank.
|
||||
pub fn paint(app: &mut crate::ModelApp, ui: &egui::Ui, id: &'static str, rect: egui::Rect, look: Look) {
|
||||
paint_tinted(app, ui, id, rect, look, Color32::WHITE);
|
||||
}
|
||||
|
||||
/// [`paint`] multiplied by `tint` (a translucent white dims it).
|
||||
pub fn paint_tinted(app: &mut crate::ModelApp, ui: &egui::Ui, id: &'static str, rect: egui::Rect, look: Look, tint: Color32) {
|
||||
let ctx = ui.ctx();
|
||||
let px = (rect.width().max(rect.height()) * ctx.pixels_per_point()).round().clamp(1.0, MAX_PX as f32) as u32;
|
||||
let p = ui.painter();
|
||||
match app.icons.get(ctx, id, app.ui.dark, look, px) {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
None => {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
p.rect_stroke(rect.shrink(3.0), 3.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -91,7 +140,7 @@ mod tests {
|
||||
assert!(svg(c.id).is_some(), "no icon assets/icons/{}.svg", c.id);
|
||||
}
|
||||
for (id, _) in ICONS {
|
||||
assert!(CATALOGUE.iter().any(|c| c.id == *id), "assets/icons/{id}.svg belongs to no catalogue entry");
|
||||
assert!(id.starts_with("ui.") || CATALOGUE.iter().any(|c| c.id == *id), "assets/icons/{id}.svg belongs to no catalogue entry");
|
||||
}
|
||||
assert!(ICONS.windows(2).all(|w| w[0].0 < w[1].0), "sorted for the binary search");
|
||||
}
|
||||
@@ -110,11 +159,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The SVG colours are the dark theme's own, so the dark theme draws icons as authored.
|
||||
/// Each role takes its own token, so recolouring never merges two roles into one colour.
|
||||
#[test]
|
||||
fn the_palette_is_the_dark_themes_colours() {
|
||||
let hex: Vec<String> = colours(&Tokens::dark(), true).iter().map(|c| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())).collect();
|
||||
assert_eq!(hex, PALETTE);
|
||||
fn every_role_takes_a_distinct_theme_colour() {
|
||||
for t in [Tokens::dark(), Tokens::light()] {
|
||||
let c = colours(&t, true);
|
||||
for i in 0..c.len() {
|
||||
for j in i + 1..c.len() {
|
||||
assert_ne!(c[i], c[j], "roles {i} and {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,9 +177,10 @@ mod tests {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0 L24 24" stroke="#4f9ce8" fill="#2d4e70"/></svg>"##;
|
||||
let light = Tokens::light();
|
||||
let out = recolour(svg, colours(&light, true));
|
||||
assert!(out.contains("stroke=\"#1f6fc4\"") && out.contains("fill=\"#c6dcf4\""), "{out}");
|
||||
let hex = |c: Color32| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
|
||||
assert!(out.contains(&format!("stroke=\"{}\"", hex(light.accent))) && out.contains(&format!("fill=\"{}\"", hex(light.icon_fill))), "{out}");
|
||||
let grey = recolour(svg, colours(&light, false));
|
||||
let dim = light.text_dim;
|
||||
let dim = light.border;
|
||||
assert!(grey.contains(&format!("stroke=\"#{:02x}{:02x}{:02x}\"", dim.r(), dim.g(), dim.b())), "{grey}");
|
||||
assert!(rasterise("not svg", 24).is_err());
|
||||
assert_eq!(rasterise(svg, 100_000).unwrap().size, [MAX_PX as usize; 2]);
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
//! The ModelCraft egui front end.
|
||||
//!
|
||||
//! A thin shell over [`modelcraft_engine::Session`]: panels read engine state and act through
|
||||
//! commands ([`ModelApp::run`]); nothing here owns model data. The window has a menu bar, the
|
||||
//! model tree on the left, the 3D viewport in the middle, the commands (generated from the
|
||||
//! engine's registry) on the right and a status bar at the bottom. The control channel's
|
||||
//! requests are handled here ([`control`]); the desktop app owns the socket.
|
||||
//! commands ([`ModelApp::run`]); nothing here owns model data. The window follows the Craft
|
||||
//! family's layout (SolidCraft, VectorCraft):
|
||||
//!
|
||||
//! Structure and conventions follow SolidCraft's `crates/ui-egui` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
//! - the title bar ([`appbar`]): the app, quick actions (File menu, save, undo, redo, Solve,
|
||||
//! search, theme, help) and the model's document tab;
|
||||
//! - the ribbon ([`toolbar`]): the workspace switcher, its tabs, and panels of icon buttons whose
|
||||
//! labelled drop-downs list the whole catalogue (coming-soon entries greyed with their
|
||||
//! milestone);
|
||||
//! - the browser on the left ([`browser`]): the model's geometry, mesh and definitions;
|
||||
//! - the viewport ([`viewport`]) with its navigation bar and orientation gizmo;
|
||||
//! - the context panel on the right ([`context`]), only while there is context: the open command
|
||||
//! form, the results controls, the selected definition's properties;
|
||||
//! - the command line and status ([`palette`]), and the journal timeline at the bottom
|
||||
//! ([`timeline`]). Every command is also one search away (S or Cmd/Ctrl+K).
|
||||
//!
|
||||
//! The control channel's requests are handled here ([`control`]); the desktop app owns the
|
||||
//! socket. Structure and conventions follow SolidCraft's `crates/ui-egui` (MIT OR Apache-2.0),
|
||||
//! see ATTRIBUTION.md.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod appbar;
|
||||
pub mod browser;
|
||||
pub mod capture;
|
||||
pub mod context;
|
||||
pub mod control;
|
||||
pub mod files;
|
||||
pub mod frame;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
pub mod panels;
|
||||
pub mod palette;
|
||||
pub mod results;
|
||||
pub mod results_panel;
|
||||
pub mod scene;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod viewport;
|
||||
|
||||
@@ -28,29 +46,43 @@ use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use modelcraft_engine::Session;
|
||||
use modelcraft_engine::catalogue::{self, Tab};
|
||||
use modelcraft_engine::catalogue::{self, Tab, Workspace};
|
||||
use modelcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use control::ControlRequest;
|
||||
pub use files::{Filter, Services};
|
||||
pub use scene::Style;
|
||||
|
||||
/// The version shown in Help and over the control channel.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// View settings: what is shown and how. `ui.set` changes any of them by name.
|
||||
/// View settings: what is shown and how, and which menu or search is open. `ui.set` changes any
|
||||
/// of them by name.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiState {
|
||||
pub style: Style,
|
||||
pub show_nodes: bool,
|
||||
/// The open design and imported surfaces.
|
||||
pub show_geometry: bool,
|
||||
/// Elements.
|
||||
pub show_mesh: bool,
|
||||
pub perspective: bool,
|
||||
pub dark: bool,
|
||||
/// The browser (left).
|
||||
pub show_tree: bool,
|
||||
pub show_commands: bool,
|
||||
/// The toolbar tab shown.
|
||||
/// The journal timeline (bottom).
|
||||
pub show_timeline: bool,
|
||||
/// The workspace shown, and its toolbar tab.
|
||||
pub workspace: Workspace,
|
||||
pub tab: Tab,
|
||||
/// The command search (S, Cmd/Ctrl+K).
|
||||
pub palette: bool,
|
||||
/// The open menu: `file`, `help`, `workspace`, `view`, or a toolbar panel's drop-down as
|
||||
/// `<tab>/<panel>` (`mesh/Tet`).
|
||||
pub menu: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -58,15 +90,49 @@ impl Default for UiState {
|
||||
UiState {
|
||||
style: Style::ShadedEdges,
|
||||
show_nodes: false,
|
||||
show_geometry: true,
|
||||
show_mesh: true,
|
||||
perspective: false,
|
||||
dark: true,
|
||||
show_tree: true,
|
||||
show_commands: true,
|
||||
show_timeline: true,
|
||||
workspace: Workspace::default(),
|
||||
tab: Tab::default(),
|
||||
palette: false,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// Show `tab`, on the current workspace when it has it, else on the first that does.
|
||||
pub fn select_tab(&mut self, tab: Tab) {
|
||||
self.tab = tab;
|
||||
self.workspace = Workspace::showing(tab, self.workspace);
|
||||
}
|
||||
|
||||
/// Show workspace `w` on its first tab.
|
||||
pub fn select_workspace(&mut self, w: Workspace) {
|
||||
self.workspace = w;
|
||||
self.tab = w.tabs().first().copied().unwrap_or(Tab::Tools);
|
||||
}
|
||||
|
||||
/// What the viewport shows.
|
||||
pub fn show(&self) -> scene::Show {
|
||||
scene::Show { geometry: self.show_geometry, mesh: self.show_mesh, nodes: self.show_nodes }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a left drag in the viewport does (the navigation bar's modes).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NavMode {
|
||||
#[default]
|
||||
Orbit,
|
||||
Pan,
|
||||
Zoom,
|
||||
}
|
||||
|
||||
/// The parameter form a command opens when started from the Commands panel.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParamsDialog {
|
||||
@@ -179,6 +245,40 @@ pub struct ModelApp {
|
||||
/// The app time (seconds) of the current frame.
|
||||
pub now: f64,
|
||||
pub quit_requested: bool,
|
||||
/// Host services: native file pickers, the recent-files file.
|
||||
pub services: files::Services,
|
||||
/// Recently opened and saved files.
|
||||
pub recent: files::Recent,
|
||||
/// The file opening in the background, and files waiting to open after it.
|
||||
pub loading: Option<files::Loading>,
|
||||
pub queued: std::collections::VecDeque<String>,
|
||||
/// The question the window is asking (unsaved changes, recover).
|
||||
pub prompt: Option<files::Prompt>,
|
||||
/// The window draws its own title bar and caption buttons (Windows, Linux).
|
||||
pub custom_titlebar: bool,
|
||||
/// The title bar runs under the macOS traffic lights.
|
||||
pub integrated_titlebar: bool,
|
||||
/// The command search's query and the command line's text.
|
||||
pub palette_text: String,
|
||||
pub command_line: String,
|
||||
/// Browser sections that are unfolded.
|
||||
pub tree_open: std::collections::BTreeSet<String>,
|
||||
/// The definition selected in the browser: (kind, name), its properties on the right.
|
||||
pub selected: Option<(browser::Def, String)>,
|
||||
/// What a left drag in the viewport does.
|
||||
pub nav: NavMode,
|
||||
/// Where the window's regions were drawn last frame (`ui.inspect`'s `layout`).
|
||||
pub layout: std::collections::BTreeMap<&'static str, egui::Rect>,
|
||||
/// The toolbar's panel labels drawn last frame, by `<tab>/<panel>`.
|
||||
pub panel_rects: Vec<(String, egui::Rect)>,
|
||||
/// The timeline's items and the journal state they were built for.
|
||||
timeline: Option<((u64, usize, (usize, usize)), std::sync::Arc<Vec<timeline::Item>>)>,
|
||||
/// The browser's element counts and the model revision they were counted at.
|
||||
element_counts: Option<(u64, Vec<(modelcraft_model::ElementKind, usize)>)>,
|
||||
/// The selected definition's property rows and what they were read for.
|
||||
properties: Option<((u64, browser::Def, String), std::sync::Arc<Vec<(String, String)>>)>,
|
||||
/// The search's query and its results.
|
||||
search: Option<(String, std::sync::Arc<Vec<palette::Hit>>)>,
|
||||
}
|
||||
|
||||
impl ModelApp {
|
||||
@@ -211,6 +311,24 @@ impl ModelApp {
|
||||
offscreen: None,
|
||||
now: 0.0,
|
||||
quit_requested: false,
|
||||
services: files::Services::default(),
|
||||
recent: files::Recent::default(),
|
||||
loading: None,
|
||||
queued: std::collections::VecDeque::new(),
|
||||
prompt: None,
|
||||
custom_titlebar: false,
|
||||
integrated_titlebar: false,
|
||||
palette_text: String::new(),
|
||||
command_line: String::new(),
|
||||
tree_open: ["mesh", "results"].into_iter().map(str::to_string).collect(),
|
||||
selected: None,
|
||||
nav: NavMode::default(),
|
||||
layout: std::collections::BTreeMap::new(),
|
||||
panel_rects: Vec::new(),
|
||||
timeline: None,
|
||||
element_counts: None,
|
||||
search: None,
|
||||
properties: None,
|
||||
};
|
||||
app.fit_view();
|
||||
app
|
||||
@@ -222,6 +340,13 @@ impl ModelApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the host's services (file pickers, the recent-files file) and load the recent list.
|
||||
pub fn with_services(mut self, services: files::Services) -> Self {
|
||||
self.recent = files::Recent::load(services.recent_path.clone());
|
||||
self.services = services;
|
||||
self
|
||||
}
|
||||
|
||||
/// 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. Device errors
|
||||
/// show in the status bar.
|
||||
@@ -234,6 +359,7 @@ impl ModelApp {
|
||||
/// Run a command with JSON parameters: what every panel, the control channel and scripts
|
||||
/// do. The result or the error goes to the status bar.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
self.ensure_idle()?;
|
||||
let revision = self.session.revision();
|
||||
let label = command_label(id);
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
@@ -242,7 +368,7 @@ impl ModelApp {
|
||||
self.model_changed();
|
||||
}
|
||||
// The results display commands reply with what is shown, which the viewport shows.
|
||||
let display = id.starts_with("results.") && id != "results.reactions";
|
||||
let display = (id.starts_with("results.") && id != "results.reactions") || id == "solve.run";
|
||||
match &r {
|
||||
Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false),
|
||||
Ok(v) => self.set_status(format!("{label}: {v}"), false),
|
||||
@@ -262,11 +388,22 @@ impl ModelApp {
|
||||
r
|
||||
}
|
||||
|
||||
/// What clicking a toolbar entry or a command in the Commands panel does: a command without
|
||||
/// parameters runs, one with parameters opens its form. `id` is a command or a catalogue
|
||||
/// entry; an entry that is coming soon does nothing and says when it comes. Programmatic calls
|
||||
/// use [`ModelApp::run`] instead.
|
||||
/// What a click on a toolbar entry, a menu row or a palette result does: a command without
|
||||
/// parameters runs, one with parameters opens its form, and the file entries (open, save,
|
||||
/// write a deck, recover) go through the host's file pickers. `id` is a command or a
|
||||
/// catalogue entry; an entry that is coming soon does nothing and says when it comes.
|
||||
/// Programmatic calls use [`ModelApp::run`] or [`ModelApp::start_programmatic`].
|
||||
pub fn start(&mut self, id: &str) -> Result<(), String> {
|
||||
self.begin(id, true)
|
||||
}
|
||||
|
||||
/// `ui.start` from the control channel: as a click, but never a native file picker (entries
|
||||
/// that need one open their command's form).
|
||||
pub fn start_programmatic(&mut self, id: &str) -> Result<(), String> {
|
||||
self.begin(id, false)
|
||||
}
|
||||
|
||||
fn begin(&mut self, id: &str, interactive: bool) -> Result<(), String> {
|
||||
let (command, preset) = match catalogue::find(id) {
|
||||
Some(c) => match c.command() {
|
||||
Some(command) => (command, c.preset()),
|
||||
@@ -279,16 +416,27 @@ impl ModelApp {
|
||||
None => (id, json!({})),
|
||||
};
|
||||
let spec = modelcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
|
||||
if self.file_action(spec.id, &preset, interactive) {
|
||||
self.dialog = None;
|
||||
return Ok(());
|
||||
}
|
||||
if spec.params.trim() == "{}" {
|
||||
self.dialog = None;
|
||||
self.run(spec.id, json!({})).map(|_| ())
|
||||
} else {
|
||||
// The form starts with the entry's preset; the rest (a path…) is filled in there.
|
||||
self.dialog = Some(ParamsDialog { command: spec.id, text: preset.to_string(), error: None });
|
||||
self.open_form(spec.id, preset);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a command's parameter form with `params` filled in.
|
||||
pub fn open_form(&mut self, command: &str, params: Value) {
|
||||
if let Some(spec) = modelcraft_engine::find_command(command) {
|
||||
self.dialog = Some(ParamsDialog { command: spec.id, text: params.to_string(), error: None });
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the open parameter form; it closes when the command succeeds.
|
||||
pub fn run_dialog(&mut self) -> Result<Value, String> {
|
||||
let Some(d) = self.dialog.clone() else { return Err("no command form is open".into()) };
|
||||
@@ -411,7 +559,7 @@ impl ModelApp {
|
||||
pub fn scene_key(&self) -> u64 {
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize);
|
||||
(self.revision, self.ui.style, self.ui.show_nodes, self.ui.dark, plot).hash(&mut h);
|
||||
(self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -450,10 +598,10 @@ impl ModelApp {
|
||||
let key = self.scene_key();
|
||||
if self.scene.0 != Some(key) {
|
||||
let t = theme::Tokens::get(self.ui.dark);
|
||||
let (style, nodes) = (self.ui.style, self.ui.show_nodes);
|
||||
let (style, show) = (self.ui.style, self.ui.show());
|
||||
let s = match &self.plot.1 {
|
||||
Some(p) => results::scene(p, style, nodes, &t),
|
||||
None => scene::build(&self.display.1, style, nodes, &t),
|
||||
Some(p) => results::scene(p, style, show, &t),
|
||||
None => scene::build(&self.display.1, style, show, &t),
|
||||
};
|
||||
self.scene = (Some(key), s);
|
||||
}
|
||||
@@ -479,6 +627,12 @@ impl ModelApp {
|
||||
self.cam.fit(&b);
|
||||
}
|
||||
|
||||
/// The home view: isometric, the whole model framed.
|
||||
pub fn home_view(&mut self) {
|
||||
self.set_view(StandardView::Iso);
|
||||
self.fit_view();
|
||||
}
|
||||
|
||||
pub fn set_view(&mut self, v: StandardView) {
|
||||
self.cam.set_view(v);
|
||||
}
|
||||
@@ -491,6 +645,10 @@ impl ModelApp {
|
||||
self.styled = Some(self.ui.dark);
|
||||
}
|
||||
self.drain_control(ctx);
|
||||
if self.poll_loading() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
self.dropped_files(ctx);
|
||||
self.shortcuts(ctx);
|
||||
self.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 };
|
||||
if !self.synthetic.is_empty() {
|
||||
@@ -499,33 +657,57 @@ impl ModelApp {
|
||||
// Results coming up (a solve, a results command) bring the Results tab.
|
||||
let shown = self.session.results_view.is_some();
|
||||
if shown && !self.results_shown {
|
||||
self.ui.tab = Tab::Results;
|
||||
self.ui.select_tab(Tab::Results);
|
||||
}
|
||||
self.results_shown = shown;
|
||||
// A selected definition that is gone (undo, delete) is no longer selected.
|
||||
if self.selected.as_ref().is_some_and(|(kind, name)| !kind.exists(&self.session.model, name)) {
|
||||
self.selected = None;
|
||||
}
|
||||
if self.session.results_view.as_ref().is_some_and(|v| v.animate) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(results::PERIOD / results::FRAMES as f64));
|
||||
}
|
||||
self.screenshots(ctx);
|
||||
}
|
||||
|
||||
/// Undo (Ctrl/Cmd+Z) and redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), unless a text field has the
|
||||
/// keyboard (it undoes its own typing).
|
||||
/// Undo (Ctrl/Cmd+Z), redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), open (Ctrl/Cmd+O), save
|
||||
/// (Ctrl/Cmd+S) and save as (Ctrl/Cmd+Shift+S), unless a text field has the keyboard (it
|
||||
/// undoes its own typing).
|
||||
fn shortcuts(&mut self, ctx: &egui::Context) {
|
||||
if ctx.memory(|m| m.focused().is_some()) {
|
||||
return;
|
||||
}
|
||||
let shortcut = |modifiers, key| egui::KeyboardShortcut::new(modifiers, key);
|
||||
// Shift+Z first: the plain shortcut matches it too.
|
||||
let (redo, undo) = ctx.input_mut(|i| {
|
||||
let redo = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z))
|
||||
|| i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Y));
|
||||
(redo, i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Z)))
|
||||
let shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
|
||||
// Shift first: the plain shortcut matches it too.
|
||||
let (redo, undo, save_as, save, open) = ctx.input_mut(|i| {
|
||||
let redo = i.consume_shortcut(&shortcut(shift, egui::Key::Z)) || i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Y));
|
||||
let undo = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Z));
|
||||
let save_as = i.consume_shortcut(&shortcut(shift, egui::Key::S));
|
||||
let save = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::S));
|
||||
(redo, undo, save_as, save, i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::O)))
|
||||
});
|
||||
if redo {
|
||||
let _ = self.run("edit.redo", json!({}));
|
||||
} else if undo {
|
||||
let _ = self.run("edit.undo", json!({}));
|
||||
}
|
||||
if save_as {
|
||||
self.save_as();
|
||||
} else if save {
|
||||
self.save();
|
||||
}
|
||||
if open {
|
||||
self.open_dialog(None);
|
||||
}
|
||||
// The command search: S (as in SolidCraft) or Cmd/Ctrl+K.
|
||||
let search = ctx.input_mut(|i| {
|
||||
i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::K)) || i.consume_key(egui::Modifiers::NONE, egui::Key::S)
|
||||
});
|
||||
if search {
|
||||
self.ui.palette = true;
|
||||
self.ui.menu = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events: one pointer event per frame (egui reads a press and release in
|
||||
@@ -558,17 +740,33 @@ impl ModelApp {
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
self.show_gpu_errors();
|
||||
panels::menu_bar(self, ui);
|
||||
self.layout.clear();
|
||||
appbar::app_bar(self, ui);
|
||||
toolbar::toolbar(self, ui);
|
||||
panels::status_bar(self, ui);
|
||||
if self.ui.show_timeline {
|
||||
timeline::timeline(self, ui);
|
||||
}
|
||||
palette::command_line(self, ui);
|
||||
if self.ui.show_tree {
|
||||
panels::model_tree(self, ui);
|
||||
}
|
||||
if self.ui.show_commands {
|
||||
panels::commands(self, ui);
|
||||
browser::browser(self, ui);
|
||||
}
|
||||
context::panel(self, ui);
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui));
|
||||
panels::params_dialog(self, ui.ctx());
|
||||
palette::popup(self, ui.ctx());
|
||||
files::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember where a region of the window was drawn (`ui.inspect`'s `layout`).
|
||||
pub fn place(&mut self, region: &'static str, rect: egui::Rect) {
|
||||
self.layout.insert(region, rect);
|
||||
}
|
||||
|
||||
/// Open, or close when it is open, a menu (see [`UiState::menu`]).
|
||||
pub fn toggle_menu(&mut self, key: &str) {
|
||||
self.ui.menu = if self.ui.menu.as_deref() == Some(key) { None } else { Some(key.to_string()) };
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
@@ -735,7 +933,7 @@ mod tests {
|
||||
assert!(d.tris.is_empty());
|
||||
// Without elements, geometry draws as faces with their boundaries; the camera frames it.
|
||||
let t = theme::Tokens::dark();
|
||||
let s = scene::build(&d, Style::ShadedEdges, false, &t);
|
||||
let s = scene::build(&d, Style::ShadedEdges, scene::Show::DEFAULT, &t);
|
||||
assert_eq!(s.tris.len(), d.surface_tris.len());
|
||||
assert!(s.tris.iter().all(|tri| tri.color == theme::rgb(t.geometry)));
|
||||
assert!(!d.bounds.is_empty());
|
||||
@@ -873,6 +1071,101 @@ mod tests {
|
||||
assert!(found, "a red legend swatch");
|
||||
}
|
||||
|
||||
fn region(app: &ModelApp, name: &str) -> egui::Rect {
|
||||
app.layout.get(name).copied().unwrap_or_else(|| panic!("no {name} in {:?}", app.layout.keys()))
|
||||
}
|
||||
|
||||
/// The family layout: title bar, ribbon, browser left, viewport, command line and timeline at
|
||||
/// the bottom; the context panel on the right only while there is context.
|
||||
#[test]
|
||||
fn the_window_is_laid_out_like_the_other_crafts() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.open_sample("cantilever");
|
||||
let size = capture::CaptureSize { width: 1400.0, height: 900.0, scale: 1.0 };
|
||||
// `layout` is the live window's; a capture leaves it, so draw a frame of the UI directly.
|
||||
let frame = |app: &mut ModelApp| {
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..2 {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(size.width, size.height))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
};
|
||||
frame(&mut app);
|
||||
let (bar, ribbon, browser, view) = (region(&app, "app_bar"), region(&app, "ribbon"), region(&app, "browser"), region(&app, "viewport"));
|
||||
let (line, timeline) = (region(&app, "command_line"), region(&app, "timeline"));
|
||||
assert_eq!((bar.top(), bar.height()), (0.0, appbar::HEIGHT));
|
||||
assert!((ribbon.top() - bar.bottom()).abs() < 1.0 && (ribbon.height() - toolbar::HEIGHT).abs() < 1.0, "{ribbon:?}");
|
||||
// The browser's content, inside its margin.
|
||||
assert!(browser.left() < 8.0 && (browser.width() - browser::WIDTH).abs() <= 14.0, "{browser:?}");
|
||||
assert!(view.left() >= browser.right() - 1.0 && view.top() >= ribbon.bottom() - 1.0 && view.bottom() <= line.top() + 1.0);
|
||||
assert!((timeline.bottom() - 900.0).abs() < 1.0 && line.bottom() <= timeline.top() + 1.0);
|
||||
assert!((view.right() - 1400.0).abs() < 1.0, "no context: the viewport reaches the right edge");
|
||||
assert!(!app.layout.contains_key("context"));
|
||||
for chrome in ["nav_bar", "axis_view", "workspace_switcher", "document_tab", "app_bar:solve"] {
|
||||
let r = region(&app, chrome);
|
||||
assert!(r.is_positive(), "{chrome}");
|
||||
}
|
||||
assert!(view.contains_rect(region(&app, "nav_bar")) && view.contains_rect(region(&app, "axis_view")));
|
||||
// A form brings the context panel; the viewport makes room.
|
||||
app.start("model.material.create").unwrap();
|
||||
frame(&mut app);
|
||||
let context = region(&app, "context");
|
||||
assert!(context.right() > 1390.0 && context.width() > 280.0, "{context:?}");
|
||||
assert!(region(&app, "viewport").right() <= context.left() + 1.0);
|
||||
// The search and a drop-down are drawn when their state says so.
|
||||
app.dialog = None;
|
||||
app.ui.palette = true;
|
||||
app.ui.menu = Some("mesh/Tet".into());
|
||||
app.ui.select_tab(Tab::Mesh);
|
||||
frame(&mut app);
|
||||
assert!(app.layout.contains_key("palette") && app.layout.contains_key("toolbar_dropdown"), "{:?}", app.layout.keys());
|
||||
assert_eq!(app.ui.workspace, Workspace::Prepare);
|
||||
}
|
||||
|
||||
/// No entry is lost: on every tab, at a wide and a narrow window, every catalogue entry is a
|
||||
/// button, a collapsed panel or a » panel, and a row of its panel's drop-down once it is open.
|
||||
#[test]
|
||||
fn every_catalogue_entry_is_reachable_from_its_tab() {
|
||||
for width in [1400.0, 960.0] {
|
||||
for tab in Tab::ALL {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.ui.select_tab(tab);
|
||||
let ctx = egui::Context::default();
|
||||
let run = |app: &mut ModelApp| {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 800.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
run(&mut app);
|
||||
for c in catalogue::tab_entries(tab) {
|
||||
assert!(app.toolbar_rects.iter().any(|(id, ..)| *id == c.id), "{width}: {} is not on the {tab:?} tab", c.id);
|
||||
}
|
||||
for p in catalogue::panels(tab) {
|
||||
let Some(panel) = p.first().map(|c| c.panel) else { continue };
|
||||
let collapsed_or_more = app.toolbar_rects.iter().any(|(id, _, f)| *id == p[0].id && matches!(f, toolbar::Fit::More));
|
||||
if collapsed_or_more {
|
||||
continue;
|
||||
}
|
||||
app.ui.menu = Some(toolbar::panel_key(tab, panel));
|
||||
run(&mut app);
|
||||
run(&mut app);
|
||||
for c in p {
|
||||
let row = app.toolbar_rects.iter().any(|(id, r, f)| *id == c.id && *f == toolbar::Fit::Menu && r.is_positive());
|
||||
assert!(row, "{width}: {} has no row in the {panel} drop-down", c.id);
|
||||
}
|
||||
app.ui.menu = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_tolerance_follows_the_size() {
|
||||
let b = |max: [f64; 3]| Aabb3 { min: Vec3::ZERO, max: max.into() };
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! The command line (bottom) and the command search (S or Cmd/Ctrl+K).
|
||||
//!
|
||||
//! The command line runs `id {json}` (`mesh.node.add {"label": 1, "xyz": [0, 0, 0]}`), starts a
|
||||
//! command given by its id alone, or starts the best match for a name. Beside it, the status and
|
||||
//! the model's size. The search lists every catalogue entry and related command by name, id or
|
||||
//! tip; coming-soon entries are listed greyed with their milestone. Adapted from SolidCraft's
|
||||
//! `crates/ui-egui/src/palette.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use egui::{Align2, FontId, Rect, RichText, Sense, Vec2, pos2, vec2};
|
||||
use modelcraft_engine::catalogue::{self, CATALOGUE};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::icons::{self, Look};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The command line's height.
|
||||
pub const HEIGHT: f32 = 28.0;
|
||||
/// Most results the search lists.
|
||||
const MAX_RESULTS: usize = 60;
|
||||
|
||||
/// One search result.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Hit {
|
||||
/// What starting it starts: a catalogue entry or a command.
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Where it is on the toolbar: `Tab › Panel`.
|
||||
pub place: String,
|
||||
/// The icon (its catalogue entry's).
|
||||
pub icon: &'static str,
|
||||
/// The milestone that brings it, while it is coming soon.
|
||||
pub soon: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Every entry and related command matching `query` (by label, id, panel or tip), best first:
|
||||
/// labels starting with it, then ids starting with it, then the rest, ready before coming soon.
|
||||
pub fn matches(query: &str) -> Vec<Hit> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let mut hits: Vec<(u8, Hit)> = Vec::new();
|
||||
let rank = |label: &str, id: &str| -> u8 {
|
||||
if label.to_lowercase().starts_with(&q) {
|
||||
0
|
||||
} else if id.starts_with(&q) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
};
|
||||
for c in CATALOGUE {
|
||||
let place = format!("{} › {}", c.tab.label(), c.panel);
|
||||
let text = format!("{} {} {} {}", c.label, c.id, c.panel, c.tip).to_lowercase();
|
||||
if q.is_empty() || text.contains(&q) {
|
||||
let soon = c.milestone();
|
||||
hits.push((
|
||||
rank(c.label, c.id) + if soon.is_some() { 3 } else { 0 },
|
||||
Hit { id: c.id, label: c.label, place: place.clone(), icon: c.id, soon },
|
||||
));
|
||||
}
|
||||
for m in c.more {
|
||||
let label = crate::command_label(m);
|
||||
if q.is_empty() || format!("{label} {m}").to_lowercase().contains(&q) {
|
||||
hits.push((rank(label, m), Hit { id: m, label, place: place.clone(), icon: c.id, soon: None }));
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.label.cmp(b.1.label)));
|
||||
hits.dedup_by(|a, b| a.1.id == b.1.id);
|
||||
hits.into_iter().map(|(_, h)| h).collect()
|
||||
}
|
||||
|
||||
/// Run a line of command-line input: `id {json}` runs the command with those parameters, `id`
|
||||
/// starts it, anything else starts the best match.
|
||||
pub fn submit(app: &mut ModelApp, line: &str) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (id, rest) = match line.find(|c: char| c.is_whitespace() || c == '{') {
|
||||
Some(i) => (line.get(..i).unwrap_or(line), line.get(i..).unwrap_or("").trim()),
|
||||
None => (line, ""),
|
||||
};
|
||||
if modelcraft_engine::find_command(id).is_some() || catalogue::find(id).is_some() {
|
||||
if rest.is_empty() {
|
||||
let _ = app.start(id);
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(rest) {
|
||||
Ok(p) => {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
Err(e) => app.set_status(format!("the parameters must be JSON: {e}"), true),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match matches(line).into_iter().find(|h| h.soon.is_none()) {
|
||||
Some(h) => {
|
||||
let _ = app.start(h.id);
|
||||
}
|
||||
None => app.set_status(format!("no command matches `{line}`"), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command line, the status and the model's size, above the timeline.
|
||||
pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
egui::Panel::bottom("mc_command_line")
|
||||
.exact_size(HEIGHT)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 3)))
|
||||
.show(ui, |ui| {
|
||||
app.place("command_line", ui.max_rect());
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(RichText::new("›").strong().color(t.accent));
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut app.command_line)
|
||||
.id(egui::Id::new("mc_command_line_text"))
|
||||
.hint_text(r#"Type a command, e.g. mesh.node.add {"label": 1, "xyz": [0, 0, 0]} — or press S to search"#)
|
||||
.desired_width(440.0),
|
||||
);
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let line = std::mem::take(&mut app.command_line);
|
||||
submit(app, &line);
|
||||
}
|
||||
let (nodes, elements) = (app.session.model.nodes().len(), app.session.model.elements().len());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(app.gpu_info.as_deref().unwrap_or("CPU renderer")).size(11.5).color(t.text_dim));
|
||||
ui.separator();
|
||||
ui.label(RichText::new(if app.ui.perspective { "Perspective" } else { "Orthographic" }).size(11.5).color(t.text_dim));
|
||||
ui.separator();
|
||||
ui.label(RichText::new(format!("{nodes} nodes {elements} elements")).size(11.5).color(t.text_dim));
|
||||
ui.separator();
|
||||
let (text, color) = match &app.status {
|
||||
Some(s) => (s.text.as_str(), if s.error { t.error } else { t.text }),
|
||||
None => ("Ready", t.text_dim),
|
||||
};
|
||||
ui.add(egui::Label::new(RichText::new(text).size(11.5).color(color)).truncate());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The command search, centred near the top of the window.
|
||||
pub fn popup(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
if !app.ui.palette {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let mut picked: Option<&'static str> = None;
|
||||
let mut open = true;
|
||||
let center = ctx.content_rect().center();
|
||||
let r = crate::frame::window(ctx, "Search commands", crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("mc_search"))
|
||||
.pivot(Align2::CENTER_TOP)
|
||||
.fixed_pos(pos2(center.x, 140.0))
|
||||
.fixed_size(vec2(440.0, 380.0))
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut app.palette_text)
|
||||
.id(egui::Id::new("mc_search_text"))
|
||||
.hint_text("command name, id or what it does")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
let entered = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
r.request_focus();
|
||||
// Searched again only when the query changes.
|
||||
if app.search.as_ref().is_none_or(|(q, _)| *q != app.palette_text) {
|
||||
app.search = Some((app.palette_text.clone(), std::sync::Arc::new(matches(&app.palette_text))));
|
||||
}
|
||||
let list = app.search.as_ref().map(|(_, l)| l.clone()).unwrap_or_default();
|
||||
if entered {
|
||||
picked = list.iter().find(|h| h.soon.is_none()).map(|h| h.id);
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for h in list.iter().take(MAX_RESULTS) {
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 26.0), if h.soon.is_none() { Sense::click() } else { Sense::hover() });
|
||||
if resp.hovered() && h.soon.is_none() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
app,
|
||||
ui,
|
||||
h.icon,
|
||||
Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), Vec2::splat(18.0)),
|
||||
Look::of(h.soon.is_none()),
|
||||
);
|
||||
let p = ui.painter();
|
||||
p.text(
|
||||
pos2(rect.left() + 30.0, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
h.label,
|
||||
FontId::proportional(13.0),
|
||||
if h.soon.is_none() { t.text } else { t.text_dim },
|
||||
);
|
||||
let right = match h.soon {
|
||||
Some(m) => format!("{} · soon ({m})", h.place),
|
||||
None => h.place.clone(),
|
||||
};
|
||||
p.text(pos2(rect.right() - 6.0, rect.center().y), Align2::RIGHT_CENTER, right, FontId::proportional(11.0), t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.soon.is_none(), h.label));
|
||||
if resp.on_hover_text(h.id).clicked() && h.soon.is_none() {
|
||||
picked = Some(h.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(r) = r {
|
||||
app.place("palette", r.response.rect);
|
||||
}
|
||||
if let Some(id) = picked {
|
||||
app.palette_text.clear();
|
||||
app.ui.palette = false;
|
||||
let _ = app.start(id);
|
||||
} else if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.palette = false;
|
||||
app.palette_text.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use modelcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn search_finds_entries_and_related_commands_ready_first() {
|
||||
let hits = matches("material");
|
||||
assert_eq!(hits.first().map(|h| h.id), Some("model.material.create"));
|
||||
assert!(hits.iter().any(|h| h.id == "model.material.delete"), "related commands are found");
|
||||
let soon = hits.iter().position(|h| h.soon.is_some()).unwrap();
|
||||
assert!(hits.iter().skip(soon).all(|h| h.soon.is_some()), "coming soon last");
|
||||
assert_eq!(matches("").len(), {
|
||||
let mut ids: Vec<&str> = CATALOGUE.iter().flat_map(|c| std::iter::once(c.id).chain(c.more.iter().copied())).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids.len()
|
||||
});
|
||||
assert!(matches("no such thing at all").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_line_runs_ids_with_json_and_starts_names() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
submit(&mut app, r#"mesh.node.add {"label": 4, "xyz": [1, 2, 3]}"#);
|
||||
assert!(app.session.model.node(4).is_some());
|
||||
submit(&mut app, "mesh.node.add");
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("mesh.node.add"));
|
||||
app.dialog = None;
|
||||
submit(&mut app, "material");
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create"));
|
||||
submit(&mut app, "mesh.node.add {not json");
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.error));
|
||||
submit(&mut app, "zzzz qqqq");
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.contains("no command")));
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
//! The panels around the viewport: menu bar, model tree, commands, status bar and the command
|
||||
//! parameter form. They read engine state and act through commands.
|
||||
|
||||
use egui::RichText;
|
||||
use modelcraft_render::StandardView;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{ModelApp, Style, VERSION};
|
||||
|
||||
pub fn menu_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
egui::Panel::top("mc_menu").frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin::symmetric(8, 4))).show(ui, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.label(RichText::new("ModelCraft").strong());
|
||||
ui.add_space(8.0);
|
||||
ui.menu_button("View", |ui| {
|
||||
for s in Style::ALL {
|
||||
ui.radio_value(&mut app.ui.style, s, s.label());
|
||||
}
|
||||
ui.separator();
|
||||
ui.checkbox(&mut app.ui.show_nodes, "Nodes");
|
||||
ui.checkbox(&mut app.ui.perspective, "Perspective");
|
||||
ui.checkbox(&mut app.ui.dark, "Dark theme");
|
||||
ui.separator();
|
||||
for v in StandardView::ALL {
|
||||
if ui.button(v.label()).clicked() {
|
||||
app.set_view(v);
|
||||
}
|
||||
}
|
||||
if ui.button("Fit").clicked() {
|
||||
app.fit_view();
|
||||
}
|
||||
});
|
||||
ui.menu_button("Window", |ui| {
|
||||
ui.checkbox(&mut app.ui.show_tree, "Model tree");
|
||||
ui.checkbox(&mut app.ui.show_commands, "Commands");
|
||||
});
|
||||
ui.menu_button("Help", |ui| {
|
||||
ui.label(format!("ModelCraft {VERSION}"));
|
||||
ui.label(RichText::new("Left drag orbits, right or middle drag pans,\nthe wheel zooms, F or a double click fits.").color(t.text_dim));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn status_bar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let (nodes, elements) = (app.session.model.nodes().len(), app.session.model.elements().len());
|
||||
egui::Panel::bottom("mc_status").exact_size(24.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin::symmetric(8, 3))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
match &app.status {
|
||||
Some(s) => {
|
||||
ui.label(RichText::new(&s.text).color(if s.error { t.error } else { t.text }));
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("Ready").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(app.gpu_info.as_deref().unwrap_or("CPU renderer")).color(t.text_dim));
|
||||
ui.separator();
|
||||
ui.label(RichText::new(if app.ui.perspective { "Perspective" } else { "Orthographic" }).color(t.text_dim));
|
||||
ui.separator();
|
||||
ui.label(format!("{nodes} nodes {elements} elements"));
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn model_tree(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let model = &app.session.model;
|
||||
let shown = app.session.results_view.as_ref().map(|v| v.step.clone());
|
||||
let mut show = None;
|
||||
egui::Panel::left("mc_tree").resizable(true).default_size(220.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::same(8))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.label(RichText::new("Model").strong());
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().id_salt("mc_tree_scroll").show(ui, |ui| {
|
||||
ui.label(format!("Nodes ({})", model.nodes().len()));
|
||||
egui::CollapsingHeader::new(format!("Elements ({})", model.elements().len())).id_salt("mc_tree_elements").default_open(true).show(
|
||||
ui,
|
||||
|ui| {
|
||||
let counts = model.element_counts();
|
||||
if counts.is_empty() {
|
||||
ui.label(RichText::new("none").color(t.text_dim));
|
||||
}
|
||||
for (kind, n) in counts {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, t.element(kind));
|
||||
ui.label(format!("{} ({n})", kind.name()));
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(d) = &app.session.design {
|
||||
ui.label(format!("Design {} ({} bodies)", d.name, d.bodies.len()));
|
||||
}
|
||||
if !app.session.surfaces.is_empty() {
|
||||
ui.label(format!("Surfaces ({})", app.session.surfaces.len()));
|
||||
}
|
||||
let steps: Vec<(&str, bool)> = app.session.result_steps().collect();
|
||||
if !steps.is_empty() {
|
||||
egui::CollapsingHeader::new(format!("Results ({})", steps.len())).id_salt("mc_tree_results").default_open(true).show(ui, |ui| {
|
||||
for (name, current) in steps {
|
||||
let text = if current { RichText::new(name) } else { RichText::new(format!("{name} (stale)")).color(t.text_dim) };
|
||||
let selected = shown.as_deref() == Some(name);
|
||||
if ui.selectable_label(selected, text).on_hover_text("Show this step's results").clicked() {
|
||||
show = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.label(format!("History ({} commands)", app.session.journal.len()));
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Some(step) = show {
|
||||
// Errors show in the status bar.
|
||||
let _ = app.run("results.deformed", serde_json::json!({"step": step}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The command registry by area (the id's first part), each command a button that starts it.
|
||||
pub fn commands(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let mut start = None;
|
||||
egui::Panel::right("mc_commands")
|
||||
.resizable(true)
|
||||
.default_size(220.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::same(8)))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new("Commands").strong());
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().id_salt("mc_commands_scroll").show(ui, |ui| {
|
||||
let all = modelcraft_engine::commands();
|
||||
let mut areas: Vec<&str> = Vec::new();
|
||||
for c in all {
|
||||
if !areas.contains(&area(c.id)) {
|
||||
areas.push(area(c.id));
|
||||
}
|
||||
}
|
||||
for a in areas {
|
||||
ui.label(RichText::new(title(a)).color(t.text_dim).small());
|
||||
for c in all.iter().filter(|c| area(c.id) == a) {
|
||||
let button = egui::Button::new(c.label).min_size(egui::vec2(ui.available_width(), 0.0));
|
||||
if ui.add(button).on_hover_text(format!("{}\n{}", c.id, c.params)).clicked() {
|
||||
start = Some(c.id);
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(id) = start {
|
||||
// Errors already show in the status bar.
|
||||
let _ = app.start(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn area(id: &str) -> &str {
|
||||
id.split('.').next().unwrap_or(id)
|
||||
}
|
||||
|
||||
fn title(area: &str) -> String {
|
||||
let mut c = area.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The open command's parameters as JSON, with the command's parameter documentation.
|
||||
pub fn params_dialog(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let Some(d) = app.dialog.as_mut() else { return };
|
||||
let Some(spec) = modelcraft_engine::find_command(d.command) else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let (mut run, mut cancel) = (false, false);
|
||||
egui::Window::new(spec.label).id(egui::Id::new("mc_params")).collapsible(false).resizable(true).default_width(360.0).show(ctx, |ui| {
|
||||
ui.label(RichText::new(spec.id).color(t.text_dim).small());
|
||||
ui.label(RichText::new(spec.params).color(t.text_dim));
|
||||
ui.add(egui::TextEdit::multiline(&mut d.text).code_editor().desired_rows(4).desired_width(f32::INFINITY));
|
||||
if let Some(e) = &d.error {
|
||||
ui.label(RichText::new(e).color(t.error));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
run = ui.button("Run").clicked();
|
||||
cancel = ui.button("Cancel").clicked();
|
||||
});
|
||||
});
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
} else if run {
|
||||
let _ = app.run_dialog();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn areas_are_titled() {
|
||||
assert_eq!(super::area("mesh.node.add"), "mesh");
|
||||
assert_eq!(super::title("mesh"), "Mesh");
|
||||
assert_eq!(super::title(""), "");
|
||||
}
|
||||
}
|
||||
@@ -174,9 +174,9 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
|
||||
|
||||
/// The scene of a plot: the deformed elements drawn as the model is ([`scene::build`]), with
|
||||
/// their faces split into contour bands when a field is shown.
|
||||
pub fn scene(plot: &Plot, style: Style, show_nodes: bool, t: &Tokens) -> Scene {
|
||||
let mut s = crate::scene::build(&plot.display, style, show_nodes, t);
|
||||
let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe) else { return s };
|
||||
pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) -> Scene {
|
||||
let mut s = crate::scene::build(&plot.display, style, show, t);
|
||||
let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe && show.mesh) else { return s };
|
||||
let nodes = plot.result.nodes();
|
||||
let stops = t.contour_stops();
|
||||
s.tris.clear();
|
||||
@@ -270,7 +270,7 @@ pub(crate) mod tests {
|
||||
// The tip moves most (lowest band), the clamped root not at all (highest band).
|
||||
assert!(legend.min.1 < -0.2 && legend.max.1.abs() < 1e-12, "{legend:?}");
|
||||
let t = Tokens::dark();
|
||||
let scene = scene(&p, Style::Shaded, false, &t);
|
||||
let scene = scene(&p, Style::Shaded, crate::scene::Show::DEFAULT, &t);
|
||||
let stops = t.contour_stops();
|
||||
let (low, high) = (band_color(&stops, 0, 4), band_color(&stops, 3, 4));
|
||||
// Faces whose corners are all near the root are in the top band; near the tip, the bottom.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What the viewport shows over a results plot: the legend (field, bands, range, deformation
|
||||
//! scale, a stale-result warning), the probed node, and the results panel whose controls run
|
||||
//! the `results.*` commands.
|
||||
//! scale, a stale-result warning) and the probed node; and the results controls of the context
|
||||
//! panel (right), which run the `results.*` commands.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, RichText, Stroke, pos2, vec2};
|
||||
use modelcraft_engine::results::{Field, Kind};
|
||||
@@ -11,8 +11,6 @@ use crate::ModelApp;
|
||||
use crate::results::{Plot, number};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Width of the results panel and of the legend's colour swatches (points).
|
||||
const PANEL_WIDTH: f32 = 260.0;
|
||||
/// Width of the panel's drop-down lists.
|
||||
const COMBO_WIDTH: f32 = 160.0;
|
||||
const SWATCH: egui::Vec2 = vec2(18.0, 15.0);
|
||||
@@ -20,13 +18,12 @@ const SWATCH: egui::Vec2 = vec2(18.0, 15.0);
|
||||
const PLOT_HEIGHT: f32 = 90.0;
|
||||
const MAX_PLOT_POINTS: usize = 400;
|
||||
|
||||
/// Draw the legend, the probe and the panel when a results plot is shown.
|
||||
/// Draw the legend and the probe when a results plot is shown.
|
||||
pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
let Some(plot) = app.plot() else { return };
|
||||
let painter = ui.painter_at(rect);
|
||||
legend(&plot, &painter, rect, t);
|
||||
probe(app, &plot, &painter, t);
|
||||
panel(app, &plot, ui, rect, t);
|
||||
}
|
||||
|
||||
/// The legend, top left under the view buttons.
|
||||
@@ -53,11 +50,14 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
|
||||
if plot.stale {
|
||||
lines.push(("Stale: the model changed since this step was solved".into(), t.warm));
|
||||
}
|
||||
let origin = rect.left_top() + vec2(12.0, 44.0);
|
||||
let origin = rect.left_top() + vec2(12.0, 12.0);
|
||||
let font = FontId::proportional(12.0);
|
||||
let line_h = 16.0;
|
||||
let bands = plot.contour.as_ref().map_or(0, |l| l.bands.count) as f32;
|
||||
let width = 250.0;
|
||||
// As wide as its longest line (and the range labels beside the swatches).
|
||||
let widest = lines.iter().map(|(text, _)| painter.layout_no_wrap(text.clone(), font.clone(), t.overlay_text).size().x).fold(0.0_f32, f32::max);
|
||||
let title_w = painter.layout_no_wrap(title.clone(), FontId::proportional(14.0), t.overlay_text).size().x;
|
||||
let width = (widest.max(title_w) + 20.0).max(170.0);
|
||||
// Padding and title, the lines, then the swatches with the range lines under them.
|
||||
let height = 36.0 + line_h * lines.len() as f32 + if bands > 0.0 { 16.0 + bands * SWATCH.y + 2.0 * line_h } else { 0.0 };
|
||||
let frame = Rect::from_min_size(origin, vec2(width, height));
|
||||
@@ -106,96 +106,83 @@ fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, t: &Tokens) {
|
||||
painter.galley(box_rect.left_top() + vec2(8.0, 4.0), galley, t.overlay_text);
|
||||
}
|
||||
|
||||
/// The results panel, top right: step, contour field, deformation, modes, animation, reaction
|
||||
/// sums. Every control runs a command.
|
||||
fn panel(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
let area = Rect::from_min_size(pos2(rect.right() - PANEL_WIDTH - 12.0, rect.top() + 44.0), vec2(PANEL_WIDTH, rect.height() - 56.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
/// The results controls (the context panel's Results section): step, contour field,
|
||||
/// deformation, modes, animation, reaction sums. Every control runs a command.
|
||||
pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let mut run: Vec<(&'static str, Value)> = Vec::new();
|
||||
let steps: Vec<(String, bool)> = app.session.result_steps().map(|(n, c)| (n.to_string(), c)).collect();
|
||||
egui::Frame::NONE.fill(t.overlay).corner_radius(4.0).inner_margin(egui::Margin::same(8)).show(&mut child, |ui| {
|
||||
ui.set_width(PANEL_WIDTH - 16.0);
|
||||
egui::Grid::new("mc_results_grid").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| {
|
||||
ui.label("Step");
|
||||
egui::ComboBox::from_id_salt("mc_results_step").selected_text(&plot.view.step).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| {
|
||||
for (name, current) in &steps {
|
||||
let label = if *current { name.clone() } else { format!("{name} (stale)") };
|
||||
if ui.selectable_label(*name == plot.view.step, label).clicked() && *name != plot.view.step {
|
||||
run.push(("results.deformed", json!({"step": name})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Contours");
|
||||
let shown = plot.view.contour.map_or("None", Field::label);
|
||||
egui::ComboBox::from_id_salt("mc_results_field").selected_text(shown).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| {
|
||||
if ui.selectable_label(plot.view.contour.is_none(), "None").clicked() {
|
||||
run.push(("results.contour", json!({"field": "none"})));
|
||||
}
|
||||
for f in Field::available(&plot.result) {
|
||||
if ui.selectable_label(plot.view.contour == Some(f), f.label()).clicked() {
|
||||
run.push(("results.contour", json!({"field": f.id()})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Deformed");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Results").strong().color(t.overlay_text));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Close").on_hover_text("Back to the model (results.clear)").clicked() {
|
||||
run.push(("results.clear", json!({})));
|
||||
}
|
||||
});
|
||||
let mut deformed = plot.view.deformed;
|
||||
if ui.checkbox(&mut deformed, "").changed() {
|
||||
run.push(("results.deformed", json!({"show": deformed})));
|
||||
}
|
||||
let mut auto = plot.view.scale.is_none();
|
||||
if ui.checkbox(&mut auto, "Auto").on_hover_text("Scale the largest displacement to a tenth of the model").changed() {
|
||||
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale.max(f64::MIN_POSITIVE)) }})));
|
||||
}
|
||||
let mut scale = plot.scale;
|
||||
let drag = egui::DragValue::new(&mut scale).speed(plot.scale.abs().max(1e-6) * 0.01).range(1e-9..=1e12).prefix("× ");
|
||||
if ui.add_enabled(plot.view.deformed, drag).changed() {
|
||||
run.push(("results.deformed", json!({"scale": scale})));
|
||||
}
|
||||
});
|
||||
egui::Grid::new("mc_results_grid").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| {
|
||||
ui.label("Step");
|
||||
egui::ComboBox::from_id_salt("mc_results_step").selected_text(&plot.view.step).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| {
|
||||
for (name, current) in &steps {
|
||||
let label = if *current { name.clone() } else { format!("{name} (stale)") };
|
||||
if ui.selectable_label(*name == plot.view.step, label).clicked() && *name != plot.view.step {
|
||||
run.push(("results.deformed", json!({"step": name})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Contours");
|
||||
let shown = plot.view.contour.map_or("None", Field::label);
|
||||
egui::ComboBox::from_id_salt("mc_results_field").selected_text(shown).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| {
|
||||
if ui.selectable_label(plot.view.contour.is_none(), "None").clicked() {
|
||||
run.push(("results.contour", json!({"field": "none"})));
|
||||
}
|
||||
for f in Field::available(&plot.result) {
|
||||
if ui.selectable_label(plot.view.contour == Some(f), f.label()).clicked() {
|
||||
run.push(("results.contour", json!({"field": f.id()})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Deformed");
|
||||
ui.horizontal(|ui| {
|
||||
let mut deformed = plot.view.deformed;
|
||||
if ui.checkbox(&mut deformed, "").changed() {
|
||||
run.push(("results.deformed", json!({"show": deformed})));
|
||||
}
|
||||
let mut auto = plot.view.scale.is_none();
|
||||
if ui.checkbox(&mut auto, "Auto").on_hover_text("Scale the largest displacement to a tenth of the model").changed() {
|
||||
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale.max(f64::MIN_POSITIVE)) }})));
|
||||
}
|
||||
let mut scale = plot.scale;
|
||||
let drag = egui::DragValue::new(&mut scale).speed(plot.scale.abs().max(1e-6) * 0.01).range(1e-9..=1e12).prefix("× ");
|
||||
if ui.add_enabled(plot.view.deformed, drag).changed() {
|
||||
run.push(("results.deformed", json!({"scale": scale})));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate");
|
||||
let mut animate = plot.view.animate;
|
||||
if ui.checkbox(&mut animate, "").changed() {
|
||||
run.push(("results.animate", json!({"on": animate})));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
if let Kind::Explicit(e) = plot.result.kind() {
|
||||
ui.separator();
|
||||
explicit(ui, e, plot, t, &mut run);
|
||||
} else if let Kind::Frequency(f) = plot.result.kind() {
|
||||
ui.separator();
|
||||
ui.label(RichText::new("Modes").color(t.text_dim));
|
||||
egui::ScrollArea::vertical().id_salt("mc_results_modes").max_height(150.0).show(ui, |ui| {
|
||||
for (i, m) in f.modes.iter().enumerate() {
|
||||
let mode = i + 1;
|
||||
if ui.selectable_label(plot.view.mode == mode, format!("{mode} {} Hz", number(m.frequency))).clicked() {
|
||||
run.push(("results.modes", json!({"mode": mode})));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.separator();
|
||||
if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() {
|
||||
run.push(("results.reactions", json!({"step": plot.view.step})));
|
||||
}
|
||||
if let Some(r) = app.reactions.as_ref().filter(|r| r["step"] == plot.view.step.as_str()) {
|
||||
reactions_table(ui, r, t);
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Animate");
|
||||
let mut animate = plot.view.animate;
|
||||
if ui.checkbox(&mut animate, "").changed() {
|
||||
run.push(("results.animate", json!({"on": animate})));
|
||||
}
|
||||
ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim));
|
||||
ui.end_row();
|
||||
});
|
||||
if let Kind::Explicit(e) = plot.result.kind() {
|
||||
ui.separator();
|
||||
explicit(ui, e, plot, t, &mut run);
|
||||
} else if let Kind::Frequency(f) = plot.result.kind() {
|
||||
ui.separator();
|
||||
ui.label(RichText::new("Modes").color(t.text_dim));
|
||||
egui::ScrollArea::vertical().id_salt("mc_results_modes").max_height(150.0).show(ui, |ui| {
|
||||
for (i, m) in f.modes.iter().enumerate() {
|
||||
let mode = i + 1;
|
||||
if ui.selectable_label(plot.view.mode == mode, format!("{mode} {} Hz", number(m.frequency))).clicked() {
|
||||
run.push(("results.modes", json!({"mode": mode})));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.separator();
|
||||
if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() {
|
||||
run.push(("results.reactions", json!({"step": plot.view.step})));
|
||||
}
|
||||
if let Some(r) = app.reactions.as_ref().filter(|r| r["step"] == plot.view.step.as_str()) {
|
||||
reactions_table(ui, r, t);
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim));
|
||||
for (id, params) in run {
|
||||
let reply = app.run(id, params);
|
||||
if id == "results.reactions" {
|
||||
|
||||
@@ -40,27 +40,53 @@ const GEOMETRY_EDGE_WIDTH: f64 = 1.5;
|
||||
const LINE_ELEMENT_WIDTH: f64 = 3.0;
|
||||
const NODE_SIZE: f64 = 5.0;
|
||||
|
||||
/// What is shown (the browser's eye toggles).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Show {
|
||||
/// The open design and imported surfaces.
|
||||
pub geometry: bool,
|
||||
/// Elements.
|
||||
pub mesh: bool,
|
||||
pub nodes: bool,
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// Geometry and mesh, without nodes.
|
||||
pub const DEFAULT: Show = Show { geometry: true, mesh: true, nodes: false };
|
||||
}
|
||||
|
||||
/// The scene for `display` drawn in `style`.
|
||||
pub fn build(display: &DisplayMesh, style: Style, show_nodes: bool, t: &Tokens) -> Scene {
|
||||
pub fn build(display: &DisplayMesh, style: Style, show: Show, t: &Tokens) -> Scene {
|
||||
let mut s = Scene { background: Some((rgb(t.viewport_top), rgb(t.viewport_bottom))), ..Default::default() };
|
||||
// Geometry faces draw where no element faces cover them.
|
||||
let mesh_faces = show.mesh && !display.tris.is_empty();
|
||||
if style != Style::Wireframe {
|
||||
s.tris = display.tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.element(d.kind)) }).collect();
|
||||
if display.tris.is_empty() {
|
||||
if show.mesh {
|
||||
s.tris = display.tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.element(d.kind)) }).collect();
|
||||
}
|
||||
if !mesh_faces && show.geometry {
|
||||
s.tris.extend(display.surface_tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.geometry) }));
|
||||
}
|
||||
}
|
||||
if style != Style::Shaded {
|
||||
if style != Style::Shaded && show.mesh {
|
||||
// Without faces behind them the edges take the element colour, so they read on the
|
||||
// background.
|
||||
let color = rgb(if style == Style::Wireframe { t.hex } else { t.mesh_edge });
|
||||
s.lines = display.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color, width: EDGE_WIDTH }).collect();
|
||||
}
|
||||
// Face boundaries in every style: they are the geometry's outline.
|
||||
let faces_drawn = style != Style::Wireframe && display.tris.is_empty();
|
||||
let edge = rgb(if faces_drawn { t.mesh_edge } else { t.geometry_edge });
|
||||
s.lines.extend(display.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color: edge, width: GEOMETRY_EDGE_WIDTH }));
|
||||
s.lines.extend(display.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: rgb(t.element(l.kind)), width: LINE_ELEMENT_WIDTH }));
|
||||
if show_nodes {
|
||||
if show.geometry {
|
||||
s.lines.extend(display.surface_edges.iter().map(|[a, b]| SceneLine {
|
||||
a: *a,
|
||||
b: *b,
|
||||
color: rgb(t.geometry_edge),
|
||||
width: GEOMETRY_EDGE_WIDTH,
|
||||
}));
|
||||
}
|
||||
if show.mesh {
|
||||
s.lines.extend(display.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: rgb(t.element(l.kind)), width: LINE_ELEMENT_WIDTH }));
|
||||
}
|
||||
if show.nodes {
|
||||
s.points = display.nodes.iter().map(|n| ScenePoint { p: n.p, color: rgb(t.node), size: NODE_SIZE }).collect();
|
||||
}
|
||||
s
|
||||
@@ -86,11 +112,13 @@ mod tests {
|
||||
m.set_element(1, ElementKind::Tet4, vec![1, 2, 3, 4]).unwrap();
|
||||
let d = DisplayMesh::from_model(&m);
|
||||
let t = Tokens::dark();
|
||||
let s = build(&d, Style::ShadedEdges, false, &t);
|
||||
let s = build(&d, Style::ShadedEdges, Show::DEFAULT, &t);
|
||||
assert_eq!((s.tris.len(), s.lines.len(), s.points.len()), (4, 6, 0));
|
||||
let s = build(&d, Style::Shaded, true, &t);
|
||||
let s = build(&d, Style::Shaded, Show { nodes: true, ..Show::DEFAULT }, &t);
|
||||
assert_eq!((s.tris.len(), s.lines.len(), s.points.len()), (4, 0, 4));
|
||||
let s = build(&d, Style::Wireframe, false, &t);
|
||||
let s = build(&d, Style::ShadedEdges, Show { mesh: false, ..Show::DEFAULT }, &t);
|
||||
assert_eq!((s.tris.len(), s.lines.len()), (0, 0), "a hidden mesh draws nothing");
|
||||
let s = build(&d, Style::Wireframe, Show::DEFAULT, &t);
|
||||
assert_eq!((s.tris.len(), s.lines.len()), (0, 6));
|
||||
assert_eq!(s.tris.first().map(|t| t.color), None);
|
||||
assert!(radius(&d.bounds, &Camera::default()) > 0.5);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Colours and widget style: a dark theme (the default) and a light one. Every colour the UI
|
||||
//! uses comes from [`Tokens`].
|
||||
//! Colours and widget style: the Craft family's dark theme (the default, blue-grey chrome around a
|
||||
//! #2e3440 viewport) and its light one, with the same values as SolidCraft's tokens so the apps
|
||||
//! look alike side by side. Every colour the UI uses comes from [`Tokens`].
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/ui-egui/src/theme.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
//! ATTRIBUTION.md. Widget states are coloured with craft-ui's `WidgetPalette`.
|
||||
|
||||
use egui::{Color32, Stroke};
|
||||
use modelcraft_model::ElementKind;
|
||||
@@ -10,9 +11,18 @@ use modelcraft_render::Rgb;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
/// The title bar (dark in both themes) and its text.
|
||||
pub app_bar: Color32,
|
||||
pub app_bar_text: Color32,
|
||||
/// The ribbon.
|
||||
pub toolbar: Color32,
|
||||
pub panel: Color32,
|
||||
pub panel_header: Color32,
|
||||
/// Command forms and the context panel.
|
||||
pub dialog_bg: Color32,
|
||||
/// Band behind a panel's heading and, lighter, behind its section headings.
|
||||
pub heading_bg: Color32,
|
||||
pub subheading_bg: Color32,
|
||||
pub border: Color32,
|
||||
pub field: Color32,
|
||||
pub text: Color32,
|
||||
@@ -22,13 +32,20 @@ pub struct Tokens {
|
||||
pub hover: Color32,
|
||||
pub error: Color32,
|
||||
pub ok: Color32,
|
||||
/// Loads and heat in icons.
|
||||
/// Warnings, loads and heat in icons.
|
||||
pub warm: Color32,
|
||||
/// Icon line work and fills.
|
||||
pub icon: Color32,
|
||||
pub icon_fill: Color32,
|
||||
/// The journal timeline: its strip, its items and the current-position marker.
|
||||
pub timeline: Color32,
|
||||
pub timeline_item: Color32,
|
||||
pub timeline_marker: Color32,
|
||||
// ---- viewport ----
|
||||
/// Background gradient, top and bottom.
|
||||
/// Background gradient, top and bottom (equal for a flat fill).
|
||||
pub viewport_top: Color32,
|
||||
pub viewport_bottom: Color32,
|
||||
/// Buttons drawn over the viewport.
|
||||
/// Chips drawn over the viewport (navigation bar, legend, start screen).
|
||||
pub overlay: Color32,
|
||||
pub overlay_text: Color32,
|
||||
pub axis_x: Color32,
|
||||
@@ -44,7 +61,7 @@ pub struct Tokens {
|
||||
pub mesh_edge: Color32,
|
||||
pub node: Color32,
|
||||
// ---- geometry ----
|
||||
/// Faces of the open design and imported surfaces.
|
||||
/// Faces of the open design and imported surfaces (SolidCraft's body colour).
|
||||
pub geometry: Color32,
|
||||
/// Their face boundaries.
|
||||
pub geometry_edge: Color32,
|
||||
@@ -69,61 +86,35 @@ impl Tokens {
|
||||
if dark { Tokens::dark() } else { Tokens::light() }
|
||||
}
|
||||
|
||||
pub const fn dark() -> Tokens {
|
||||
Tokens {
|
||||
app_bar: Color32::from_rgb(0x23, 0x27, 0x2e),
|
||||
panel: Color32::from_rgb(0x2b, 0x30, 0x38),
|
||||
panel_header: Color32::from_rgb(0x34, 0x3a, 0x44),
|
||||
border: Color32::from_rgb(0x44, 0x4b, 0x57),
|
||||
field: Color32::from_rgb(0x1f, 0x23, 0x29),
|
||||
text: Color32::from_rgb(0xdd, 0xe2, 0xea),
|
||||
text_dim: Color32::from_rgb(0x96, 0x9f, 0xad),
|
||||
accent: Color32::from_rgb(0x4f, 0x9c, 0xe8),
|
||||
accent_soft: Color32::from_rgb(0x2d, 0x4e, 0x70),
|
||||
hover: Color32::from_rgb(0x3c, 0x43, 0x4f),
|
||||
error: Color32::from_rgb(0xf0, 0x6c, 0x64),
|
||||
ok: Color32::from_rgb(0x8c, 0xc8, 0x8c),
|
||||
warm: Color32::from_rgb(0xf0, 0xb4, 0x4a),
|
||||
viewport_top: Color32::from_rgb(0x3a, 0x41, 0x4d),
|
||||
viewport_bottom: Color32::from_rgb(0x1e, 0x22, 0x28),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(0x1c, 0x20, 0x26, 0xd0),
|
||||
overlay_text: Color32::from_rgb(0xdd, 0xe2, 0xea),
|
||||
axis_x: Color32::from_rgb(0xe0, 0x5a, 0x50),
|
||||
axis_y: Color32::from_rgb(0x6c, 0xc0, 0x5a),
|
||||
axis_z: Color32::from_rgb(0x52, 0x8c, 0xe6),
|
||||
hex: Color32::from_rgb(0x82, 0xa6, 0xc8),
|
||||
tet: Color32::from_rgb(0x8c, 0xc0, 0x96),
|
||||
wedge: Color32::from_rgb(0xc8, 0xb4, 0x82),
|
||||
shell: Color32::from_rgb(0xc8, 0x96, 0x82),
|
||||
line_element: Color32::from_rgb(0xf0, 0xc0, 0x50),
|
||||
mesh_edge: Color32::from_rgb(0x14, 0x18, 0x1e),
|
||||
node: Color32::from_rgb(0xf2, 0xe6, 0x6e),
|
||||
geometry: Color32::from_rgb(0xa8, 0xae, 0xb8),
|
||||
geometry_edge: Color32::from_rgb(0xe8, 0xec, 0xf2),
|
||||
contour: CONTOUR,
|
||||
no_result: Color32::from_rgb(0x70, 0x76, 0x80),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn light() -> Tokens {
|
||||
Tokens {
|
||||
app_bar: Color32::from_rgb(0xe4, 0xe8, 0xee),
|
||||
panel: Color32::from_rgb(0xf3, 0xf5, 0xf8),
|
||||
panel_header: Color32::from_rgb(0xe2, 0xe6, 0xec),
|
||||
border: Color32::from_rgb(0xc4, 0xcb, 0xd5),
|
||||
field: Color32::from_rgb(0xff, 0xff, 0xff),
|
||||
text: Color32::from_rgb(0x1e, 0x23, 0x2a),
|
||||
text_dim: Color32::from_rgb(0x5e, 0x67, 0x74),
|
||||
accent: Color32::from_rgb(0x1f, 0x6f, 0xc4),
|
||||
accent_soft: Color32::from_rgb(0xc6, 0xdc, 0xf4),
|
||||
hover: Color32::from_rgb(0xd8, 0xde, 0xe6),
|
||||
error: Color32::from_rgb(0xc0, 0x30, 0x28),
|
||||
ok: Color32::from_rgb(0x2c, 0x7a, 0x3a),
|
||||
warm: Color32::from_rgb(0xc0, 0x7a, 0x14),
|
||||
viewport_top: Color32::from_rgb(0xf0, 0xf3, 0xf7),
|
||||
viewport_bottom: Color32::from_rgb(0xc8, 0xd0, 0xdc),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(0xea, 0xee, 0xf3, 0xe0),
|
||||
overlay_text: Color32::from_rgb(0x1e, 0x23, 0x2a),
|
||||
app_bar: Color32::from_rgb(52, 56, 64),
|
||||
app_bar_text: Color32::from_rgb(230, 234, 242),
|
||||
toolbar: Color32::from_rgb(246, 247, 249),
|
||||
panel: Color32::from_rgb(250, 251, 252),
|
||||
panel_header: Color32::from_rgb(236, 238, 242),
|
||||
dialog_bg: Color32::from_rgb(250, 251, 252),
|
||||
heading_bg: Color32::from_rgb(218, 223, 231),
|
||||
subheading_bg: Color32::from_rgb(234, 237, 242),
|
||||
border: Color32::from_rgb(208, 212, 220),
|
||||
field: Color32::WHITE,
|
||||
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),
|
||||
hover: Color32::from_rgb(226, 234, 246),
|
||||
error: Color32::from_rgb(214, 54, 54),
|
||||
ok: Color32::from_rgb(44, 122, 58),
|
||||
warm: 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),
|
||||
timeline_item: Color32::WHITE,
|
||||
timeline_marker: Color32::from_rgb(70, 76, 88),
|
||||
viewport_top: Color32::from_rgb(240, 243, 247),
|
||||
viewport_bottom: Color32::from_rgb(203, 210, 221),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
|
||||
overlay_text: Color32::from_rgb(38, 42, 50),
|
||||
axis_x: Color32::from_rgb(0xc8, 0x3c, 0x32),
|
||||
axis_y: Color32::from_rgb(0x3c, 0x96, 0x32),
|
||||
axis_z: Color32::from_rgb(0x2c, 0x64, 0xc8),
|
||||
@@ -134,13 +125,59 @@ impl Tokens {
|
||||
line_element: Color32::from_rgb(0xc8, 0x8c, 0x1e),
|
||||
mesh_edge: Color32::from_rgb(0x1e, 0x23, 0x2a),
|
||||
node: Color32::from_rgb(0xb4, 0x3c, 0x8c),
|
||||
geometry: Color32::from_rgb(0xb4, 0xba, 0xc4),
|
||||
geometry_edge: Color32::from_rgb(0x2a, 0x30, 0x3a),
|
||||
geometry: Color32::from_rgb(176, 186, 198),
|
||||
geometry_edge: Color32::from_rgb(40, 44, 52),
|
||||
contour: CONTOUR,
|
||||
no_result: Color32::from_rgb(0x9a, 0xa0, 0xaa),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dark blue-grey: the viewport #2e3440, the chrome a little darker, light text, a blue
|
||||
/// accent.
|
||||
pub const fn dark() -> Tokens {
|
||||
let mut t = Tokens::light();
|
||||
t.app_bar = Color32::from_rgb(28, 31, 38);
|
||||
t.toolbar = Color32::from_rgb(37, 41, 49);
|
||||
t.panel = Color32::from_rgb(39, 43, 52);
|
||||
t.panel_header = Color32::from_rgb(47, 52, 62);
|
||||
t.dialog_bg = Color32::from_rgb(59, 68, 83);
|
||||
t.heading_bg = Color32::from_rgb(38, 44, 55);
|
||||
t.subheading_bg = Color32::from_rgb(48, 55, 68);
|
||||
t.border = Color32::from_rgb(63, 69, 82);
|
||||
t.field = Color32::from_rgb(30, 34, 41);
|
||||
t.text = Color32::from_rgb(222, 226, 232);
|
||||
t.text_dim = Color32::from_rgb(148, 155, 168);
|
||||
t.accent = Color32::from_rgb(58, 136, 238);
|
||||
t.accent_soft = Color32::from_rgb(42, 70, 110);
|
||||
t.hover = Color32::from_rgb(54, 61, 74);
|
||||
t.error = Color32::from_rgb(240, 100, 96);
|
||||
t.ok = Color32::from_rgb(0x8c, 0xc8, 0x8c);
|
||||
t.warm = Color32::from_rgb(236, 170, 70);
|
||||
t.icon = Color32::from_rgb(208, 214, 226);
|
||||
t.icon_fill = Color32::from_rgb(86, 112, 150);
|
||||
t.timeline = Color32::from_rgb(33, 37, 45);
|
||||
t.timeline_item = Color32::from_rgb(52, 58, 70);
|
||||
t.timeline_marker = Color32::from_rgb(170, 178, 192);
|
||||
t.viewport_top = Color32::from_rgb(46, 52, 64);
|
||||
t.viewport_bottom = Color32::from_rgb(46, 52, 64);
|
||||
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
|
||||
t.overlay_text = Color32::from_rgb(222, 226, 232);
|
||||
t.axis_x = Color32::from_rgb(0xe0, 0x5a, 0x50);
|
||||
t.axis_y = Color32::from_rgb(0x6c, 0xc0, 0x5a);
|
||||
t.axis_z = Color32::from_rgb(0x52, 0x8c, 0xe6);
|
||||
t.hex = Color32::from_rgb(0x82, 0xa6, 0xc8);
|
||||
t.tet = Color32::from_rgb(0x8c, 0xc0, 0x96);
|
||||
t.wedge = Color32::from_rgb(0xc8, 0xb4, 0x82);
|
||||
t.shell = Color32::from_rgb(0xc8, 0x96, 0x82);
|
||||
t.line_element = Color32::from_rgb(0xf0, 0xc0, 0x50);
|
||||
t.mesh_edge = Color32::from_rgb(0x14, 0x18, 0x1e);
|
||||
t.node = Color32::from_rgb(0xf2, 0xe6, 0x6e);
|
||||
t.geometry = Color32::from_rgb(152, 156, 162);
|
||||
t.geometry_edge = Color32::from_rgb(16, 16, 18);
|
||||
t.no_result = Color32::from_rgb(0x70, 0x76, 0x80);
|
||||
t
|
||||
}
|
||||
|
||||
/// The contour colour scale for the renderer, low to high.
|
||||
pub fn contour_stops(&self) -> [Rgb; 5] {
|
||||
self.contour.map(rgb)
|
||||
@@ -172,23 +209,49 @@ fn visuals(dark: bool) -> egui::Visuals {
|
||||
v.extreme_bg_color = t.field;
|
||||
v.faint_bg_color = t.panel_header;
|
||||
v.override_text_color = Some(t.text);
|
||||
v.widgets.inactive.weak_bg_fill = t.panel_header;
|
||||
v.widgets.inactive.bg_fill = t.panel_header;
|
||||
v.widgets.active.weak_bg_fill = t.accent_soft;
|
||||
craft_ui::theme::WidgetPalette {
|
||||
noninteractive: v.widgets.noninteractive.bg_fill,
|
||||
inactive: t.panel_header,
|
||||
hovered: t.hover,
|
||||
active: t.accent_soft,
|
||||
open: t.panel_header,
|
||||
}
|
||||
.apply_to(&mut v.widgets, egui::CornerRadius::same(3), Stroke::new(1.0, t.text));
|
||||
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.widgets.open.weak_bg_fill = t.panel_header;
|
||||
v.window_corner_radius = egui::CornerRadius::same(6);
|
||||
v.menu_corner_radius = egui::CornerRadius::same(6);
|
||||
v
|
||||
}
|
||||
|
||||
/// Style the context for the dark or light theme.
|
||||
/// The font family of the interface's caps labels (tabs, panel labels, workspaces, headings):
|
||||
/// Inter SemiBold, then egui's own proportional fonts for what it lacks. egui has no bold weight
|
||||
/// of its default font, so labels need a face of their own to read at small sizes.
|
||||
pub const LABEL_FAMILY: &str = "label";
|
||||
/// Inter SemiBold 4.1 (SIL OFL 1.1, unmodified; fonts/OFL.txt, ATTRIBUTION.md).
|
||||
static INTER_SEMIBOLD: &[u8] = include_bytes!("../fonts/Inter-SemiBold.ttf");
|
||||
|
||||
/// The font definitions: egui's defaults plus the label family.
|
||||
pub fn fonts() -> egui::FontDefinitions {
|
||||
let mut f = egui::FontDefinitions::default();
|
||||
f.font_data.insert("inter-semibold".into(), std::sync::Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)));
|
||||
let mut family = vec!["inter-semibold".to_string()];
|
||||
family.extend(f.families.get(&egui::FontFamily::Proportional).cloned().unwrap_or_default());
|
||||
f.families.insert(egui::FontFamily::Name(LABEL_FAMILY.into()), family);
|
||||
f
|
||||
}
|
||||
|
||||
/// The caps-label font at `size`: the label family once the context has it (fonts set with
|
||||
/// [`apply`] take effect from the next pass), the default proportional font until then.
|
||||
pub fn label_font(ctx: &egui::Context, size: f32) -> egui::FontId {
|
||||
let family = egui::FontFamily::Name(LABEL_FAMILY.into());
|
||||
if ctx.fonts(|f| f.families().contains(&family)) { egui::FontId::new(size, family) } else { egui::FontId::proportional(size) }
|
||||
}
|
||||
|
||||
/// Style the context for the dark or light theme, with the interface's fonts.
|
||||
pub fn apply(ctx: &egui::Context, dark: bool) {
|
||||
ctx.set_fonts(fonts());
|
||||
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
ctx.set_visuals(visuals(dark));
|
||||
ctx.global_style_mut(|s| {
|
||||
@@ -201,6 +264,36 @@ pub fn apply(ctx: &egui::Context, dark: bool) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Title and section bands stand out from a panel, the title more than its sections.
|
||||
#[test]
|
||||
fn heading_bands_are_darker_than_panels_in_order() {
|
||||
let luma = |c: Color32| u32::from(c.r()) * 3 + u32::from(c.g()) * 6 + u32::from(c.b());
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
assert!(luma(t.heading_bg) < luma(t.subheading_bg), "{t:?}");
|
||||
assert!(luma(t.subheading_bg) < luma(t.dialog_bg), "{t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The label font is used only once the context has it; before that, the default font.
|
||||
#[test]
|
||||
fn the_label_font_falls_back_until_it_is_installed() {
|
||||
let ctx = egui::Context::default();
|
||||
let run = |ctx: &egui::Context| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let f = label_font(ui.ctx(), 12.0);
|
||||
// Laying text out in it never panics.
|
||||
let _ = ui.painter().layout_no_wrap("PREPARE".into(), f, Color32::WHITE);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
run(&ctx);
|
||||
assert_eq!(label_font(&ctx, 12.0).family, egui::FontFamily::Proportional);
|
||||
apply(&ctx, true);
|
||||
run(&ctx);
|
||||
assert_eq!(label_font(&ctx, 12.0).family, egui::FontFamily::Name(LABEL_FAMILY.into()));
|
||||
run(&ctx);
|
||||
}
|
||||
|
||||
/// Every element kind has a colour, and edges stand out from the faces they lie on.
|
||||
#[test]
|
||||
fn element_colours_contrast_with_edges() {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! The timeline (bottom): the command journal in order, as SolidCraft shows its features. Runs of
|
||||
//! the same command make one item with a count; the marker stands after the current state, and
|
||||
//! undone commands follow it greyed, ready to redo. Clicking an item (or the transport buttons)
|
||||
//! undoes or redoes up to it with `edit.undo` / `edit.redo`; commands older than the undo history
|
||||
//! cannot be reached and say so.
|
||||
//!
|
||||
//! Layout follows SolidCraft's `crates/ui-egui/src/timeline.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, UiBuilder, Vec2, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::icons::{self, Look};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The timeline's height.
|
||||
pub const HEIGHT: f32 = 40.0;
|
||||
const ITEM: f32 = 28.0;
|
||||
const STRIDE: f32 = 30.0;
|
||||
|
||||
/// A run of the same command in the journal (`start..end`), or among the undone commands
|
||||
/// (positions past the journal's end).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Item {
|
||||
pub command: String,
|
||||
/// Journal positions: after this item the journal holds `end` commands.
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
/// The first command's parameters (the tooltip).
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn count(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
}
|
||||
|
||||
/// The journal and the undone commands as runs of the same command, rebuilt only when the journal
|
||||
/// or the undo history changed.
|
||||
fn cached_items(app: &mut ModelApp) -> (std::sync::Arc<Vec<Item>>, usize) {
|
||||
let key = (app.session.revision(), app.session.journal.len(), app.session.undo_depth());
|
||||
match &app.timeline {
|
||||
Some((k, list)) if *k == key => (list.clone(), key.1),
|
||||
_ => {
|
||||
let (list, done) = items(app);
|
||||
let list = std::sync::Arc::new(list);
|
||||
app.timeline = Some((key, list.clone()));
|
||||
(list, done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The journal and the undone commands as runs of the same command.
|
||||
pub fn items(app: &ModelApp) -> (Vec<Item>, usize) {
|
||||
let done = app.session.journal.len();
|
||||
let all = app.session.journal.iter().chain(app.session.redo_entries());
|
||||
let mut out: Vec<Item> = Vec::new();
|
||||
for (i, (command, params)) in all.enumerate() {
|
||||
// A run never crosses the current position.
|
||||
match out.last_mut() {
|
||||
Some(last) if last.command == *command && last.end == i && i != done => last.end = i + 1,
|
||||
_ => out.push(Item { command: command.clone(), start: i, end: i + 1, params: params.clone() }),
|
||||
}
|
||||
}
|
||||
(out, done)
|
||||
}
|
||||
|
||||
impl ModelApp {
|
||||
/// Undo or redo until the journal holds `target` commands (as far as the history reaches):
|
||||
/// one `edit.undo` or `edit.redo` with a `count`.
|
||||
pub fn roll_to(&mut self, target: usize) {
|
||||
let (undo, redo) = self.session.undo_depth();
|
||||
let done = self.session.journal.len();
|
||||
let target = target.clamp(done.saturating_sub(undo), done + redo);
|
||||
let step = if target < done { "edit.undo" } else { "edit.redo" };
|
||||
if target != done {
|
||||
// An error shows in the status bar.
|
||||
let _ = self.run(step, json!({"count": done.abs_diff(target)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for an item's tooltip: the command, how many, and its first parameters.
|
||||
fn tip(item: &Item, reachable: bool) -> String {
|
||||
let label = crate::command_label(&item.command);
|
||||
let mut s = if item.count() > 1 { format!("{label} × {}", item.count()) } else { label.to_string() };
|
||||
let params = item.params.to_string();
|
||||
if params != "{}" {
|
||||
let short: String = params.chars().take(160).collect();
|
||||
s.push_str(&format!("\n{}{short}{}", if item.count() > 1 { "first: " } else { "" }, if params.chars().count() > 160 { "…" } else { "" }));
|
||||
}
|
||||
s.push_str(if reachable { "\nClick to roll the model to here" } else { "\nOlder than the undo history" });
|
||||
s
|
||||
}
|
||||
|
||||
/// The transport buttons' glyphs: to the start, back, forward, to the end.
|
||||
fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
|
||||
let tri = |x: f32, left: bool| {
|
||||
let (a, b) = if left { (x + 3.5, x - 3.5) } else { (x - 3.5, x + 3.5) };
|
||||
egui::Shape::convex_polygon(vec![pos2(a, c.y - 4.5), pos2(a, c.y + 4.5), pos2(b, c.y)], col, Stroke::NONE)
|
||||
};
|
||||
let bar = |x: f32| egui::Shape::line_segment([pos2(x, c.y - 4.5), pos2(x, c.y + 4.5)], Stroke::new(1.5, col));
|
||||
match what {
|
||||
"first" => {
|
||||
p.add(bar(c.x - 4.5));
|
||||
p.add(tri(c.x + 1.0, true));
|
||||
}
|
||||
"back" => {
|
||||
p.add(tri(c.x, true));
|
||||
}
|
||||
"forward" => {
|
||||
p.add(tri(c.x, false));
|
||||
}
|
||||
_ => {
|
||||
p.add(tri(c.x - 1.0, false));
|
||||
p.add(bar(c.x + 4.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timeline(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let mut roll: Option<usize> = None;
|
||||
egui::Panel::bottom("mc_timeline").exact_size(HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
app.place("timeline", r);
|
||||
let p = ui.painter().clone();
|
||||
p.line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border));
|
||||
let (undo, redo) = app.session.undo_depth();
|
||||
let done = app.session.journal.len();
|
||||
let oldest = done.saturating_sub(undo);
|
||||
let mut x = r.left() + 10.0;
|
||||
for (what, target, tip) in [
|
||||
("first", oldest, "Undo everything the history holds"),
|
||||
("back", done.saturating_sub(1), "Undo"),
|
||||
("forward", done + 1, "Redo"),
|
||||
("last", done + redo, "Redo everything undone"),
|
||||
] {
|
||||
let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), Vec2::splat(22.0));
|
||||
let enabled = target != done && target >= oldest && target <= done + redo;
|
||||
let resp = ui.interact(br, ui.id().with(("mc_transport", what)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
if resp.hovered() && enabled {
|
||||
p.rect_filled(br, 3.0, t.hover);
|
||||
}
|
||||
transport_glyph(&p, br.center(), what, if enabled { t.icon } else { t.border });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
|
||||
if resp.on_hover_text(tip).clicked() && enabled {
|
||||
roll = Some(target);
|
||||
}
|
||||
x += 24.0;
|
||||
}
|
||||
x += 14.0;
|
||||
let (list, done) = cached_items(app);
|
||||
let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom());
|
||||
if list.is_empty() {
|
||||
p.text(
|
||||
pos2(x, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"The journal is empty: every command that changes the model appears here",
|
||||
FontId::proportional(12.0),
|
||||
t.text_dim,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| {
|
||||
egui::ScrollArea::horizontal().id_salt("mc_timeline_scroll").stick_to_right(true).auto_shrink([false, false]).show(ui, |ui| {
|
||||
let marker_at = list.iter().position(|i| i.start >= done).unwrap_or(list.len());
|
||||
let width = list.len() as f32 * STRIDE + 12.0;
|
||||
let (area, _) = ui.allocate_exact_size(vec2(width, r.height()), Sense::hover());
|
||||
let p = ui.painter().clone();
|
||||
let marker =
|
||||
|x: f32| p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(5.0, r.height() - 10.0)), 2.0, t.timeline_marker);
|
||||
let mut x = area.left();
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
if k == marker_at {
|
||||
marker(x);
|
||||
x += 10.0;
|
||||
}
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), Vec2::splat(ITEM));
|
||||
x += STRIDE;
|
||||
if !ui.is_rect_visible(br) {
|
||||
continue;
|
||||
}
|
||||
let undone = item.start >= done;
|
||||
let reachable = item.end >= oldest;
|
||||
let resp = ui.interact(br, ui.id().with(("mc_journal", k)), if reachable { Sense::click() } else { Sense::hover() });
|
||||
let bg = if resp.hovered() && reachable { t.hover } else { t.timeline_item };
|
||||
p.rect(br, 3.0, bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let icon = modelcraft_engine::catalogue::entry_of(&item.command).map_or("ui.views", |c| c.id);
|
||||
let tint = if undone || !reachable { Color32::from_white_alpha(110) } else { Color32::WHITE };
|
||||
icons::paint_tinted(app, ui, icon, br.shrink(4.0), Look::of(!undone), tint);
|
||||
if item.count() > 1 {
|
||||
let text = p.layout_no_wrap(format!("{}", item.count()), FontId::proportional(9.0), t.text);
|
||||
let b = Rect::from_min_size(br.right_bottom() - text.size() - vec2(3.0, 1.0), text.size() + vec2(3.0, 1.0));
|
||||
p.rect_filled(b, 3.0, t.panel_header);
|
||||
p.galley(b.min + vec2(1.5, 0.5), text, t.text);
|
||||
}
|
||||
let label = crate::command_label(&item.command);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, reachable, label));
|
||||
if resp
|
||||
.on_hover_ui(|ui| {
|
||||
ui.label(tip(item, reachable));
|
||||
})
|
||||
.clicked()
|
||||
&& reachable
|
||||
{
|
||||
roll = Some(item.end);
|
||||
}
|
||||
}
|
||||
if marker_at == list.len() {
|
||||
marker(x);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(target) = roll {
|
||||
app.roll_to(target);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use modelcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runs_of_a_command_are_one_item_and_rolling_undoes_and_redoes() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
for l in 1..=3 {
|
||||
app.run("mesh.node.add", json!({"label": l, "xyz": [l, 0, 0]})).unwrap();
|
||||
}
|
||||
app.run(
|
||||
"model.material.create",
|
||||
json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}),
|
||||
)
|
||||
.unwrap();
|
||||
let (list, done) = items(&app);
|
||||
assert_eq!(done, 4);
|
||||
assert_eq!(list.iter().map(|i| (i.command.as_str(), i.count())).collect::<Vec<_>>(), [("mesh.node.add", 3), ("model.material.create", 1)]);
|
||||
app.roll_to(2);
|
||||
assert_eq!(app.session.journal.len(), 2);
|
||||
assert!(app.session.model.node(3).is_none());
|
||||
// The undone commands follow the marker, split from the done ones.
|
||||
let (list, done) = items(&app);
|
||||
assert_eq!(done, 2);
|
||||
assert_eq!(list.iter().map(|i| (i.start, i.end)).collect::<Vec<_>>(), [(0, 2), (2, 3), (3, 4)]);
|
||||
app.roll_to(4);
|
||||
assert!(app.session.model.materials().contains_key("Steel"));
|
||||
app.roll_to(100);
|
||||
assert_eq!(app.session.journal.len(), 4, "clamped to what can be redone");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The window's own title bar. On Windows and Linux the window has no OS decorations
|
||||
//! ([`ModelApp::custom_titlebar`]): empty space in the application bar drags the window and a
|
||||
//! double-click there maximizes or restores it, caption buttons at the far right minimize,
|
||||
//! maximize/restore and close, and thin invisible zones along the window's edges resize it.
|
||||
//! macOS keeps its traffic lights over the bar ([`ModelApp::integrated_titlebar`]).
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/ui-egui/src/titlebar.rs` (itself from VectorCraft's), both
|
||||
//! MIT OR Apache-2.0, see ATTRIBUTION.md.
|
||||
|
||||
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Room for the macOS traffic lights at the bar's left end.
|
||||
pub const TRAFFIC_LIGHTS: f32 = 76.0;
|
||||
/// Resize zone thickness along an edge, and the side of a corner zone.
|
||||
const EDGE: f32 = 5.0;
|
||||
const CORNER: f32 = 12.0;
|
||||
/// The Close button's hover colour (as on Windows).
|
||||
const CLOSE_HOVER: Color32 = Color32::from_rgb(196, 43, 28);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
pub fn caption_id(c: Caption) -> Id {
|
||||
Id::new(("titlebar-caption", c as u8))
|
||||
}
|
||||
|
||||
fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// The bar's background: a press-and-drag on empty space moves the window, a double-click
|
||||
/// toggles maximize. Call it before laying out the bar so its buttons, registered later, take
|
||||
/// their clicks.
|
||||
pub fn drag_area(ui: &Ui, rect: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag());
|
||||
// egui hands a drag that starts on a click-only widget to the drag-sensing background below
|
||||
// it, so remember whether the press itself landed on bare background.
|
||||
if ctx.input(|i| i.pointer.any_pressed()) {
|
||||
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
|
||||
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
toggle_maximize(ctx);
|
||||
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize, Maximize/Restore and Close at the right end of `bar`, full bar height, flush with
|
||||
/// the window's edge so a maximized window's corner hits Close.
|
||||
pub fn caption_buttons(app: &mut ModelApp, ui: &mut Ui, bar: Rect) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let max = maximized(ui.ctx());
|
||||
let mut clicked = None;
|
||||
for (i, c) in CAPTIONS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height()));
|
||||
let resp = ui.interact(r, caption_id(c), Sense::click());
|
||||
let close = c == Caption::Close;
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) if close => CLOSE_HOVER.gamma_multiply(0.8),
|
||||
(_, true) if close => CLOSE_HOVER,
|
||||
(true, _) => Color32::from_white_alpha(18),
|
||||
(_, true) => Color32::from_white_alpha(30),
|
||||
_ => Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = if close && resp.hovered() { Color32::WHITE } else { t.app_bar_text };
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
let tip = match c {
|
||||
Caption::Minimize => "Minimize",
|
||||
Caption::Maximize if max => "Restore",
|
||||
Caption::Maximize => "Maximize",
|
||||
Caption::Close => "Close",
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
clicked = Some(c);
|
||||
}
|
||||
}
|
||||
let ctx = ui.ctx();
|
||||
match clicked {
|
||||
Some(Caption::Minimize) => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
Some(Caption::Maximize) => toggle_maximize(ctx),
|
||||
Some(Caption::Close) => {
|
||||
app.quit_requested = true;
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 pt line glyphs of the caption buttons.
|
||||
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
|
||||
let p = ui.painter();
|
||||
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
|
||||
match c {
|
||||
Caption::Minimize => {
|
||||
p.line_segment([g.left_center(), g.right_center()], s);
|
||||
}
|
||||
// Restore: a front square with the corner of the one behind it.
|
||||
Caption::Maximize if maximized => {
|
||||
let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
|
||||
p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
|
||||
p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
|
||||
}
|
||||
Caption::Maximize => {
|
||||
p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
|
||||
}
|
||||
Caption::Close => {
|
||||
p.line_segment([g.left_top(), g.right_bottom()], s);
|
||||
p.line_segment([g.right_top(), g.left_bottom()], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor).
|
||||
/// Corners come last so they win where they overlap the edges.
|
||||
fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {
|
||||
use ResizeDirection::*;
|
||||
let (e, c) = (EDGE, CORNER);
|
||||
[
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
|
||||
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
|
||||
]
|
||||
}
|
||||
|
||||
/// Invisible resize zones along the window's edges, above everything else; a press in one
|
||||
/// starts an OS resize in that direction. None while the window is maximized or fullscreen.
|
||||
pub fn resize_zones(ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let w = ctx.content_rect();
|
||||
let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize"));
|
||||
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
|
||||
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
|
||||
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Event, Pos2, RawInput, ViewportId};
|
||||
use modelcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A headless window `width` pt wide showing the application bar and the resize zones.
|
||||
struct Win {
|
||||
ctx: egui::Context,
|
||||
app: ModelApp,
|
||||
width: f32,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
fn new(width: f32) -> Self {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.custom_titlebar = true;
|
||||
Self { ctx: egui::Context::default(), app, width, time: 0.0 }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
|
||||
self.time += 0.05;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 700.0))),
|
||||
time: Some(self.time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
crate::appbar::app_bar(app, ui);
|
||||
resize_zones(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
|
||||
let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
|
||||
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn caption(&self, c: Caption) -> Rect {
|
||||
self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_buttons_sit_in_the_corner_and_close_quits() {
|
||||
let mut w = Win::new(1200.0);
|
||||
w.frame(vec![]);
|
||||
w.frame(vec![]);
|
||||
let close = w.caption(Caption::Close);
|
||||
assert_eq!((close.right(), close.top()), (1200.0, 0.0));
|
||||
assert!(w.gesture(&[w.caption(Caption::Minimize).center()]).contains(&ViewportCommand::Minimized(true)));
|
||||
assert!(w.gesture(&[w.caption(Caption::Maximize).center()]).contains(&ViewportCommand::Maximized(true)));
|
||||
assert!(w.gesture(&[close.center()]).contains(&ViewportCommand::Close));
|
||||
assert!(w.app.quit_requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bar_space_drags_and_edges_resize() {
|
||||
let mut w = Win::new(1600.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(1000.0, 17.0);
|
||||
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
|
||||
assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::West)]);
|
||||
}
|
||||
}
|
||||
@@ -1,254 +1,407 @@
|
||||
//! The toolbar: one tab per stage of the analyst's workflow, each a row of panels of icon buttons,
|
||||
//! all from the capability catalogue (`modelcraft_engine::catalogue`).
|
||||
//! The ribbon: the workspace switcher, the workspace's tabs, and the current tab's panels, all
|
||||
//! from the capability catalogue (`modelcraft_engine::catalogue`).
|
||||
//!
|
||||
//! When the window is too narrow for labelled buttons, every panel goes icon-only (the label is
|
||||
//! in the tooltip), then panels collapse from the right into one drop-down button each.
|
||||
//! Each panel shows its ready entries as icon buttons (up to [`MAX_BUTTONS`]; a panel with none
|
||||
//! ready shows its first entry, greyed with its milestone) over its label with a caret. The label
|
||||
//! opens the panel's drop-down, which lists every entry of the panel: ready ones with their
|
||||
//! related commands (edit, delete…) under them, coming-soon ones greyed with their milestone.
|
||||
//! When the window is too narrow, panels collapse from the right into one button that opens the
|
||||
//! same drop-down, then into the » menu at the end. Nothing is dropped.
|
||||
//!
|
||||
//! A ready entry starts its command (the same as the Commands panel: a command without
|
||||
//! parameters runs, one with parameters opens its form); its related commands (edit, delete…)
|
||||
//! are in the drop-down beside it. A planned entry is drawn greyed with its milestone and does
|
||||
//! nothing; its tooltip says when it comes. Structure follows SolidCraft's
|
||||
//! `crates/ui-egui/src/toolbar.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
//! A ready entry starts its command ([`ModelApp::start`]); a coming-soon entry does nothing and
|
||||
//! its tooltip says when it comes. Structure follows SolidCraft's `crates/ui-egui/src/toolbar.rs`
|
||||
//! (MIT OR Apache-2.0), see ATTRIBUTION.md; the tabs are craft-ui `Tab`s.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, FontId, Galley, Rect, RichText, Sense, Stroke, vec2};
|
||||
use modelcraft_engine::catalogue::{self, Capability, Tab};
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Vec2, pos2, vec2};
|
||||
use modelcraft_engine::catalogue::{self, Capability, Tab, Workspace};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::icons::{self, Look};
|
||||
use crate::theme::{Tokens, label_font};
|
||||
|
||||
/// Icon side (points).
|
||||
const ICON: f32 = 24.0;
|
||||
/// Button height with its label, and the width the label wraps at (points).
|
||||
const BUTTON_H: f32 = 60.0;
|
||||
const LABEL_W: f32 = 64.0;
|
||||
/// A button without its label (compact panels).
|
||||
const COMPACT: f32 = ICON + 12.0;
|
||||
/// The drop-down strip beside a button with related commands.
|
||||
const ARROW_W: f32 = 12.0;
|
||||
/// A collapsed panel: one button that opens the list of its entries.
|
||||
const COLLAPSED_W: f32 = COMPACT + ARROW_W;
|
||||
/// Room a panel separator takes.
|
||||
const PANEL_GAP: f32 = 13.0;
|
||||
/// Room between buttons of a panel.
|
||||
const BUTTON_GAP: f32 = 2.0;
|
||||
/// The ribbon's height.
|
||||
pub const HEIGHT: f32 = 92.0;
|
||||
/// Most buttons a panel shows; the rest are in its drop-down.
|
||||
pub const MAX_BUTTONS: usize = 4;
|
||||
/// A button's side and the room it takes.
|
||||
const BUTTON: f32 = 38.0;
|
||||
const STRIDE: f32 = 40.0;
|
||||
/// Where the tabs and the panels start, right of the workspace switcher.
|
||||
const PANELS_X: f32 = 112.0;
|
||||
/// A collapsed panel's width, and the gap after each panel (its separator).
|
||||
pub const COLLAPSED_WIDTH: f32 = 72.0;
|
||||
const PANEL_GAP: f32 = 2.0;
|
||||
/// The » menu's width.
|
||||
const MORE_WIDTH: f32 = 40.0;
|
||||
/// A drop-down row's height.
|
||||
const ROW_H: f32 = 24.0;
|
||||
|
||||
/// How a panel is drawn on a toolbar that may be too narrow for every labelled button.
|
||||
/// How an entry is shown on a toolbar that may be too narrow for every panel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Fit {
|
||||
/// Icon and label.
|
||||
/// A button of its panel.
|
||||
Full,
|
||||
/// Icon only; the label is in the tooltip.
|
||||
Compact,
|
||||
/// One button that lists the panel's entries.
|
||||
/// Its panel is one button that opens the drop-down.
|
||||
Collapsed,
|
||||
/// Its panel is in the » menu.
|
||||
More,
|
||||
/// In its panel's drop-down (a row while it is open).
|
||||
Menu,
|
||||
}
|
||||
|
||||
/// Fit panels into `room`: every panel in full when they all fit; otherwise every panel compact,
|
||||
/// then panels wider than a collapsed one collapse from the right until the row fits. Nothing is
|
||||
/// dropped.
|
||||
pub fn fit(full: &[f32], compact: &[f32], room: f32) -> Vec<Fit> {
|
||||
let total = |widths: &mut dyn Iterator<Item = f32>| -> f32 { widths.map(|w| w + PANEL_GAP).sum() };
|
||||
if total(&mut full.iter().copied()) <= room {
|
||||
return vec![Fit::Full; full.len()];
|
||||
}
|
||||
let mut fits = vec![Fit::Compact; compact.len()];
|
||||
for i in (0..fits.len()).rev() {
|
||||
let width = total(&mut compact.iter().zip(&fits).map(|(w, f)| if *f == Fit::Collapsed { COLLAPSED_W } else { *w }));
|
||||
if width <= room {
|
||||
break;
|
||||
}
|
||||
if let (Some(f), Some(w)) = (fits.get_mut(i), compact.get(i))
|
||||
&& *w > COLLAPSED_W
|
||||
{
|
||||
*f = Fit::Collapsed;
|
||||
/// A panel's width with `buttons` buttons.
|
||||
pub fn panel_width(buttons: usize) -> f32 {
|
||||
(buttons.max(1) as f32 * STRIDE).max(64.0) + 8.0
|
||||
}
|
||||
|
||||
/// Fit panels of these full widths into `room`: all in full when they fit; otherwise panels
|
||||
/// collapse into their drop-down from the right, then (all collapsed and still too wide) the last
|
||||
/// ones go into the » menu. Nothing is dropped.
|
||||
pub fn fit(widths: &[f32], room: f32) -> Vec<Fit> {
|
||||
let mut fits = vec![Fit::Full; widths.len()];
|
||||
let total = |fits: &[Fit]| -> f32 {
|
||||
let more = if fits.contains(&Fit::More) { MORE_WIDTH } else { 0.0 };
|
||||
more + widths
|
||||
.iter()
|
||||
.zip(fits)
|
||||
.map(|(w, f)| match f {
|
||||
Fit::Full | Fit::Menu => w + PANEL_GAP,
|
||||
Fit::Collapsed => COLLAPSED_WIDTH.min(*w) + PANEL_GAP,
|
||||
Fit::More => 0.0,
|
||||
})
|
||||
.sum::<f32>()
|
||||
};
|
||||
for stage in [Fit::Collapsed, Fit::More] {
|
||||
for i in (0..widths.len()).rev() {
|
||||
if total(&fits) <= room {
|
||||
return fits;
|
||||
}
|
||||
if let Some(f) = fits.get_mut(i) {
|
||||
*f = stage;
|
||||
}
|
||||
}
|
||||
}
|
||||
fits
|
||||
}
|
||||
|
||||
/// An entry's label laid out under its icon (at most two lines).
|
||||
fn label(ui: &egui::Ui, t: &Tokens, c: &Capability) -> Arc<Galley> {
|
||||
let colour = if c.command().is_some() { t.text } else { t.text_dim };
|
||||
let mut job = egui::text::LayoutJob::simple(c.label.to_string(), FontId::proportional(11.0), colour, LABEL_W);
|
||||
job.wrap.max_rows = 2;
|
||||
job.halign = egui::Align::Center;
|
||||
ui.painter().layout_job(job)
|
||||
/// The buttons a panel shows: its ready entries (at most [`MAX_BUTTONS`]), or its first entry
|
||||
/// when none is ready yet.
|
||||
pub fn buttons(entries: &'static [Capability]) -> Vec<&'static Capability> {
|
||||
let ready: Vec<_> = entries.iter().filter(|c| c.command().is_some()).take(MAX_BUTTONS).collect();
|
||||
if ready.is_empty() { entries.first().into_iter().collect() } else { ready }
|
||||
}
|
||||
|
||||
fn arrow_w(c: &Capability) -> f32 {
|
||||
if c.command().is_some() && !c.more.is_empty() { ARROW_W } else { 0.0 }
|
||||
/// The key of a panel's drop-down in [`crate::UiState::menu`]: `<tab>/<panel>`.
|
||||
pub fn panel_key(tab: Tab, panel: &str) -> String {
|
||||
format!("{}/{panel}", tab.id())
|
||||
}
|
||||
|
||||
fn panel_width(widths: impl Iterator<Item = f32>) -> f32 {
|
||||
let (sum, n) = widths.fold((0.0, 0usize), |(s, n), w| (s + w, n + 1));
|
||||
sum + BUTTON_GAP * n.saturating_sub(1) as f32
|
||||
/// A small drop-down caret.
|
||||
pub 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));
|
||||
}
|
||||
|
||||
/// A milestone badge ("M5") with its top right corner at `corner`.
|
||||
fn badge(p: &egui::Painter, t: &Tokens, corner: egui::Pos2, m: &str) {
|
||||
let text = p.layout_no_wrap(m.to_string(), FontId::proportional(9.0), t.text_dim);
|
||||
let r = Rect::from_min_size(pos2(corner.x - text.size().x - 4.0, corner.y), text.size() + vec2(4.0, 0.0));
|
||||
p.rect_filled(r, 3.0, t.panel_header);
|
||||
p.rect_stroke(r, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
p.galley(r.min + vec2(2.0, 0.0), text, t.text_dim);
|
||||
}
|
||||
|
||||
pub fn toolbar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let mut action = None;
|
||||
egui::Panel::top("mc_toolbar").frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 4))).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for tab in Tab::ALL {
|
||||
let text = RichText::new(tab.label()).color(if app.ui.tab == tab { t.accent } else { t.text });
|
||||
if ui.selectable_label(app.ui.tab == tab, text).clicked() {
|
||||
app.ui.tab = tab;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
let mut start: Option<&'static str> = None;
|
||||
egui::Panel::top("mc_toolbar").exact_size(HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.toolbar)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
app.place("ribbon", r);
|
||||
app.toolbar_rects.clear();
|
||||
let panels: Vec<&'static [Capability]> = catalogue::panels(app.ui.tab).collect();
|
||||
let labels: Vec<Vec<Arc<Galley>>> = panels.iter().map(|p| p.iter().map(|c| label(ui, &t, c)).collect()).collect();
|
||||
let full: Vec<f32> = panels
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|(p, l)| panel_width(p.iter().zip(l).map(|(c, g)| g.size().x.max(ICON + 12.0) + 8.0 + arrow_w(c))))
|
||||
.collect();
|
||||
let compact: Vec<f32> = panels.iter().map(|p| panel_width(p.iter().map(|c| COMPACT + arrow_w(c)))).collect();
|
||||
let fits = fit(&full, &compact, ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
for (i, ((entries, galleys), fit)) in panels.iter().zip(labels).zip(&fits).enumerate() {
|
||||
let Some(panel) = entries.first().map(|c| c.panel) else { continue };
|
||||
if i > 0 {
|
||||
ui.separator();
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = BUTTON_GAP;
|
||||
let clicked = match fit {
|
||||
Fit::Collapsed => collapsed(app, ui, &t, panel, entries),
|
||||
Fit::Full | Fit::Compact => {
|
||||
let mut clicked = None;
|
||||
for (c, galley) in entries.iter().zip(galleys) {
|
||||
let galley = (*fit == Fit::Full).then_some(galley);
|
||||
clicked = button(app, ui, &t, c, galley).or(clicked);
|
||||
}
|
||||
clicked
|
||||
}
|
||||
};
|
||||
action = clicked.or(action);
|
||||
});
|
||||
let width = ui.min_rect().width();
|
||||
ui.allocate_ui(vec2(width, 14.0), |ui| {
|
||||
ui.centered_and_justified(|ui| ui.add(egui::Label::new(RichText::new(panel).small().color(t.text_dim)).truncate()));
|
||||
});
|
||||
app.panel_rects.clear();
|
||||
let painter = ui.painter().clone();
|
||||
workspace_switcher(app, ui, &t, r);
|
||||
// Tabs.
|
||||
let mut x = r.left() + PANELS_X;
|
||||
for &tab in app.ui.workspace.tabs() {
|
||||
let label = tab.label().to_uppercase();
|
||||
let w = 12.0 + painter.layout_no_wrap(label.clone(), label_font(ui.ctx(), 12.0), t.text).size().x.max(28.0);
|
||||
let tr = Rect::from_min_size(pos2(x, r.top() + 2.0), vec2(w, 22.0));
|
||||
let active = app.ui.tab == tab;
|
||||
let resp = craft_ui::tabs::Tab::new(egui::Id::new(("mc_tab", panel_key(tab, ""))), &label, active)
|
||||
.focus_stroke(Stroke::new(1.0, t.accent))
|
||||
.show_at(ui, tr, |ui, resp| {
|
||||
let p = ui.painter();
|
||||
if active {
|
||||
p.line_segment(
|
||||
[pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)],
|
||||
Stroke::new(2.0, t.accent),
|
||||
);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(tr, 4.0, t.hover);
|
||||
}
|
||||
p.text(tr.center(), Align2::CENTER_CENTER, &label, label_font(ui.ctx(), 12.0), if active { t.text } else { t.text_dim });
|
||||
});
|
||||
if resp.clicked() {
|
||||
app.ui.tab = tab;
|
||||
app.ui.menu = None;
|
||||
}
|
||||
});
|
||||
x += w + 4.0;
|
||||
}
|
||||
// Panels.
|
||||
let tab = app.ui.tab;
|
||||
let top = r.top() + 26.0;
|
||||
let panels: Vec<&'static [Capability]> = catalogue::panels(tab).collect();
|
||||
let shown: Vec<Vec<&'static Capability>> = panels.iter().map(|p| buttons(p)).collect();
|
||||
// A panel is as wide as its buttons, and wide enough for its label's longest word (plus
|
||||
// the caret beside it).
|
||||
let label_word = |entries: &[Capability]| {
|
||||
let label = entries.first().map_or(String::new(), |c| c.panel.to_uppercase());
|
||||
label.split_whitespace().map(|w| painter.layout_no_wrap(w.to_string(), label_font(ui.ctx(), 10.5), t.text).size().x).fold(0.0, f32::max)
|
||||
};
|
||||
let widths: Vec<f32> = shown.iter().zip(&panels).map(|(b, p)| panel_width(b.len()).max(label_word(p) + 26.0)).collect();
|
||||
let fits = fit(&widths, r.right() - 8.0 - (r.left() + PANELS_X));
|
||||
let mut px = r.left() + PANELS_X;
|
||||
let mut overflow: Vec<&'static [Capability]> = Vec::new();
|
||||
for ((entries, shown), (fit, full)) in panels.iter().zip(&shown).zip(fits.iter().zip(&widths)) {
|
||||
let Some(panel) = entries.first().map(|c| c.panel) else { continue };
|
||||
if *fit == Fit::More {
|
||||
for c in entries.iter() {
|
||||
app.toolbar_rects.push((c.id, Rect::NOTHING, Fit::More));
|
||||
}
|
||||
overflow.push(entries);
|
||||
continue;
|
||||
}
|
||||
let key = panel_key(tab, panel);
|
||||
let collapsed = *fit == Fit::Collapsed;
|
||||
let width = if collapsed { COLLAPSED_WIDTH.min(*full) } else { *full };
|
||||
if collapsed {
|
||||
// One button: the panel's first button's icon over a caret; it opens the drop-down.
|
||||
let br = Rect::from_center_size(pos2(px + width / 2.0, top + 21.0), Vec2::splat(BUTTON));
|
||||
let resp = ui.interact(br, ui.id().with(("mc_collapsed", &key)), Sense::click());
|
||||
if resp.hovered() {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
if let Some(c) = shown.first() {
|
||||
icons::paint(
|
||||
app,
|
||||
ui,
|
||||
c.id,
|
||||
Rect::from_center_size(br.center() - vec2(0.0, 3.0), Vec2::splat(26.0)),
|
||||
Look::of(c.command().is_some()),
|
||||
);
|
||||
}
|
||||
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
|
||||
for c in entries.iter() {
|
||||
app.toolbar_rects.push((c.id, br, Fit::Collapsed));
|
||||
}
|
||||
if resp.on_hover_text(format!("{panel} ({})", entries.len())).clicked() {
|
||||
app.toggle_menu(&key);
|
||||
}
|
||||
} else {
|
||||
let inset = ((width - 8.0 - (shown.len() as f32 * STRIDE - 2.0)) / 2.0).max(0.0).floor();
|
||||
for (i, c) in shown.iter().enumerate() {
|
||||
let br = Rect::from_min_size(pos2(px + 4.0 + inset + i as f32 * STRIDE, top + 2.0), Vec2::splat(BUTTON));
|
||||
if let Some(id) = button(app, ui, &t, c, br) {
|
||||
start = Some(id);
|
||||
}
|
||||
}
|
||||
for c in entries.iter().filter(|c| !shown.iter().any(|s| s.id == c.id)) {
|
||||
app.toolbar_rects.push((c.id, Rect::NOTHING, Fit::Menu));
|
||||
}
|
||||
}
|
||||
// The panel's label with its caret opens the drop-down.
|
||||
// A label too wide wraps between words onto a second line; a single word wider than
|
||||
// the panel stays whole.
|
||||
let label = panel.to_uppercase();
|
||||
let widest_word = label
|
||||
.split_whitespace()
|
||||
.map(|w| painter.layout_no_wrap(w.to_string(), label_font(ui.ctx(), 10.5), t.text).size().x)
|
||||
.fold(0.0, f32::max);
|
||||
let mut job = egui::text::LayoutJob::simple(label, label_font(ui.ctx(), 10.5), t.text, (width - 14.0).max(widest_word + 1.0));
|
||||
job.wrap.overflow_character = None;
|
||||
job.wrap.max_rows = 2;
|
||||
job.halign = egui::Align::Center;
|
||||
let g = painter.layout_job(job);
|
||||
let gw = g.size().x;
|
||||
// One line centred under the buttons; two lines start higher to stay inside the ribbon.
|
||||
let lh = (g.size().y + 2.0).max(18.0);
|
||||
let lr = if lh > 20.0 {
|
||||
Rect::from_min_size(pos2(px, top + 40.0), vec2(width, lh))
|
||||
} else {
|
||||
Rect::from_center_size(pos2(px + width / 2.0, top + 51.0), vec2(width, lh))
|
||||
};
|
||||
let lresp = ui.interact(lr, ui.id().with(("mc_panel", &key)), Sense::click());
|
||||
let open = app.ui.menu.as_deref() == Some(key.as_str());
|
||||
if lresp.hovered() || open {
|
||||
painter.rect_filled(lr, 3.0, t.hover);
|
||||
}
|
||||
let gx = lr.center().x - 4.5;
|
||||
painter.galley(pos2(gx, lr.center().y - g.size().y / 2.0), g, t.text);
|
||||
caret(&painter, pos2(gx + gw / 2.0 + 6.0, lr.center().y), t.text);
|
||||
app.panel_rects.push((key.clone(), lr));
|
||||
if lresp.on_hover_text(format!("All {panel} commands")).clicked() {
|
||||
app.toggle_menu(&key);
|
||||
}
|
||||
if let Some(rect) = crate::frame::menu(app, ui, &key, lr, |app, ui| dropdown(app, ui, entries)) {
|
||||
app.place("toolbar_dropdown", rect);
|
||||
}
|
||||
px += width;
|
||||
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
|
||||
px += PANEL_GAP;
|
||||
}
|
||||
if !overflow.is_empty() {
|
||||
let mr = Rect::from_min_size(pos2(px.min(r.right() - MORE_WIDTH), top + 4.0), vec2(MORE_WIDTH - 2.0, 52.0));
|
||||
let resp = ui.interact(mr, ui.id().with("mc_more"), Sense::click());
|
||||
if resp.hovered() || app.ui.menu.as_deref() == Some("more") {
|
||||
painter.rect_filled(mr, 4.0, t.hover);
|
||||
}
|
||||
painter.text(mr.center(), Align2::CENTER_CENTER, "»", FontId::proportional(18.0), t.text);
|
||||
if resp.on_hover_text("More toolbar panels").clicked() {
|
||||
app.toggle_menu("more");
|
||||
}
|
||||
crate::frame::menu(app, ui, "more", mr, |app, ui| {
|
||||
for entries in &overflow {
|
||||
let panel = entries.first().map_or("", |c| c.panel);
|
||||
ui.menu_button(panel, |ui| dropdown(app, ui, entries));
|
||||
}
|
||||
});
|
||||
}
|
||||
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
|
||||
});
|
||||
if let Some(id) = action {
|
||||
if let Some(id) = start {
|
||||
app.ui.menu = None;
|
||||
// Errors already show in the status bar.
|
||||
let _ = app.start(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(app: &mut ModelApp, ui: &egui::Ui, t: &Tokens, c: &'static Capability, rect: Rect) {
|
||||
let px = (ICON * ui.ctx().pixels_per_point()).round() as u32;
|
||||
/// The workspace switcher: a box spanning the ribbon's height with the workspace's icon and name;
|
||||
/// it opens the list of workspaces.
|
||||
fn workspace_switcher(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, r: Rect) {
|
||||
let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0));
|
||||
let resp = ui.interact(ws, ui.id().with("mc_workspace"), Sense::click());
|
||||
let open = app.ui.menu.as_deref() == Some("workspace");
|
||||
ui.painter().rect(ws, 4.0, if resp.hovered() || open { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let w = app.ui.workspace;
|
||||
icons::paint(app, ui, w.icon(), Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), Vec2::splat(26.0)), Look::Normal);
|
||||
let p = ui.painter();
|
||||
match app.icons.get(ui.ctx(), c.id, app.ui.dark, c.command().is_some(), px) {
|
||||
Some(tex) => p.image(tex.id(), rect, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE),
|
||||
None => p.rect_stroke(rect.shrink(3.0), 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside),
|
||||
};
|
||||
let label = w.label().to_uppercase();
|
||||
let font = label_font(ui.ctx(), 12.0);
|
||||
let label_w = p.layout_no_wrap(label.clone(), font.clone(), t.text).size().x;
|
||||
p.text(pos2(ws.center().x - 5.0, ws.top() + 52.0), Align2::CENTER_CENTER, label, font, t.text);
|
||||
caret(p, pos2(ws.center().x - 5.0 + label_w / 2.0 + 7.0, ws.top() + 52.0), t.text);
|
||||
p.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
|
||||
app.place("workspace_switcher", ws);
|
||||
if resp.on_hover_text("Change workspace").clicked() {
|
||||
app.toggle_menu("workspace");
|
||||
}
|
||||
crate::frame::menu(app, ui, "workspace", ws, |app, ui| {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
for w in Workspace::ALL {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(360.0, 40.0), Sense::click());
|
||||
if resp.hovered() || w == app.ui.workspace {
|
||||
ui.painter().rect_filled(rect, 3.0, if w == app.ui.workspace { t.accent_soft } else { t.hover });
|
||||
}
|
||||
icons::paint(app, ui, w.icon(), Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), Vec2::splat(24.0)), Look::Normal);
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.left() + 40.0, rect.top() + 12.0), Align2::LEFT_CENTER, w.label().to_uppercase(), label_font(ui.ctx(), 12.5), t.text);
|
||||
p.text(pos2(rect.left() + 40.0, rect.top() + 28.0), Align2::LEFT_CENTER, w.about(), FontId::proportional(11.0), t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, w.label()));
|
||||
if resp.clicked() {
|
||||
app.ui.select_workspace(w);
|
||||
app.ui.menu = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One entry's button, with its label when `label` is given; returns the id to start when it
|
||||
/// (or its drop-down) was clicked.
|
||||
fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capability, label: Option<Arc<Galley>>) -> Option<&'static str> {
|
||||
/// One entry's button; returns the id to start when it was clicked.
|
||||
fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capability, br: Rect) -> Option<&'static str> {
|
||||
let enabled = c.command().is_some();
|
||||
let size = match &label {
|
||||
Some(g) => vec2(g.size().x.max(ICON + 12.0) + 8.0, BUTTON_H),
|
||||
None => vec2(COMPACT, COMPACT),
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
|
||||
app.toolbar_rects.push((c.id, rect, if label.is_some() { Fit::Full } else { Fit::Compact }));
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
let icon_rect = Rect::from_center_size(egui::pos2(rect.center().x, rect.top() + 6.0 + ICON / 2.0), vec2(ICON, ICON));
|
||||
icon(app, ui, t, c, icon_rect);
|
||||
let p = ui.painter();
|
||||
if let Some(g) = label {
|
||||
p.galley(egui::pos2(rect.center().x, icon_rect.bottom() + 4.0), g, t.text);
|
||||
let resp = ui.interact(br, ui.id().with(("mc_button", c.id)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
app.toolbar_rects.push((c.id, br, Fit::Full));
|
||||
let running = app.dialog.as_ref().is_some_and(|d| c.command() == Some(d.command));
|
||||
if running {
|
||||
ui.painter().rect_filled(br, 4.0, t.border.gamma_multiply(0.7));
|
||||
} else if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(app, ui, c.id, br.shrink(6.0), Look::of(enabled));
|
||||
if let Some(m) = c.milestone() {
|
||||
let text = p.layout_no_wrap(m.to_string(), FontId::proportional(9.0), t.text);
|
||||
let badge = Rect::from_min_size(egui::pos2(rect.right() - text.size().x - 4.0, rect.top()), text.size() + vec2(4.0, 0.0));
|
||||
p.rect_filled(badge, 3.0, t.panel_header);
|
||||
p.rect_stroke(badge, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
p.galley(badge.min + vec2(2.0, 0.0), text, t.text);
|
||||
badge(ui.painter(), t, br.right_top() + vec2(4.0, -2.0), m);
|
||||
}
|
||||
let resp = resp.on_hover_ui(|ui| {
|
||||
ui.label(c.tooltip());
|
||||
});
|
||||
let mut action = resp.clicked().then_some(c.id);
|
||||
if enabled && !c.more.is_empty() {
|
||||
let (arrow, arrow_resp) = ui.allocate_exact_size(vec2(ARROW_W, size.y), Sense::click());
|
||||
if arrow_resp.hovered() {
|
||||
ui.painter().rect_filled(arrow, 4.0, t.hover);
|
||||
}
|
||||
ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "▾", FontId::proportional(11.0), t.text_dim);
|
||||
let arrow_resp = arrow_resp.on_hover_text(format!("More {} commands", c.label));
|
||||
egui::Popup::menu(&arrow_resp).show(|ui| {
|
||||
for id in c.more {
|
||||
if ui.button(crate::command_label(id)).clicked() {
|
||||
action = Some(*id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
action
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, c.label));
|
||||
let resp = resp.on_hover_text(c.tooltip());
|
||||
resp.clicked().then_some(c.id)
|
||||
}
|
||||
|
||||
/// A collapsed panel: its first entry's icon and a drop-down listing every entry (and their
|
||||
/// related commands), coming-soon ones greyed with their milestone.
|
||||
fn collapsed(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, panel: &str, entries: &'static [Capability]) -> Option<&'static str> {
|
||||
let first = entries.first()?;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(COLLAPSED_W, COMPACT), Sense::click());
|
||||
/// The rows of a panel's drop-down: every entry with its related commands under it, coming-soon
|
||||
/// entries greyed with their milestone. A click starts the row's command.
|
||||
pub fn dropdown(app: &mut ModelApp, ui: &mut egui::Ui, entries: &'static [Capability]) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let font = FontId::proportional(13.0);
|
||||
let width = entries
|
||||
.iter()
|
||||
.flat_map(|c| std::iter::once(c.label).chain(c.more.iter().map(|id| crate::command_label(id))))
|
||||
.map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)
|
||||
.fold(0.0_f32, f32::max)
|
||||
.max(140.0)
|
||||
+ 96.0;
|
||||
let mut start = None;
|
||||
for c in entries {
|
||||
app.toolbar_rects.push((c.id, rect, Fit::Collapsed));
|
||||
}
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
icon(app, ui, t, first, Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(ICON, ICON)));
|
||||
ui.painter().text(egui::pos2(rect.right() - ARROW_W / 2.0, rect.center().y), Align2::CENTER_CENTER, "▾", FontId::proportional(11.0), t.text_dim);
|
||||
let resp = resp.on_hover_text(panel);
|
||||
let mut action = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for c in entries {
|
||||
match c.milestone() {
|
||||
Some(m) => {
|
||||
ui.add_enabled(false, egui::Button::new(format!("{} — coming soon ({m})", c.label)));
|
||||
}
|
||||
None => {
|
||||
if ui.button(c.label).on_hover_text(c.tip).clicked() {
|
||||
action = Some(c.id);
|
||||
}
|
||||
for id in c.more {
|
||||
if ui.button(format!(" {}", crate::command_label(id))).clicked() {
|
||||
action = Some(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let enabled = c.command().is_some();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H), if enabled { Sense::click() } else { Sense::hover() });
|
||||
app.toolbar_rects.push((c.id, rect, Fit::Menu));
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(app, ui, c.id, Rect::from_center_size(pos2(rect.left() + 15.0, rect.center().y), Vec2::splat(18.0)), Look::of(enabled));
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, c.label, font.clone(), if enabled { t.text } else { t.text_dim });
|
||||
if let Some(m) = c.milestone() {
|
||||
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, format!("soon · {m}"), FontId::proportional(11.0), t.text_dim);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, c.label));
|
||||
if resp.on_hover_text(c.tooltip()).clicked() && enabled {
|
||||
start = Some(c.id);
|
||||
}
|
||||
for id in c.more {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, ROW_H - 2.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let label = crate::command_label(id);
|
||||
let p = ui.painter();
|
||||
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::proportional(12.5), t.text);
|
||||
p.line_segment([pos2(rect.left() + 15.0, rect.top()), pos2(rect.left() + 15.0, rect.center().y)], Stroke::new(1.0, t.border));
|
||||
p.line_segment([pos2(rect.left() + 15.0, rect.center().y), pos2(rect.left() + 25.0, rect.center().y)], Stroke::new(1.0, t.border));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
if resp.on_hover_text(*id).clicked() {
|
||||
start = Some(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
if let Some(id) = start {
|
||||
app.ui.menu = None;
|
||||
ui.close();
|
||||
let _ = app.start(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolbar as the control channel reports it: the tabs, the current tab, and every entry
|
||||
/// with its status and, on the current tab, its button (screen points, last frame).
|
||||
/// The toolbar as the control channel reports it: the workspaces and tabs, the current ones, the
|
||||
/// panels' labels (screen points, last frame), the open menu, and every entry with its status
|
||||
/// and where it is: a button (`full`), a collapsed panel, the » menu, or its panel's drop-down
|
||||
/// (`menu`, with the row's rect while the drop-down is open).
|
||||
pub fn describe(app: &ModelApp) -> serde_json::Value {
|
||||
let rect = |r: &Rect| {
|
||||
if r.is_finite() && r.is_positive() { serde_json::json!([r.left(), r.top(), r.width(), r.height()]) } else { serde_json::Value::Null }
|
||||
};
|
||||
let entries: Vec<serde_json::Value> = catalogue::CATALOGUE
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let shown = app.toolbar_rects.iter().find(|(id, ..)| *id == c.id);
|
||||
let rect = shown.map(|(_, r, _)| [r.left(), r.top(), r.width(), r.height()]);
|
||||
let shown = app.toolbar_rects.iter().rev().find(|(id, ..)| *id == c.id);
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"label": c.label,
|
||||
@@ -257,12 +410,21 @@ pub fn describe(app: &ModelApp) -> serde_json::Value {
|
||||
"command": c.command(),
|
||||
"more": c.more,
|
||||
"coming_soon": c.milestone(),
|
||||
"rect": rect,
|
||||
"rect": shown.map(|(_, r, _)| rect(r)),
|
||||
"fit": shown.map(|(.., f)| f),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({"tab": app.ui.tab, "tabs": Tab::ALL, "entries": entries})
|
||||
let panels: Vec<serde_json::Value> = app.panel_rects.iter().map(|(k, r)| serde_json::json!({"menu": k, "rect": rect(r)})).collect();
|
||||
serde_json::json!({
|
||||
"workspace": app.ui.workspace,
|
||||
"workspaces": Workspace::ALL,
|
||||
"tab": app.ui.tab,
|
||||
"tabs": app.ui.workspace.tabs(),
|
||||
"menu": app.ui.menu,
|
||||
"panels": panels,
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -270,14 +432,28 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn panels_go_compact_then_collapse_from_the_right() {
|
||||
let full = [200.0, 300.0, 100.0];
|
||||
let compact = [80.0, 120.0, 40.0];
|
||||
assert_eq!(fit(&full, &compact, 700.0), vec![Fit::Full; 3]);
|
||||
assert_eq!(fit(&full, &compact, 400.0), vec![Fit::Compact; 3]);
|
||||
// The last panel is narrower compact than collapsed, so the middle one goes first.
|
||||
assert_eq!(fit(&full, &compact, 230.0), vec![Fit::Compact, Fit::Collapsed, Fit::Compact]);
|
||||
assert_eq!(fit(&full, &compact, 10.0), vec![Fit::Collapsed, Fit::Collapsed, Fit::Compact], "nothing is dropped");
|
||||
assert!(fit(&[], &[], 0.0).is_empty());
|
||||
fn panels_collapse_from_the_right_then_go_to_the_more_menu() {
|
||||
let widths = [200.0, 300.0, 100.0];
|
||||
assert_eq!(fit(&widths, 700.0), vec![Fit::Full; 3]);
|
||||
assert_eq!(fit(&widths, 500.0), vec![Fit::Full, Fit::Collapsed, Fit::Collapsed]);
|
||||
assert_eq!(fit(&widths, 230.0), vec![Fit::Collapsed; 3]);
|
||||
let tight = fit(&widths, 120.0);
|
||||
assert_eq!(tight.first(), Some(&Fit::Collapsed));
|
||||
assert!(tight.contains(&Fit::More), "nothing is dropped: {tight:?}");
|
||||
assert!(fit(&[], 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_show_their_ready_entries_or_their_first_one() {
|
||||
for tab in Tab::ALL {
|
||||
for p in catalogue::panels(tab) {
|
||||
let b = buttons(p);
|
||||
assert!(!b.is_empty() && b.len() <= MAX_BUTTONS, "{tab:?}");
|
||||
if p.iter().any(|c| c.command().is_some()) {
|
||||
assert!(b.iter().all(|c| c.command().is_some()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(panel_key(Tab::Loads, "Loads"), "loads/Loads");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
//! The 3D viewport: the model drawn on the GPU (or on the CPU without one), camera navigation,
|
||||
//! the standard-view buttons and an axis indicator.
|
||||
//! the navigation bar (bottom centre) and the orientation gizmo (top right), as in SolidCraft.
|
||||
//!
|
||||
//! Navigation: left drag orbits, right or middle drag (or Shift + left drag) pans, the wheel or a
|
||||
//! pinch zooms at the cursor, a double click or F frames the model.
|
||||
//! Navigation: left drag orbits (or pans or zooms: the navigation bar's modes), right or middle
|
||||
//! drag (or Shift + left drag) pans, the wheel or a pinch zooms at the cursor, a double click or F
|
||||
//! frames the model. The gizmo's axis tips turn the view to look along them. The bar and the gizmo
|
||||
//! follow SolidCraft's `viewport.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
|
||||
use modelcraft_render::{StandardView, Vec3};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::gpu::{GpuScene, ViewportCallback};
|
||||
use crate::icons::{self, Look};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{ModelApp, NavMode, Style};
|
||||
|
||||
/// Wheel zoom per point scrolled.
|
||||
const ZOOM_PER_POINT: f64 = 0.0015;
|
||||
@@ -17,6 +20,7 @@ const ZOOM_PER_POINT: f64 = 0.0015;
|
||||
pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
app.viewport.rect = Some(rect);
|
||||
app.place("viewport", rect);
|
||||
let response = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
navigate(app, ui, &response, rect);
|
||||
if response.clicked()
|
||||
@@ -32,18 +36,34 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
paint_cpu(app, ui, &painter, rect);
|
||||
}
|
||||
if app.display().bounds.is_empty() {
|
||||
painter.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"The model is empty.\nOpen a design or add nodes and elements, or start with --sample.",
|
||||
egui::FontId::proportional(15.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if app.display().bounds.is_empty() && app.busy().is_none() {
|
||||
start_screen(app, ui, rect, &t);
|
||||
}
|
||||
axis_indicator(app, &painter, rect, &t);
|
||||
view_buttons(app, ui, rect, &t);
|
||||
crate::results_panel::show(app, ui, rect, &t);
|
||||
axis_view(app, ui, rect, &t);
|
||||
nav_bar(app, ui, rect, &t);
|
||||
}
|
||||
|
||||
/// The empty viewport: open a file or a sample (the samples are ready to solve).
|
||||
fn start_screen(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
let mut open = None;
|
||||
let mut pick = false;
|
||||
let area = Rect::from_center_size(rect.center(), vec2(440.0_f32.min(rect.width() - 24.0), 300.0_f32.min(rect.height() - 24.0)));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
egui::Frame::NONE.fill(t.overlay).corner_radius(6.0).inner_margin(egui::Margin::same(14)).show(&mut child, |ui| {
|
||||
ui.label(egui::RichText::new("Start").size(16.0).color(t.overlay_text));
|
||||
ui.label(egui::RichText::new("Open a model, a SolidCraft design or a tagged mesh — or drop one here.").color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
pick = ui.button("Open…").clicked();
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Samples").color(t.overlay_text));
|
||||
crate::files::sample_rows(ui, t, &mut open);
|
||||
});
|
||||
if pick {
|
||||
app.open_dialog(None);
|
||||
} else if let Some(n) = open {
|
||||
app.open_sample(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a results plot probes the node nearest to the face under the pointer.
|
||||
@@ -61,12 +81,12 @@ fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect:
|
||||
let d = response.drag_delta();
|
||||
let (dx, dy) = (f64::from(d.x), f64::from(d.y));
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if response.dragged_by(egui::PointerButton::Primary) && !shift {
|
||||
let left = response.dragged_by(egui::PointerButton::Primary);
|
||||
if left && !shift && app.nav == NavMode::Orbit {
|
||||
app.cam.orbit(dx, dy);
|
||||
} else if response.dragged_by(egui::PointerButton::Secondary)
|
||||
|| response.dragged_by(egui::PointerButton::Middle)
|
||||
|| (response.dragged_by(egui::PointerButton::Primary) && shift)
|
||||
{
|
||||
} else if left && !shift && app.nav == NavMode::Zoom {
|
||||
app.cam.zoom_at((dy * ZOOM_PER_POINT * 4.0).exp(), None);
|
||||
} else if response.dragged_by(egui::PointerButton::Secondary) || response.dragged_by(egui::PointerButton::Middle) || left {
|
||||
app.cam.pan(dx, dy, h);
|
||||
}
|
||||
if response.double_clicked() || (response.hovered() && ui.input(|i| i.key_pressed(egui::Key::F) && i.modifiers.is_none())) {
|
||||
@@ -156,42 +176,156 @@ fn paint_cpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R
|
||||
}
|
||||
}
|
||||
|
||||
/// X, Y and Z drawn from the lower-left corner in the current view direction.
|
||||
fn axis_indicator(app: &ModelApp, painter: &egui::Painter, rect: Rect, t: &Tokens) {
|
||||
/// The orientation gizmo's arm length and tip radius (points).
|
||||
const AXIS_LENGTH: f32 = 38.0;
|
||||
const AXIS_RADIUS: f32 = 11.0;
|
||||
|
||||
/// The standard view looking along `d` from outside (the one whose eye side is nearest `d`).
|
||||
fn view_along(d: Vec3) -> StandardView {
|
||||
let mut best = (StandardView::Front, f64::NEG_INFINITY);
|
||||
for v in StandardView::ALL.into_iter().filter(|v| *v != StandardView::Iso) {
|
||||
let mut c = modelcraft_render::Camera::default();
|
||||
c.set_view(v);
|
||||
let score = c.back().dot(d);
|
||||
if score > best.1 {
|
||||
best = (v, score);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// The orientation gizmo (top right): the six signed axes in the current view, back to front;
|
||||
/// clicking a tip looks along it, the house returns to the home view.
|
||||
fn axis_view(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
let area = Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), Vec2::splat(116.0));
|
||||
if !rect.contains_rect(area) {
|
||||
return;
|
||||
}
|
||||
app.place("axis_view", area);
|
||||
let c = area.center();
|
||||
let (r, u, b) = app.cam.basis();
|
||||
let origin = rect.left_bottom() + vec2(44.0, -44.0);
|
||||
let len = 30.0;
|
||||
let mut axes = [(Vec3::X, t.axis_x, "X"), (Vec3::Y, t.axis_y, "Y"), (Vec3::Z, t.axis_z, "Z")];
|
||||
// Farthest first, so the nearest axis is drawn on top.
|
||||
axes.sort_by(|a, b2| a.0.dot(b).total_cmp(&b2.0.dot(b)));
|
||||
for (axis, color, name) in axes {
|
||||
let tip = origin + Vec2::new(axis.dot(r) as f32, -axis.dot(u) as f32) * len;
|
||||
painter.line_segment([origin, tip], Stroke::new(2.0, color));
|
||||
painter.text(tip + (tip - origin).normalized() * 9.0, egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(12.0), color);
|
||||
let mut tips: Vec<(&str, Vec3, Pos2, f64, Color32)> = [
|
||||
("X", Vec3::X, t.axis_x),
|
||||
("-X", Vec3::new(-1.0, 0.0, 0.0), t.axis_x),
|
||||
("Y", Vec3::Y, t.axis_y),
|
||||
("-Y", Vec3::new(0.0, -1.0, 0.0), t.axis_y),
|
||||
("Z", Vec3::Z, t.axis_z),
|
||||
("-Z", Vec3::new(0.0, 0.0, -1.0), t.axis_z),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, d, col)| (name, d, c + vec2(d.dot(r) as f32 * AXIS_LENGTH, -(d.dot(u) as f32) * AXIS_LENGTH), d.dot(b), col))
|
||||
.collect();
|
||||
tips.sort_by(|a, b2| a.3.total_cmp(&b2.3));
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| area.contains(*p));
|
||||
let hovered = hover.and_then(|p| tips.iter().rposition(|(_, _, at, _, _)| at.distance(p) <= AXIS_RADIUS));
|
||||
let painter = ui.painter_at(rect);
|
||||
if hover.is_some() {
|
||||
painter.circle_filled(c, 55.0, t.overlay);
|
||||
}
|
||||
for (i, (name, _, at, depth, col)) in tips.iter().enumerate() {
|
||||
let positive = !name.starts_with('-');
|
||||
let ink = if *depth < -0.01 { col.gamma_multiply(0.55) } else { *col };
|
||||
painter.line_segment([c, *at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
|
||||
let hot = hovered == Some(i);
|
||||
painter.circle_filled(*at, AXIS_RADIUS, if hot { t.accent_soft } else { t.overlay });
|
||||
painter.circle_stroke(*at, AXIS_RADIUS, Stroke::new(if hot { 2.0 } else { 1.0 }, if hot { t.accent } else { ink }));
|
||||
painter.text(*at, egui::Align2::CENTER_CENTER, *name, egui::FontId::proportional(11.0), ink);
|
||||
}
|
||||
let resp = ui.interact(area, ui.id().with("mc_axis_view"), Sense::click());
|
||||
if let Some(i) = hovered
|
||||
&& let Some((name, d, ..)) = tips.get(i)
|
||||
{
|
||||
let view = view_along(*d);
|
||||
let resp = resp.clone().on_hover_text(format!("Look from {name} ({})", view.label()));
|
||||
if resp.clicked() {
|
||||
app.set_view(view);
|
||||
}
|
||||
}
|
||||
let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), Vec2::splat(18.0));
|
||||
let hr = ui.interact(home, ui.id().with("mc_axis_home"), Sense::click());
|
||||
if hr.hovered() {
|
||||
painter.rect_filled(home.expand(2.0), 3.0, t.hover);
|
||||
}
|
||||
icons::paint(app, ui, "ui.home", home, Look::Normal);
|
||||
if hr.on_hover_text("Home view").clicked() {
|
||||
app.home_view();
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard views and Fit, as text buttons along the viewport's top edge.
|
||||
fn view_buttons(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
let area = Rect::from_min_size(rect.left_top() + vec2(8.0, 8.0), vec2(rect.width() - 16.0, 26.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
egui::Frame::NONE.fill(t.overlay).corner_radius(4.0).inner_margin(egui::Margin::symmetric(4, 2)).show(&mut child, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for v in StandardView::ALL {
|
||||
if view_button(ui, v.label(), t).clicked() {
|
||||
app.set_view(v);
|
||||
/// The navigation bar's buttons: (key, icon, tooltip).
|
||||
const NAV: [(&str, &str, &str); 7] = [
|
||||
("orbit", "ui.orbit", "Orbit: left drag turns the view"),
|
||||
("pan", "ui.pan", "Pan: left drag moves the view (or right or middle drag)"),
|
||||
("zoom", "ui.zoom", "Zoom: left drag up and down (or the wheel)"),
|
||||
("fit", "ui.fit", "Fit the model (F, or a double click)"),
|
||||
("home", "ui.home", "Home view"),
|
||||
("perspective", "ui.perspective", "Perspective or orthographic"),
|
||||
("style", "ui.style", "Visual style and what is shown"),
|
||||
];
|
||||
|
||||
/// The navigation bar, bottom centre: navigation modes, fit, home, projection, and the display
|
||||
/// menu (style, geometry, mesh, nodes).
|
||||
fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
let w = NAV.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));
|
||||
if !rect.contains_rect(bar) {
|
||||
return;
|
||||
}
|
||||
app.place("nav_bar", bar);
|
||||
ui.painter().rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let mut style_button = None;
|
||||
for (i, (key, icon, tip)) in NAV.iter().enumerate() {
|
||||
let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), Vec2::splat(26.0));
|
||||
let resp = ui.interact(br, ui.id().with(("mc_nav", *key)), Sense::click());
|
||||
let active = match *key {
|
||||
"orbit" => app.nav == NavMode::Orbit,
|
||||
"pan" => app.nav == NavMode::Pan,
|
||||
"zoom" => app.nav == NavMode::Zoom,
|
||||
"perspective" => app.ui.perspective,
|
||||
"style" => app.ui.menu.as_deref() == Some("view"),
|
||||
_ => false,
|
||||
};
|
||||
if active || resp.hovered() {
|
||||
ui.painter().rect_filled(br, 4.0, if active { t.accent_soft } else { t.hover });
|
||||
}
|
||||
icons::paint(app, ui, icon, br.shrink(4.0), Look::Normal);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, *tip));
|
||||
if resp.on_hover_text(*tip).clicked() {
|
||||
match *key {
|
||||
"orbit" => app.nav = NavMode::Orbit,
|
||||
"pan" => app.nav = NavMode::Pan,
|
||||
"zoom" => app.nav = NavMode::Zoom,
|
||||
"fit" => app.fit_view(),
|
||||
"home" => {
|
||||
app.home_view();
|
||||
}
|
||||
"perspective" => app.ui.perspective = !app.ui.perspective,
|
||||
_ => app.toggle_menu("view"),
|
||||
}
|
||||
}
|
||||
if *key == "style" {
|
||||
style_button = Some(br);
|
||||
}
|
||||
}
|
||||
if let Some(br) = style_button {
|
||||
crate::frame::settings_menu(app, ui, "view", br, |app, ui| {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
crate::frame::menu_heading(ui, &t, "STYLE");
|
||||
for s in Style::ALL {
|
||||
if ui.radio(app.ui.style == s, s.label()).clicked() {
|
||||
app.ui.style = s;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if view_button(ui, "Fit", t).on_hover_text("Frame the whole model (F, or double-click)").clicked() {
|
||||
app.fit_view();
|
||||
}
|
||||
crate::frame::menu_heading(ui, &t, "SHOW");
|
||||
ui.checkbox(&mut app.ui.show_geometry, "Geometry");
|
||||
ui.checkbox(&mut app.ui.show_mesh, "Mesh");
|
||||
ui.checkbox(&mut app.ui.show_nodes, "Nodes");
|
||||
ui.separator();
|
||||
ui.checkbox(&mut app.ui.show_tree, "Browser");
|
||||
ui.checkbox(&mut app.ui.show_timeline, "Timeline");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn view_button(ui: &mut egui::Ui, label: &str, t: &Tokens) -> egui::Response {
|
||||
ui.add(egui::Button::new(egui::RichText::new(label).color(t.overlay_text).size(12.0)).frame(false).min_size(vec2(0.0, 20.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen point (points) of a world point in the viewport drawn last frame.
|
||||
|
||||
@@ -13,4 +13,5 @@ unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# SolidCraft's kernel and its patched truck crates, pinned by rev in Cargo.toml.
|
||||
allow-git = ["https://github.com/bherbruck/solidcraft"]
|
||||
# storytold/craft-libs: the Craft family's shared UI components (craft-ui), pinned by rev.
|
||||
allow-git = ["https://github.com/bherbruck/solidcraft", "https://github.com/storytold/craft-libs"]
|
||||
@@ -31,7 +31,7 @@ crates in strictly lower layers.
|
||||
| L4 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
|
||||
| L5 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery. Bytes in, bytes out; the file system only in `fs` and `recovery`. Result files later. | started |
|
||||
| L6 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts. | started |
|
||||
| L7 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI (egui + wgpu viewport, model tree, commands, status bar, results display with legend, panel and probe, offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server. | ui started |
|
||||
| L7 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server. | ui started |
|
||||
| testkit | `modelcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |
|
||||
| apps | `modelcraft`, `modelcraft-cli`, `modelcraft-web` | Desktop, command line, browser. | desktop, cli started |
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ handled on the UI thread between frames; pointer and keyboard input is injected
|
||||
events, one pointer event per frame. The protocol has the same shape as SolidCraft's.
|
||||
|
||||
```text
|
||||
modelcraft --sample --control 7070
|
||||
modelcraft --sample --control 7070 # or --sample cantilever | impact
|
||||
{"id":1,"method":"engine.execute","params":{"command":"model.info","params":{}}}
|
||||
→ {"id":1,"ok":true,"result":{"elements":{"hex8":64,"tet4":162},"nodes":199}}
|
||||
{"id":2,"method":"ui.render","params":{"path":"/tmp/model.png","width":900,"height":600}}
|
||||
@@ -28,17 +28,21 @@ checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses
|
||||
| `engine.script` | `commands`: `[{command, params}, …]` | stops at the first error; `journal`: commands that changed the model so far |
|
||||
| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation) |
|
||||
|
||||
Commands never open forms when run this way. `modelcraft-cli commands` prints the same registry.
|
||||
Commands never open forms or file pickers when run this way. `modelcraft-cli commands` prints the
|
||||
same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impact`) and
|
||||
`solve.run {}` solves every step; the Results workspace comes up when a result appears.
|
||||
|
||||
## UI
|
||||
|
||||
| Method | Params | Notes |
|
||||
|---|---|---|
|
||||
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes) and the probed node (`probe`), version |
|
||||
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `perspective`, `dark`, `show_tree`, `show_commands`, `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`) | an unknown setting or value is an error; returns the settings |
|
||||
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
|
||||
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
|
||||
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera |
|
||||
| `ui.toolbar` | — | the toolbar (`ui.set {tab}` switches tabs): `tab`, `tabs`, and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its drop-down's commands), `coming_soon` (the milestone, or null), `rect` (its button in screen points last frame, on the shown tab; a collapsed panel's entries share its drop-down button) and `fit` (`full`, `compact` icon-only, or `collapsed` into the panel's drop-down, by window width) |
|
||||
| `ui.start` | `command`: a command or toolbar entry id | like clicking it on the toolbar or in the Commands panel: a command without parameters runs, one with parameters opens its form; an entry that is coming soon is an error naming its milestone |
|
||||
| `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) |
|
||||
| `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form |
|
||||
| `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel |
|
||||
| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) |
|
||||
| `ui.dialog` | `params?` (JSON object, fills the form), `run?`, `cancel?` | drives the open command form; `run` returns the command's result and closes the form on success |
|
||||
| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; a click on a results plot probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) |
|
||||
| `ui.move` | `x`, `y` | |
|
||||
@@ -48,7 +52,15 @@ Commands never open forms when run this way. `modelcraft-cli commands` prints th
|
||||
| `ui.text` | `text` | typed text (into the focused field) |
|
||||
| `ui.worldToScreen` | `points`: `[[x, y, z]…]` (at most 1000) | screen points in the viewport drawn last frame (null when not on screen) |
|
||||
| `ui.resize` | `width`, `height` | window inner size in points |
|
||||
| `ui.fullscreen` | `on?` (default true) | into or out of full screen; on macOS the title bar's left group moves into the traffic lights' room while there are none |
|
||||
| `ui.screenshot` | `path?` | PNG of the window, taken a few frames later; waits for a presented frame; when none comes within 8 seconds (a hidden window, a locked screen) it answers with `ui.capture`'s offscreen picture (`"offscreen": true`). Without `path`: `modelcraft-shot.png` in the temporary folder |
|
||||
| `ui.capture` | `path?`, `width?`, `height?` (points; default the window's), `scale?` (pixels per point, 0.5–4; default the window's) | PNG of the whole window (menu, toolbar, panels, forms and the viewport) drawn offscreen: the UI pass runs in a separate egui context and is rasterised on the CPU, so it works with the window hidden, minimised or the screen locked. At most 8192 pixels a side and 16 megapixels. Without `path`: `modelcraft-shot.png` in the temporary folder |
|
||||
| `ui.capture` | `path?`, `width?`, `height?` (points; default the window's), `scale?` (pixels per point, 0.5–4; default the window's) | PNG of the whole window (title bar, ribbon, open menus and search, panels, forms and the viewport) drawn offscreen, without fade-in animations: the UI pass runs in a separate egui context and is rasterised on the CPU, so it works with the window hidden, minimised or the screen locked. At most 8192 pixels a side and 16 megapixels. Without `path`: `modelcraft-shot.png` in the temporary folder |
|
||||
| `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all | CPU render of the model with the current camera and view settings (no window or GPU needed). A size out of range, or a scene over 20 million primitives, is an error |
|
||||
| `app.quit` | — | closes the window |
|
||||
|
||||
## Files
|
||||
|
||||
User clicks (title bar File menu, toolbar entries, Cmd/Ctrl+O, S, Shift+S), drops on the window and
|
||||
files named on the command line go through native file pickers and open in the background
|
||||
(`ui.inspect`'s `busy`); commands sent here take their paths as parameters and run at once
|
||||
(`file.open`, `file.save_as`, `geometry.open`, `geometry.import.mesh`, `deck.write`).
|
||||
@@ -5,7 +5,7 @@ Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by
|
||||
Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are
|
||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md).
|
||||
|
||||
50 ready, 51 coming soon.
|
||||
52 ready, 51 coming soon.
|
||||
|
||||
## Geometry
|
||||
|
||||
@@ -115,6 +115,7 @@ shown greyed with the roadmap milestone that brings them (ROADMAP.md).
|
||||
|
||||
| Panel | Capability | Status | Command |
|
||||
|---|---|---|---|
|
||||
| Run | Solve — Solve every step in order and show the results | ready | `solve.run` |
|
||||
| Check | Check Model — Name every missing or bad definition before writing or solving | ready | `model.check` |
|
||||
| Decks | Abaqus Input — Write an Abaqus input file (.inp) | ready | `deck.write {"format":"abaqus"}` |
|
||||
| Decks | Nastran Deck — Write Nastran bulk data (SOL 101, 103) | ready | `deck.write {"format":"nastran"}` |
|
||||
@@ -152,6 +153,7 @@ shown greyed with the roadmap milestone that brings them (ROADMAP.md).
|
||||
|---|---|---|---|
|
||||
| Model | Model Info — Node and element counts and the model summary | ready | `model.info` |
|
||||
| File | Open Model — Open a .modelcraft model | ready | `file.open` |
|
||||
| File | Samples — Open a sample model, ready to solve | ready | `file.sample` |
|
||||
| File | Save — Save the model to its .modelcraft file | ready | `file.save` |
|
||||
| File | Save As — Save the model to a new .modelcraft file | ready | `file.save_as` |
|
||||
| File | Recover — Open a model autosaved by an app that crashed or closed with unsaved changes | ready | `file.recover`, `file.recovery.list`, `file.recovery.discard` |
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
//! `dash` (`[dash, gap]`). The task turns every recipe into VectorCraft
|
||||
//! commands, runs them headlessly with `vectorcraft-cli run` (`VECTORCRAFT_CLI`, else
|
||||
//! `vectorcraft-cli` on the PATH) and writes `assets/icons/<id>.vectorcraft` (the editable
|
||||
//! source) and `assets/icons/<id>.svg` (what the app draws). The roles' colours are the dark
|
||||
//! theme's; the app recolours each role with its theme tokens (`crates/ui-egui/src/icons.rs`).
|
||||
//! source) and `assets/icons/<id>.svg` (what the app draws). The roles' fixed colours are
|
||||
//! keys: the app recolours each role with its theme tokens (`crates/ui-egui/src/icons.rs`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Palette roles and the colour each is drawn in (the dark theme's tokens). Keep in step with
|
||||
/// `PALETTE` in `crates/ui-egui/src/icons.rs`.
|
||||
/// Palette roles and the fixed colour each is drawn in: keys the app swaps for its theme tokens.
|
||||
/// Keep in step with `PALETTE` in `crates/ui-egui/src/icons.rs`.
|
||||
pub const PALETTE: &[(&str, &str)] = &[("line", "#dde2ea"), ("accent", "#4f9ce8"), ("soft", "#2d4e70"), ("warm", "#f0b44a")];
|
||||
|
||||
/// The artboard side (pixels) and the default stroke weight.
|
||||
|
||||