Files
OneCAD-Simulation/crates/ui-egui/src/lib.rs
T
a292212b67 Toolbars for every capability, VectorCraft icons, coming soon for planned ones (#20)
* feat(engine): capability catalogue — every toolbar entry, ready or coming soon, and docs/features.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat: 98 toolbar icons drawn with VectorCraft from recipes (cargo xtask icons), attributed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat(ui): toolbar of tabs and panels from the catalogue, VectorCraft icons recoloured per theme, coming-soon entries greyed with their milestone; ui.toolbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* refactor: ready entries are their command; one tooltip; tabs and panels as catalogue runs; icon cache per theme and size; ui.toolbar read-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: roadmap notes the toolbar and icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat(ui): deck entries run deck.write with their format, ready when the writer is in FORMATS; file and edit entries; compact and collapsed panels instead of scrolling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: features page after the Nastran writer (#24) made its deck entry ready

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: features page after the LS-DYNA writer (#25) made its deck entry ready

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* feat: solve.static, solve.frequency and solve.probe on the toolbar (Solve and Results tabs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:49:31 -04:00

557 lines
22 KiB
Rust

//! 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.
//!
//! 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 control;
pub mod gpu;
pub mod icons;
pub mod panels;
pub mod scene;
pub mod theme;
pub mod toolbar;
pub mod viewport;
use std::sync::mpsc::{Receiver, Sender};
use modelcraft_engine::Session;
use modelcraft_engine::catalogue::{self, Tab};
use modelcraft_render::{Camera, DisplayMesh, Scene, StandardView};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub use control::ControlRequest;
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.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiState {
pub style: Style,
pub show_nodes: bool,
pub perspective: bool,
pub dark: bool,
pub show_tree: bool,
pub show_commands: bool,
/// The toolbar tab shown.
pub tab: Tab,
}
impl Default for UiState {
fn default() -> Self {
UiState {
style: Style::ShadedEdges,
show_nodes: false,
perspective: false,
dark: true,
show_tree: true,
show_commands: true,
tab: Tab::default(),
}
}
}
/// The parameter form a command opens when started from the Commands panel.
#[derive(Clone, Debug, PartialEq)]
pub struct ParamsDialog {
pub command: &'static str,
/// The parameters as JSON text.
pub text: String,
pub error: Option<String>,
}
/// A status bar message.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Status {
pub text: String,
pub error: bool,
}
/// The viewport's renderer state.
#[derive(Default)]
pub struct Viewport {
/// Where the viewport was drawn last frame (points).
pub rect: Option<egui::Rect>,
/// The GPU target when drawing with wgpu; `None` draws on the CPU.
pub gpu: Option<gpu::GpuTarget>,
pub slot: gpu::SceneSlot,
/// The scene key last handed to the GPU.
pub uploaded: Option<u64>,
/// CPU fallback: the texture and what it shows (scene key, camera, size).
cpu: Option<(egui::TextureHandle, u64, Camera, [usize; 2])>,
/// CPU fallback: the scene key the renderer last refused.
cpu_failed: Option<u64>,
/// GPU errors reported by the device (see [`gpu::GpuErrors`]), shown in the status bar.
pub gpu_errors: gpu::GpuErrors,
}
/// A screenshot asked for over the control channel.
struct Shot {
token: u64,
path: Option<String>,
reply: Sender<Value>,
/// Frames waited so far: the request's own changes get drawn before the picture is taken.
frames: u32,
requested: bool,
/// App time after which the request is answered with an error.
deadline: f64,
}
pub struct ModelApp {
pub session: Session,
pub ui: UiState,
pub cam: Camera,
pub status: Option<Status>,
pub dialog: Option<ParamsDialog>,
pub viewport: Viewport,
pub icons: icons::IconCache,
/// The toolbar's buttons drawn last frame, by catalogue id (for the control channel).
pub toolbar_rects: Vec<(&'static str, egui::Rect, toolbar::Fit)>,
/// The GPU adapter, for the status bar and diagnostics.
pub gpu_info: Option<String>,
/// Bumped whenever the model may have changed.
revision: u64,
display: (Option<u64>, DisplayMesh),
/// The built scene and its key.
scene: (Option<u64>, Scene),
/// The theme last applied to the context.
styled: Option<bool>,
/// Input injected by the control channel, fed to egui one pointer event per frame.
pub synthetic: Vec<egui::Event>,
synthetic_mods: egui::Modifiers,
control_rx: Option<Receiver<ControlRequest>>,
shots: Vec<Shot>,
shot_token: u64,
/// The app time (seconds) of the current frame.
pub now: f64,
pub quit_requested: bool,
}
impl ModelApp {
pub fn new(session: Session) -> ModelApp {
let mut app = ModelApp {
session,
ui: UiState::default(),
cam: Camera::default(),
status: None,
dialog: None,
viewport: Viewport::default(),
icons: icons::IconCache::default(),
toolbar_rects: Vec::new(),
gpu_info: None,
revision: 0,
display: (None, DisplayMesh::default()),
scene: (None, Scene::default()),
styled: None,
synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(),
control_rx: None,
shots: Vec::new(),
shot_token: 0,
now: 0.0,
quit_requested: false,
};
app.fit_view();
app
}
/// Serve control-channel requests (see [`control`]).
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
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.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32, ctx: &egui::Context) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples, self.viewport.gpu_errors.clone(), ctx.clone()));
let info = rs.adapter.get_info();
self.gpu_info = Some(format!("{} ({:?})", info.name.trim(), info.backend));
}
/// 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> {
let was_empty = self.session.model.nodes().is_empty();
let revision = self.session.revision();
let label = command_label(id);
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
let changed = self.session.revision() != revision;
if changed {
self.model_changed();
}
match &r {
Ok(v) if v.is_null() || changed => self.set_status(format!("{label} done"), false),
Ok(v) => self.set_status(format!("{label}: {v}"), false),
Err(e) => self.set_status(format!("{label}: {e}"), true),
}
if was_empty && !self.session.model.nodes().is_empty() {
self.fit_view();
}
r
}
/// Run a script (a JSON array of `{command, params}`), stopping at the first error.
pub fn run_script(&mut self, script: &Value) -> Result<(), String> {
let was_empty = self.session.model.nodes().is_empty();
let r = self.session.run_script(script).map_err(|e| e.to_string());
self.model_changed();
match &r {
Ok(()) => self.set_status("Script done", false),
Err(e) => self.set_status(format!("Script: {e}"), true),
}
if was_empty && !self.session.model.nodes().is_empty() {
self.fit_view();
}
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.
pub fn start(&mut self, id: &str) -> Result<(), String> {
let (command, preset) = match catalogue::find(id) {
Some(c) => match c.command() {
Some(command) => (command, c.preset()),
None => {
let e = format!("{} is coming soon ({})", c.label, c.milestone().unwrap_or("later"));
self.set_status(e.clone(), false);
return Err(e);
}
},
None => (id, json!({})),
};
let spec = modelcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
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 });
Ok(())
}
}
/// 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()) };
let r = serde_json::from_str::<Value>(&d.text).map_err(|e| format!("the parameters are not JSON: {e}")).and_then(|p| self.run(d.command, p));
match &r {
Ok(_) => self.dialog = None,
Err(e) => {
if let Some(open) = self.dialog.as_mut() {
open.error = Some(e.clone());
}
}
}
r
}
pub fn set_status(&mut self, text: impl Into<String>, error: bool) {
let text = text.into();
if error {
log::warn!("{text}");
}
self.status = Some(Status { text, error });
}
/// Move an error the graphics device reported into the status bar.
fn show_gpu_errors(&mut self) {
let error = self.viewport.gpu_errors.lock().ok().and_then(|mut e| e.take());
if let Some(e) = error {
self.status = Some(Status { text: format!("Graphics: {e}"), error: true });
}
}
/// The model may have changed: display data and the scene are rebuilt when next drawn.
pub fn model_changed(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
/// The model's display data, rebuilt when the model changed.
pub fn display(&mut self) -> &DisplayMesh {
if self.display.0 != Some(self.revision) {
self.display = (Some(self.revision), DisplayMesh::from_model(&self.session.model));
}
&self.display.1
}
/// The key of the current scene: the model revision and every setting the scene depends on.
pub fn scene_key(&self) -> u64 {
let style = Style::ALL.iter().position(|s| *s == self.ui.style).unwrap_or(0) as u64;
(self.revision << 4) | (style << 2) | (u64::from(self.ui.show_nodes) << 1) | u64::from(self.ui.dark)
}
/// The scene the viewport draws, rebuilt when its key changes; its radius fits the camera.
pub fn scene(&mut self) -> (u64, &Scene) {
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 s = scene::build(self.display(), style, nodes, &t);
self.scene = (Some(key), s);
}
self.scene.1.radius = scene::radius(&self.display.1, &self.cam);
(key, &self.scene.1)
}
/// A CPU render of the viewport's scene with the current camera, as PNG bytes.
pub fn render_png(&mut self, w: usize, h: usize) -> Result<Vec<u8>, modelcraft_render::RenderError> {
let cam = self.cam;
let (_, scene) = self.scene();
modelcraft_render::render_png(scene, &cam, w, h)
}
/// Frame the whole model.
pub fn fit_view(&mut self) {
let b = self.display().bounds;
self.cam.fit(&b);
}
pub fn set_view(&mut self, v: StandardView) {
self.cam.set_view(v);
}
/// Per-frame logic before layout: theme, camera projection, control requests, screenshots.
pub fn logic(&mut self, ctx: &egui::Context) {
self.now = ctx.input(|i| i.time);
if self.styled != Some(self.ui.dark) {
theme::apply(ctx, self.ui.dark);
self.styled = Some(self.ui.dark);
}
self.drain_control(ctx);
self.shortcuts(ctx);
self.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 };
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
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).
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)))
});
if redo {
let _ = self.run("edit.redo", json!({}));
} else if undo {
let _ = self.run("edit.undo", json!({}));
}
}
/// Inject synthetic events: one pointer event per frame (egui reads a press and release in
/// the same frame as a click, but a drag needs frames between its moves), keys up to their
/// release.
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
let Some(first) = self.synthetic.first() else { return };
let n = match first {
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first {
raw.events.push(egui::Event::PointerMoved(*p));
}
// egui keeps the modifier keys as input state, changed by their own event.
match first {
egui::Event::PointerButton { modifiers, pressed, .. } => {
// A drag keeps its press's modifiers until the release.
self.synthetic_mods = if *pressed { *modifiers } else { egui::Modifiers::default() };
raw.events.push(egui::Event::ModifiersChanged(*modifiers));
}
egui::Event::Key { modifiers, .. } => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
egui::Event::PointerMoved(_) => raw.events.push(egui::Event::ModifiersChanged(self.synthetic_mods)),
_ => {}
}
let n = n.min(self.synthetic.len());
raw.events.extend(self.synthetic.drain(..n));
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
self.show_gpu_errors();
panels::menu_bar(self, ui);
toolbar::toolbar(self, ui);
panels::status_bar(self, ui);
if self.ui.show_tree {
panels::model_tree(self, ui);
}
if self.ui.show_commands {
panels::commands(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui));
panels::params_dialog(self, ui.ctx());
}
fn drain_control(&mut self, ctx: &egui::Context) {
/// Requests run on this thread, so a flood would starve drawing; the rest wait for the
/// next frame.
const PER_FRAME: usize = 64;
let Some(rx) = self.control_rx.take() else { return };
for _ in 0..PER_FRAME {
let Ok(req) = rx.try_recv() else { break };
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = req.reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.shot_token = self.shot_token.wrapping_add(1);
let deadline = self.now + SHOT_TIMEOUT;
self.shots.push(Shot { token: self.shot_token, path, reply: req.reply.clone(), frames: 0, requested: false, deadline });
}
}
ctx.request_repaint();
}
self.control_rx = Some(rx);
}
/// Ask for pending screenshots once their request's changes are drawn, and save the ones
/// that arrived.
fn screenshots(&mut self, ctx: &egui::Context) {
if self.shots.is_empty() {
return;
}
let arrived: Vec<(u64, std::sync::Arc<egui::ColorImage>)> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).map(|t| (*t, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in arrived {
if let Some(i) = self.shots.iter().position(|s| s.token == token) {
let shot = self.shots.remove(i);
let _ = shot.reply.send(control::save_screenshot(&image, shot.path.as_deref()));
}
}
let now = self.now;
self.shots.retain_mut(|s| {
if now > s.deadline {
let _ = s.reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"}));
return false;
}
s.frames += 1;
if !s.requested && s.frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token)));
s.requested = true;
}
true
});
if !self.shots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
}
/// Seconds a screenshot waits for a presented frame.
const SHOT_TIMEOUT: f64 = 8.0;
/// Vertical field of view of the perspective camera (radians).
const PERSPECTIVE_FOV: f64 = 0.6;
/// A command's name as people see it (its label).
pub fn command_label(id: &str) -> &str {
modelcraft_engine::find_command(id).map_or(id, |c| c.label)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_run_through_the_engine_and_report_in_the_status_bar() {
let mut app = ModelApp::new(Session::default());
let key = app.scene_key();
app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Add Node done"));
assert_ne!(app.scene_key(), key, "a model change rebuilds the scene");
let err = app.run("mesh.node.add", json!({"label": 0, "xyz": [0, 0, 0]})).unwrap_err();
assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.starts_with("Add Node:")), "{err}");
app.run("model.info", json!({})).unwrap();
assert!(app.status.as_ref().is_some_and(|s| s.text.contains("\"nodes\":1")));
}
#[test]
fn starting_a_command_opens_its_form_and_running_it_closes_the_form() {
let mut app = ModelApp::new(Session::default());
app.start("model.info").unwrap();
assert!(app.dialog.is_none());
app.start("mesh.node.add").unwrap();
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("mesh.node.add"));
app.dialog.as_mut().unwrap().text = "not json".into();
assert!(app.run_dialog().is_err());
assert!(app.dialog.as_ref().is_some_and(|d| d.error.is_some()));
app.dialog.as_mut().unwrap().text = r#"{"label": 7, "xyz": [1, 2, 3]}"#.into();
app.run_dialog().unwrap();
assert!(app.dialog.is_none());
assert!(app.session.model.node(7).is_some());
assert!(app.start("no.such").is_err());
}
#[test]
fn toolbar_entries_start_their_command_and_planned_ones_do_nothing() {
let mut app = ModelApp::new(Session::default());
app.start("model.material.create").unwrap();
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create"));
app.dialog = None;
let journal = app.session.journal.len();
let err = app.start("mesh.tet.generate").unwrap_err();
assert_eq!(err, "Tet Mesh is coming soon (M2)");
assert!(app.dialog.is_none());
assert_eq!(app.session.journal.len(), journal);
assert_eq!(app.status.as_ref().map(|s| s.error), Some(false));
// A deck entry opens deck.write's form with its format filled in.
app.start("deck.write.abaqus").unwrap();
let d = app.dialog.as_ref().unwrap();
assert_eq!((d.command, d.text.as_str()), ("deck.write", r#"{"format":"abaqus"}"#));
}
#[test]
fn the_sample_fills_the_view_and_renders() {
let mut app = ModelApp::new(Session::default());
app.run_script(&modelcraft_engine::sample::script()).unwrap();
let d = app.display().clone();
assert!(!d.tris.is_empty() && !d.edges.is_empty());
// Fitted: the model's centre is the camera target.
assert!(app.cam.target.dist(d.bounds.center()) < 1e-9);
let png = app.render_png(64, 48).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[test]
fn graphics_device_errors_reach_the_status_bar() {
let mut app = ModelApp::new(Session::default());
if let Ok(mut e) = app.viewport.gpu_errors.lock() {
*e = Some("Out of Memory".into());
}
app.show_gpu_errors();
assert_eq!(app.status, Some(Status { text: "Graphics: Out of Memory".into(), error: true }));
assert!(app.viewport.gpu_errors.lock().unwrap().is_none(), "each error is shown once");
}
}