Desktop app: egui/wgpu viewport, model tree, commands and control channel (#9)

* render: display data from the FE model, orbit camera and CPU rasterizer

modelcraft-render (L1, no GPU): DisplayMesh (the unshared faces of solid
elements, shells, element edges, line elements, nodes), the orbit camera
and a CPU rasterizer with PNG output for headless snapshots, adapted from
SolidCraft's render crate. Workspace: egui/eframe/egui-wgpu 0.36 and
SolidCraft's patched egui-wgpu from the pinned rev.

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

* engine: sample model script (a hex8 block and a tet4 cube)

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

* Desktop app: egui/wgpu viewport, model tree, commands, status bar, control channel

modelcraft-ui-egui (L5): ModelApp over the engine Session; GPU viewport
(lit faces, element edges, nodes) with a CPU fallback; orbit/pan/zoom,
standard views and fit; model tree; commands panel generated from the
registry with a JSON parameter form; status bar; control request
handling (engine.*, ui.*, ui.screenshot, ui.render). apps/modelcraft:
eframe window, --sample, --control PORT loopback JSON-lines server.
Adapted from SolidCraft's ui-egui and app.

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

* THIRD-PARTY-LICENSES: the desktop app's dependencies

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

* docs: control protocol, desktop app in architecture, roadmap and attribution

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

* render: simplify the display mesh (sorted face keys over maps, no unused fields)

Interior faces are found by sorting fixed-size face keys instead of a BTreeMap of Vecs, edges
are deduplicated by sort, and each element's corners are looked up once. Drops the unused
`element` fields on DisplayTri/DisplayLine, the unused Mat4::IDENTITY and corner_count, and
hoists the line colour out of the raster loop.

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

* viewport: split GPU geometry into batches within the buffer limit; refuse oversized CPU renders

A mesh with a few million boundary triangles made one vertex buffer larger than the device's
max_buffer_size (256 MiB under the limits eframe requests), and wgpu's default error handler
panicked. Geometry is now uploaded in batches of whole primitives, each at most 128 MiB and the
device limit, and all are drawn. A device error handler logs errors and shows them in the status
bar instead of panicking.

The CPU renderer refuses sizes over 8192 per side or 16 MP, and scenes over 20 million
primitives, with an error, and allocates its buffers fallibly. ui.render reports a size out of
range instead of clamping it; the CPU viewport fits a large window into the limit.

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>
This commit is contained in:
authored and GitHub committed 2026-10-13 15:29:49 -04:00
1 parent a817a62440
commit 3c1485d83a
27 files changed
+13965 -895

No files matched your search

+404
View File
@@ -0,0 +1,404 @@
//! Programmatic control of the running app (agents, tests). The desktop app reads JSON lines
//! from its socket and hands each request here; replies are `{"ok": true, "result": …}` or
//! `{"ok": false, "error": "…"}`. docs/control-protocol.md lists the methods.
//!
//! Requests are untrusted input: numbers are checked and clamped, and nothing here panics.
//! Adapted from SolidCraft's `crates/ui-egui/src/control.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
use std::sync::mpsc::Sender;
use modelcraft_render::StandardView;
use serde_json::{Value, json};
use crate::{ModelApp, UiState};
/// `ui.render`'s image size when none is given.
const RENDER_DEFAULT: [usize; 2] = [1280, 800];
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<Value>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
}
}
pub enum Outcome {
Done(Value),
/// A window screenshot, answered once a frame is presented.
Screenshot {
path: Option<String>,
},
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
fn wrap(r: Result<Value, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
fn button(name: Option<&str>) -> egui::PointerButton {
match name {
Some("right") => egui::PointerButton::Secondary,
Some("middle") => egui::PointerButton::Middle,
_ => egui::PointerButton::Primary,
}
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"esc" | "escape" => Some(egui::Key::Escape),
"delete" => Some(egui::Key::Delete),
"space" => Some(egui::Key::Space),
"tab" => Some(egui::Key::Tab),
_ => None,
})
}
/// An image side for `ui.render`: absent gives `default`; anything but a whole number of pixels
/// in 1..=MAX_DIM is an error (the renderer also caps the total pixel count).
fn render_dim(p: &Value, key: &str, default: usize) -> Result<usize, String> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|x| x.fract() == 0.0 && (1.0..=modelcraft_render::MAX_DIM as f64).contains(x))
.map(|x| x as usize)
.ok_or_else(|| format!("`{key}` must be a whole number of pixels in 1..={}", modelcraft_render::MAX_DIM)),
}
}
pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let mods = || egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
match req.method.as_str() {
"engine.execute" => {
let Some(id) = s("command") else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
wrap(app.run(id, params))
}
"engine.script" => {
let Some(steps) = p.get("commands") else { return err("missing `commands` (a list of {command, params})") };
wrap(app.run_script(steps).map(|()| json!({"journal": app.session.journal.len()})))
}
"engine.commands" => {
ok(Value::Array(modelcraft_engine::commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect()))
}
"ui.inspect" => ok(json!({
"ui": app.ui,
"camera": app.cam,
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
"gpu": app.gpu_info,
"dialog": app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error})),
"status": app.status,
"model": {"nodes": app.session.model.nodes().len(), "elements": app.session.model.elements().len()},
"version": crate::VERSION,
})),
"ui.set" => {
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
for (k, v) in src {
if !o.contains_key(k) {
return err(format!("unknown setting `{k}`"));
}
o.insert(k.clone(), v.clone());
}
}
match serde_json::from_value::<UiState>(cur) {
Ok(u) => {
app.ui = u;
ok(json!(app.ui))
}
Err(e) => err(e),
}
}
"ui.view" => {
match s("view").unwrap_or("iso") {
"fit" => app.fit_view(),
"home" => {
app.set_view(StandardView::Iso);
app.fit_view();
}
v => match StandardView::parse(v) {
Some(sv) => app.set_view(sv),
None => return err(format!("unknown view `{v}` (front, back, left, right, top, bottom, iso, home, fit)")),
},
}
ok(json!(app.cam))
}
"ui.start" => {
let Some(id) = s("command") else { return err("missing `command`") };
match app.start(id) {
Ok(()) => ok(json!({"dialog": app.dialog.as_ref().map(|d| d.command)})),
Err(e) => err(e),
}
}
"ui.dialog" => {
if app.dialog.is_none() {
return err("no command form is open (ui.start opens one)");
}
if b("cancel") {
app.dialog = None;
return ok(Value::Null);
}
if let (Some(d), Some(params)) = (app.dialog.as_mut(), p.get("params")) {
d.text = serde_json::to_string_pretty(params).unwrap_or_default();
}
if b("run") {
return wrap(app.run_dialog());
}
ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error}))))
}
"ui.move" | "ui.click" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
let pos = egui::pos2(x as f32, y as f32);
app.synthetic.push(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let (button, modifiers) = (button(s("button")), mods());
// `double: true` sends two clicks in one go, which egui reads as a double click.
for _ in 0..if b("double") { 2 } else { 1 } {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers });
}
}
ok(Value::Null)
}
"ui.drag" => {
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (f("x0"), f("y0"), f("x1"), f("y1")) else { return err("missing x0/y0/x1/y1") };
let (button, modifiers) = (button(s("button")), mods());
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize;
for k in 1..=steps {
app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32)));
}
app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers });
ok(Value::Null)
}
"ui.scroll" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
// Wheel lines; negative zooms out, positive zooms in.
let lines = f("delta").unwrap_or(1.0).clamp(-100.0, 100.0);
app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, lines as f32),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
ok(Value::Null)
}
"ui.key" => {
let Some(key) = s("key").and_then(key_from) else { return err("unknown key") };
let modifiers = egui::Modifiers { shift: b("shift"), command: b("cmd"), ctrl: b("cmd") || b("ctrl"), ..Default::default() };
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
ok(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ok(Value::Null)
}
"ui.worldToScreen" => {
let pts: Vec<Value> = p
.get("points")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(1000)
.map(|v| {
let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN);
crate::viewport::to_screen(app, modelcraft_render::Vec3::new(c(0), c(1), c(2))).map_or(Value::Null, |q| json!([q.x, q.y]))
})
.collect();
ok(json!(pts))
}
"ui.resize" => {
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
ok(Value::Null)
}
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
"ui.render" => {
let Some(path) = s("path") else { return err("missing `path`") };
let (w, h) = match (render_dim(p, "width", RENDER_DEFAULT[0]), render_dim(p, "height", RENDER_DEFAULT[1])) {
(Ok(w), Ok(h)) => (w, h),
(Err(e), _) | (_, Err(e)) => return err(e),
};
let png = match app.render_png(w, h) {
Ok(png) => png,
Err(e) => return err(e),
};
match std::fs::write(path, &png) {
Ok(()) => ok(json!({"path": path, "width": w, "height": h, "bytes": png.len()})),
Err(e) => err(format!("{path}: {e}")),
}
}
"app.quit" => {
app.quit_requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
other => err(format!("unknown method `{other}`")),
}
}
/// Save a screenshot as PNG: at `path`, or `modelcraft-shot.png` in the temporary folder.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let Some(png) = modelcraft_render::png(w, h, &rgba) else { return json!({"ok": false, "error": "could not encode the screenshot"}) };
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("modelcraft-shot.png").to_string_lossy().to_string());
match std::fs::write(&path, &png) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": format!("{path}: {e}")}),
}
}
#[cfg(test)]
mod tests {
use modelcraft_engine::Session;
use super::*;
fn call(app: &mut ModelApp, method: &str, params: Value) -> Value {
let (req, _rx) = ControlRequest::new(method, params);
match handle(app, &egui::Context::default(), &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!({"screenshot": true}),
}
}
#[test]
fn engine_view_and_settings_methods() {
let mut app = ModelApp::new(Session::default());
let r = call(&mut app, "engine.script", json!({"commands": modelcraft_engine::sample::script()}));
assert_eq!(r["ok"], true, "{r}");
let r = call(&mut app, "engine.execute", json!({"command": "model.info"}));
assert_eq!(r["result"]["elements"]["hex8"], 64);
assert_eq!(call(&mut app, "engine.execute", json!({"command": "no.such"}))["ok"], false);
assert!(call(&mut app, "engine.commands", json!({}))["result"].as_array().is_some_and(|a| a.len() == modelcraft_engine::commands().len()));
let r = call(&mut app, "ui.set", json!({"style": "wireframe", "show_nodes": true}));
assert_eq!(r["result"]["style"], "wireframe");
assert_eq!(app.ui.style, crate::Style::Wireframe);
assert_eq!(call(&mut app, "ui.set", json!({"style": "cartoon"}))["ok"], false);
assert_eq!(call(&mut app, "ui.set", json!({"nope": 1}))["ok"], false);
assert_eq!(call(&mut app, "ui.view", json!({"view": "top"}))["ok"], true);
assert!((app.cam.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3);
assert_eq!(call(&mut app, "ui.view", json!({"view": "sideways"}))["ok"], false);
assert_eq!(call(&mut app, "ui.inspect", json!({}))["result"]["model"]["elements"], 64 + 162);
assert_eq!(call(&mut app, "ui.screenshot", json!({})), json!({"screenshot": true}));
assert_eq!(call(&mut app, "no.method", json!({}))["ok"], false);
}
#[test]
fn the_command_form_is_driven_after_ui_start() {
let mut app = ModelApp::new(Session::default());
assert_eq!(call(&mut app, "ui.dialog", json!({"run": true}))["ok"], false);
assert_eq!(call(&mut app, "ui.start", json!({"command": "mesh.node.add"}))["result"]["dialog"], "mesh.node.add");
let r = call(&mut app, "ui.dialog", json!({"params": {"label": 3, "xyz": [1, 2, 3]}, "run": true}));
assert_eq!(r["ok"], true, "{r}");
assert!(app.dialog.is_none() && app.session.model.node(3).is_some());
}
#[test]
fn ui_render_writes_a_png_of_the_model() {
let mut app = ModelApp::new(Session::default());
app.run_script(&modelcraft_engine::sample::script()).unwrap();
let path = std::env::temp_dir().join(format!("modelcraft-render-{}.png", std::process::id()));
let r = call(&mut app, "ui.render", json!({"path": path.to_string_lossy(), "width": 120, "height": 80}));
assert_eq!(r["ok"], true, "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(&[0x89, b'P', b'N', b'G']));
let _ = std::fs::remove_file(&path);
assert_eq!(call(&mut app, "ui.render", json!({}))["ok"], false);
}
#[test]
fn ui_render_refuses_hostile_sizes() {
let mut app = ModelApp::new(Session::default());
app.run_script(&modelcraft_engine::sample::script()).unwrap();
let path = std::env::temp_dir().join(format!("modelcraft-render-hostile-{}.png", std::process::id()));
let path = path.to_string_lossy();
for (w, h) in [
(json!(100_000), json!(100_000)),
(json!(8192), json!(8192)),
(json!(1e12), json!(10)),
(json!(-5), json!(10)),
(json!(0), json!(10)),
(json!(10.5), json!(10)),
(json!("big"), json!(10)),
(json!(f64::MAX), json!(f64::MIN)),
] {
let r = call(&mut app, "ui.render", json!({"path": path, "width": w, "height": h}));
assert_eq!(r["ok"], false, "{w} x {h}: {r}");
}
assert!(std::fs::metadata(&*path).is_err(), "nothing is written for a refused size");
}
#[test]
fn hostile_requests_never_panic() {
let mut app = ModelApp::new(Session::default());
let hostile = [
json!(null),
json!([]),
json!({"x": 1e300, "y": f64::MAX, "x0": -1e308, "y0": 0, "x1": 1e308, "y1": 1, "steps": 1e12, "delta": 1e300}),
json!({"width": 1e12, "height": -5, "path": ""}),
json!({"command": 5, "params": "x", "commands": {"a": 1}}),
json!({"key": "NoSuchKey", "text": 7, "view": null, "points": [[1], "x", null]}),
];
for method in [
"engine.execute",
"engine.script",
"ui.set",
"ui.view",
"ui.start",
"ui.dialog",
"ui.move",
"ui.click",
"ui.drag",
"ui.scroll",
"ui.key",
"ui.text",
"ui.worldToScreen",
"ui.resize",
"ui.render",
] {
for p in &hostile {
let _ = call(&mut app, method, p.clone());
}
}
assert!(app.synthetic.len() < 1000);
}
#[test]
fn screenshots_are_saved_as_png() {
let image = egui::ColorImage::filled([4, 3], egui::Color32::RED);
let path = std::env::temp_dir().join(format!("modelcraft-shot-{}.png", std::process::id()));
let r = save_screenshot(&image, Some(&path.to_string_lossy()));
assert_eq!(r["ok"], true, "{r}");
assert!(std::fs::read(&path).unwrap().starts_with(&[0x89, b'P', b'N', b'G']));
let _ = std::fs::remove_file(&path);
}
}
+499
View File
@@ -0,0 +1,499 @@
//! GPU viewport: lit triangles and screen-space wide lines with a depth buffer, drawn in an
//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the
//! camera uniform changes. Nodes are drawn as zero-length lines, which the line shader turns
//! into squares.
//!
//! A large mesh does not fit one vertex buffer (wgpu's default `max_buffer_size`, which eframe
//! requests, is 256 MiB): geometry is split into batches of at most [`MAX_BATCH_BYTES`] and the
//! device's limit, each a whole number of primitives, and every batch is drawn. Device errors
//! (out of memory, validation) go to [`GpuErrors`] and the status bar instead of wgpu's default
//! handler, which panics.
//!
//! Adapted from SolidCraft's `crates/ui-egui/src/gpu.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
use std::ops::Range;
use std::sync::{Arc, Mutex};
use egui_wgpu::wgpu;
use modelcraft_render::{Rgb, Scene};
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
pub const TRI_SIZE: usize = 28;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in physical pixels (f32).
pub const LINE_SIZE: usize = 32;
const UNIFORM_SIZE: u64 = 112;
/// Largest vertex buffer the viewport creates, whatever the device allows.
pub const MAX_BATCH_BYTES: u64 = 128 << 20;
/// Longest device error message kept for the status bar.
const MAX_ERROR_LEN: usize = 500;
/// The last error the GPU device reported, waiting for the UI to show it.
pub type GpuErrors = Arc<Mutex<Option<String>>>;
/// Scene geometry in the GPU's vertex formats, waiting to be uploaded.
#[derive(Default, Clone, Debug, PartialEq)]
pub struct GpuScene {
pub tris: Vec<u8>,
pub lines: Vec<u8>,
}
impl GpuScene {
/// The scene's triangles, lines and points; widths and sizes scaled by `pixels_per_point`
/// (the scene gives them in points).
pub fn from_scene(scene: &Scene, pixels_per_point: f32) -> GpuScene {
let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), lines: Vec::new() };
for t in &scene.tris {
for p in t.p {
g.vertex(p.to_f32(), t.normal.to_f32(), t.color);
}
}
for l in &scene.lines {
g.line(l.a.to_f32(), l.b.to_f32(), l.color, l.width as f32 * pixels_per_point);
}
for p in &scene.points {
g.line(p.p.to_f32(), p.p.to_f32(), p.color, p.size as f32 * pixels_per_point);
}
g
}
fn vertex(&mut self, p: [f32; 3], n: [f32; 3], c: Rgb) {
for v in p.iter().chain(&n) {
self.tris.extend_from_slice(&v.to_le_bytes());
}
self.tris.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
fn line(&mut self, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) {
for v in a.iter().chain(&b) {
self.lines.extend_from_slice(&v.to_le_bytes());
}
self.lines.extend_from_slice(&[c.0, c.1, c.2, 255]);
self.lines.extend_from_slice(&width.to_le_bytes());
}
}
/// Where the UI hands a new scene to the renderer (taken on the next frame's `prepare`).
pub type SceneSlot = Arc<Mutex<Option<GpuScene>>>;
/// Per-frame parameters.
pub struct ViewportCallback {
/// Changes whenever the scene does; the slot holds the scene for a new key.
pub key: u64,
pub slot: SceneSlot,
/// Column-major view-projection.
pub view_proj: [[f32; 4]; 4],
/// Direction toward the eye (for lighting).
pub back: [f32; 3],
/// Eye position and 1 for a perspective camera (0: orthographic).
pub eye: [f32; 4],
/// Viewport size in physical pixels.
pub size_px: [f32; 2],
}
struct Batch {
buffer: wgpu::Buffer,
count: u32,
}
struct Resources {
tri: wgpu::RenderPipeline,
line: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
linear_out: bool,
key: Option<u64>,
tris: Vec<Batch>,
lines: Vec<Batch>,
}
const SHADER: &str = r#"
struct U {
vp: mat4x4<f32>,
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
screen: vec4<f32>, // viewport width, height (px), depth bias
eye: vec4<f32>, // eye position, w = 1 for perspective
};
@group(0) @binding(0) var<uniform> u: U;
fn to_linear(c: vec3<f32>) -> vec3<f32> {
return select(c, pow((c + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4)), c > vec3<f32>(0.04045));
}
fn out_color(c: vec4<f32>) -> vec4<f32> {
if (u.back.w > 0.5) {
return vec4<f32>(to_linear(c.rgb), c.a);
}
return c;
}
struct TOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) c: vec4<f32>,
};
@vertex
fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.c = c;
return o;
}
/// Two-sided lighting: a key light above the eye, a fill from the eye and a small highlight
/// (modelcraft_render::shade computes the same on the CPU).
@fragment
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
let v = normalize(u.back.xyz);
var n = normalize(i.n);
if (dot(n, v) < 0.0) { n = -n; }
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
let diff = max(dot(n, key), 0.0);
let fill = max(dot(n, v), 0.0);
let h = normalize(key + v);
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
let k = 0.42 + 0.38 * diff + 0.25 * fill;
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
}
/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye, so edges and
/// nodes on a face win the depth test against it.
fn biased_z(p: vec3<f32>, c: vec4<f32>, s: f32) -> f32 {
if (u.eye.w > 0.5) {
let q = u.vp * vec4<f32>(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0);
return q.z / max(q.w, 1e-6) * c.w;
}
return c.z - u.screen.z * s * c.w;
}
struct LOut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
};
@vertex
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: vec4<f32>, @location(3) w: f32) -> LOut {
var pa = a;
var pb = b;
var ca = u.vp * vec4<f32>(a, 1.0);
var cb = u.vp * vec4<f32>(b, 1.0);
// An end behind the near plane has no screen point: cut the segment at the plane.
if (ca.z < 0.0 && cb.z > 0.0) {
let k = ca.z / (ca.z - cb.z);
ca = mix(ca, cb, k);
pa = mix(a, b, k);
} else if (cb.z < 0.0 && ca.z > 0.0) {
let k = cb.z / (cb.z - ca.z);
cb = mix(cb, ca, k);
pb = mix(b, a, k);
}
let half = u.screen.xy * 0.5;
let sa = ca.xy / max(ca.w, 1e-6) * half;
let sb = cb.xy / max(cb.w, 1e-6) * half;
var dir = sb - sa;
let len = length(dir);
// A zero-length line (a node) becomes a square `w` pixels across.
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / len; }
let nrm = vec2<f32>(-dir.y, dir.x);
// Two triangles: corners (t, side).
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
var ss = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let t = ts[vi];
let s = ss[vi];
let base = select(ca, cb, t > 0.5);
let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
var o: LOut;
o.pos = vec4<f32>(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w);
o.c = c;
return o;
}
@fragment
fn fs_line(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
"#;
fn tri_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4];
wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A }
}
fn line_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32];
wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A }
}
/// The target configuration: colour format, depth format and MSAA sample count of the app.
#[derive(Clone, Copy, Debug)]
pub struct GpuTarget {
pub format: wgpu::TextureFormat,
pub depth: Option<wgpu::TextureFormat>,
pub samples: u32,
}
impl Resources {
fn new(device: &wgpu::Device, t: GpuTarget) -> Self {
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("mc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mc_viewport"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
}],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mc_viewport"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("mc_viewport_u"),
size: UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mc_viewport"),
layout: &bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }],
});
let depth = |compare: wgpu::CompareFunction, write: bool| -> Option<wgpu::DepthStencilState> {
t.depth.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: Some(write),
depth_compare: Some(compare),
stencil: Default::default(),
bias: Default::default(),
})
};
let pipeline = |label: &str,
vs: &str,
fs: &str,
buf: wgpu::VertexBufferLayout<'static>,
ds: Option<wgpu::DepthStencilState>,
blend: Option<wgpu::BlendState>| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&layout),
vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() },
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: ds,
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
Resources {
tri: pipeline("mc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
line: pipeline(
"mc_lines",
"vs_line",
"fs_line",
line_layout(),
depth(wgpu::CompareFunction::LessEqual, false),
Some(wgpu::BlendState::ALPHA_BLENDING),
),
uniform,
bind,
linear_out: t.format.is_srgb(),
key: None,
tris: Vec::new(),
lines: Vec::new(),
}
}
fn take(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u64, slot: &SceneSlot) {
if self.key == Some(key) {
return;
}
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
// The old buffers go first, so the new ones don't have to fit beside them.
self.tris.clear();
self.lines.clear();
let max = device.limits().max_buffer_size;
self.tris = upload(device, queue, "mc_tris", &sc.tris, TRI_SIZE, 3, max);
self.lines = upload(device, queue, "mc_lines", &sc.lines, LINE_SIZE, 1, max);
self.key = Some(key);
}
}
}
/// Byte ranges of `len` bytes of vertex data, `stride` bytes per vertex and `per_primitive`
/// vertices per primitive, for buffers of at most `max_buffer` bytes (and [`MAX_BATCH_BYTES`]).
/// Each range holds whole primitives; a trailing partial primitive is dropped. Empty when not
/// even one primitive fits.
pub fn batch_ranges(len: usize, stride: usize, per_primitive: usize, max_buffer: u64) -> Vec<Range<usize>> {
let Some(unit) = stride.checked_mul(per_primitive).filter(|&u| u > 0) else { return Vec::new() };
let limit = usize::try_from(max_buffer.min(MAX_BATCH_BYTES)).unwrap_or(usize::MAX);
let chunk = limit / unit * unit;
if chunk == 0 {
return Vec::new();
}
let end = len / unit * unit;
(0..end).step_by(chunk).map(|start| start..end.min(start.saturating_add(chunk))).collect()
}
/// Upload vertex data as batches (see [`batch_ranges`]); a failed allocation is reported to the
/// device's error handler, not a panic.
fn upload(device: &wgpu::Device, queue: &wgpu::Queue, label: &str, bytes: &[u8], stride: usize, per_primitive: usize, max_buffer: u64) -> Vec<Batch> {
batch_ranges(bytes.len(), stride, per_primitive, max_buffer)
.into_iter()
.filter_map(|r| {
let data = bytes.get(r)?;
let count = u32::try_from(data.len() / stride).ok()?;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: data.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&buffer, 0, data);
Some(Batch { buffer, count })
})
.collect()
}
/// Create the pipelines. Call once with the app's render state and its depth and MSAA settings.
/// Device errors are logged and kept in `errors` for the status bar (`ctx` is repainted so it
/// shows), replacing wgpu's default handler, which panics.
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32, errors: GpuErrors, ctx: egui::Context) -> GpuTarget {
rs.device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| {
let text: String = e.to_string().chars().take(MAX_ERROR_LEN).collect();
log::error!("graphics device error: {text}");
if let Ok(mut slot) = errors.lock() {
*slot = Some(text);
}
ctx.request_repaint();
}));
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples };
let res = Resources::new(&rs.device, t);
rs.renderer.write().callback_resources.insert(res);
t
}
impl egui_wgpu::CallbackTrait for ViewportCallback {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
_screen: &egui_wgpu::ScreenDescriptor,
_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
res.take(device, queue, self.key, &self.slot);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, res.linear_out));
Vec::new()
}
fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) {
let Some(res) = resources.get::<Resources>() else { return };
let vp = info.viewport_in_pixels();
if vp.width_px <= 0 || vp.height_px <= 0 {
return;
}
pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0);
pass.set_bind_group(0, &res.bind, &[]);
if !res.tris.is_empty() {
pass.set_pipeline(&res.tri);
for b in &res.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
}
if !res.lines.is_empty() {
pass.set_pipeline(&res.line);
for b in &res.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
}
}
}
/// Uniform bytes for a frame.
fn uniform_bytes(cb: &ViewportCallback, linear: bool) -> Vec<u8> {
let mut v: Vec<f32> = cb.view_proj.iter().flatten().copied().collect();
v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]);
v.extend([cb.size_px[0].max(1.0), cb.size_px[1].max(1.0), 2e-4, 0.0]);
v.extend(cb.eye);
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
#[cfg(test)]
mod tests {
use egui_wgpu::wgpu::naga;
use modelcraft_render::{SceneLine, ScenePoint, SceneTri, Vec3};
use super::*;
/// The viewport shader parses and validates (a bad shader would only fail at run time).
#[test]
fn shader_is_valid() {
let module = naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER)));
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
}
#[test]
fn batches_hold_whole_primitives_within_the_buffer_limit() {
let tri = TRI_SIZE * 3;
// Small scenes are one batch; nothing, or less than one primitive, is no batch.
assert_eq!(batch_ranges(10 * tri, TRI_SIZE, 3, 256 << 20), vec![0..10 * tri]);
assert!(batch_ranges(0, TRI_SIZE, 3, 256 << 20).is_empty());
assert!(batch_ranges(tri - 1, TRI_SIZE, 3, 256 << 20).is_empty());
// A trailing partial triangle is dropped.
assert_eq!(batch_ranges(2 * tri + 5, TRI_SIZE, 3, 256 << 20), vec![0..2 * tri]);
// A buffer limit smaller than one primitive, or a zero stride, gives nothing.
assert!(batch_ranges(10 * tri, TRI_SIZE, 3, (tri - 1) as u64).is_empty());
assert!(batch_ranges(10 * tri, 0, 3, 256 << 20).is_empty());
assert!(batch_ranges(usize::MAX, usize::MAX, 3, u64::MAX).is_empty());
// Ten triangles under a limit of four: 4 + 4 + 2.
let r = batch_ranges(10 * tri, TRI_SIZE, 3, (4 * tri + 7) as u64);
assert_eq!(r, vec![0..4 * tri, 4 * tri..8 * tri, 8 * tri..10 * tri]);
// A few million triangles under the 256 MiB default: every batch is within
// MAX_BATCH_BYTES, a whole number of triangles, and together they cover the data.
for (len, stride, per, max) in
[(5_000_000 * tri, TRI_SIZE, 3, 256u64 << 20), (9_000_000 * LINE_SIZE, LINE_SIZE, 1, u64::MAX), (777 * tri, TRI_SIZE, 3, 1000)]
{
let r = batch_ranges(len, stride, per, max);
assert!(r.len() > 1, "{len}");
let mut at = 0;
for b in &r {
assert_eq!(b.start, at);
assert!(b.len() as u64 <= max.min(MAX_BATCH_BYTES) && b.len() % (stride * per) == 0 && !b.is_empty());
at = b.end;
}
assert_eq!(at, len / (stride * per) * (stride * per));
}
}
#[test]
fn scene_bytes_match_the_vertex_layouts() {
let scene = Scene {
tris: vec![SceneTri { p: [Vec3::ZERO, Vec3::X, Vec3::Y], normal: Vec3::Z, color: Rgb(1, 2, 3) }],
lines: vec![SceneLine { a: Vec3::ZERO, b: Vec3::Z, color: Rgb(4, 5, 6), width: 1.5 }],
points: vec![ScenePoint { p: Vec3::X, color: Rgb(7, 8, 9), size: 4.0 }],
..Default::default()
};
let g = GpuScene::from_scene(&scene, 2.0);
assert_eq!(g.tris.len(), 3 * TRI_SIZE);
assert_eq!(g.lines.len(), 2 * LINE_SIZE);
assert_eq!(&g.tris[24..28], &[1, 2, 3, 255]);
// Widths are in physical pixels.
assert_eq!(f32::from_le_bytes([g.lines[28], g.lines[29], g.lines[30], g.lines[31]]), 3.0);
assert_eq!(f32::from_le_bytes([g.lines[60], g.lines[61], g.lines[62], g.lines[63]]), 8.0);
let cb =
ViewportCallback { key: 0, slot: SceneSlot::default(), view_proj: [[0.0; 4]; 4], back: [0.0; 3], eye: [0.0; 4], size_px: [1.0, 1.0] };
assert_eq!(uniform_bytes(&cb, true).len() as u64, UNIFORM_SIZE);
}
}
+484
View File
@@ -0,0 +1,484 @@
//! 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 panels;
pub mod scene;
pub mod theme;
pub mod viewport;
use std::sync::mpsc::{Receiver, Sender};
use modelcraft_engine::Session;
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,
}
impl Default for UiState {
fn default() -> Self {
UiState { style: Style::ShadedEdges, show_nodes: false, perspective: false, dark: true, show_tree: true, show_commands: true }
}
}
/// 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,
/// 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(),
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 journal = self.session.journal.len();
let label = command_label(id);
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
let changed = self.session.journal.len() != journal;
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 command in the Commands panel does: a command without parameters runs,
/// one with parameters opens its form. Programmatic calls use [`ModelApp::run`] instead.
pub fn start(&mut self, id: &str) -> Result<(), String> {
let spec = modelcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
if spec.params.trim() == "{}" {
self.dialog = None;
self.run(spec.id, json!({})).map(|_| ())
} else {
self.dialog = Some(ParamsDialog { command: spec.id, text: "{}".into(), 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.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 };
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
self.screenshots(ctx);
}
/// 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);
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 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");
}
}
+187
View File
@@ -0,0 +1,187 @@
//! 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;
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()));
});
}
},
);
ui.label(format!("History ({} commands)", app.session.journal.len()));
});
},
);
}
/// 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(""), "");
}
}
+87
View File
@@ -0,0 +1,87 @@
//! What the viewport draws: the model's display data in the chosen style and the theme's
//! colours. The GPU viewport and the headless render (`ui.render`) draw the same [`Scene`].
use modelcraft_render::{Camera, DisplayMesh, Scene, SceneLine, ScenePoint, SceneTri};
use serde::{Deserialize, Serialize};
use crate::theme::{Tokens, rgb};
/// How the model is drawn.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Style {
/// Lit faces with the element edges on them.
#[default]
ShadedEdges,
/// Lit faces only.
Shaded,
/// Element edges only.
Wireframe,
}
impl Style {
pub const ALL: [Style; 3] = [Style::ShadedEdges, Style::Shaded, Style::Wireframe];
pub fn label(self) -> &'static str {
match self {
Style::ShadedEdges => "Shaded with edges",
Style::Shaded => "Shaded",
Style::Wireframe => "Wireframe",
}
}
}
/// Mesh edge width, line element width and node size, in points.
const EDGE_WIDTH: f64 = 1.0;
const LINE_ELEMENT_WIDTH: f64 = 3.0;
const NODE_SIZE: f64 = 5.0;
/// The scene for `display` drawn in `style`.
pub fn build(display: &DisplayMesh, style: Style, show_nodes: bool, t: &Tokens) -> Scene {
let mut s = Scene { background: Some((rgb(t.viewport_top), rgb(t.viewport_bottom))), ..Default::default() };
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 style != Style::Shaded {
// 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(|[a, b]| SceneLine { a: *a, b: *b, color, width: EDGE_WIDTH }).collect();
}
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(|p| ScenePoint { p: *p, color: rgb(t.node), size: NODE_SIZE }).collect();
}
s
}
/// The radius around the camera target that holds the whole model (for the clipping planes).
pub fn radius(display: &DisplayMesh, cam: &Camera) -> f64 {
let b = &display.bounds;
if b.is_empty() { 1.0 } else { (cam.target.dist(b.center()) + b.diagonal() * 0.5).max(1e-3) }
}
#[cfg(test)]
mod tests {
use modelcraft_model::{ElementKind, Model};
use super::*;
#[test]
fn styles_choose_faces_edges_and_nodes() {
let mut m = Model::default();
for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]].into_iter().enumerate() {
m.set_node(i as u32 + 1, p).unwrap();
}
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);
assert_eq!((s.tris.len(), s.lines.len(), s.points.len()), (4, 6, 0));
let s = build(&d, Style::Shaded, true, &t);
assert_eq!((s.tris.len(), s.lines.len(), s.points.len()), (4, 0, 4));
let s = build(&d, Style::Wireframe, false, &t);
assert_eq!((s.tris.len(), s.lines.len()), (0, 6));
assert_eq!(s.tris.first().map(|t| t.color), None);
assert!(radius(&d, &Camera::default()) > 0.5);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Colours and widget style: a dark theme (the default) and a light one. 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.
use egui::{Color32, Stroke};
use modelcraft_model::ElementKind;
use modelcraft_render::Rgb;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub app_bar: Color32,
pub panel: Color32,
pub panel_header: Color32,
pub border: Color32,
pub field: Color32,
pub text: Color32,
pub text_dim: Color32,
pub accent: Color32,
pub accent_soft: Color32,
pub hover: Color32,
pub error: Color32,
pub ok: Color32,
// ---- viewport ----
/// Background gradient, top and bottom.
pub viewport_top: Color32,
pub viewport_bottom: Color32,
/// Buttons drawn over the viewport.
pub overlay: Color32,
pub overlay_text: Color32,
pub axis_x: Color32,
pub axis_y: Color32,
pub axis_z: Color32,
// ---- elements ----
pub hex: Color32,
pub tet: Color32,
pub wedge: Color32,
pub shell: Color32,
pub line_element: Color32,
/// Element edges (the mesh wireframe).
pub mesh_edge: Color32,
pub node: Color32,
}
impl Tokens {
pub fn get(dark: bool) -> 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),
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),
}
}
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),
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),
axis_x: Color32::from_rgb(0xc8, 0x3c, 0x32),
axis_y: Color32::from_rgb(0x3c, 0x96, 0x32),
axis_z: Color32::from_rgb(0x2c, 0x64, 0xc8),
hex: Color32::from_rgb(0x78, 0x9c, 0xc0),
tet: Color32::from_rgb(0x80, 0xb4, 0x8a),
wedge: Color32::from_rgb(0xc0, 0xaa, 0x78),
shell: Color32::from_rgb(0xc0, 0x8c, 0x78),
line_element: Color32::from_rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Color32::from_rgb(0x1e, 0x23, 0x2a),
node: Color32::from_rgb(0xb4, 0x3c, 0x8c),
}
}
/// The colour elements of this kind are drawn in.
pub fn element(&self, kind: ElementKind) -> Color32 {
match kind {
ElementKind::Hex8 | ElementKind::Hex20 => self.hex,
ElementKind::Tet4 | ElementKind::Tet10 => self.tet,
ElementKind::Wedge6 => self.wedge,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => self.shell,
ElementKind::Rod2 | ElementKind::Beam2 => self.line_element,
}
}
}
/// A colour for the renderer.
pub fn rgb(c: Color32) -> Rgb {
Rgb(c.r(), c.g(), c.b())
}
fn visuals(dark: bool) -> egui::Visuals {
let t = Tokens::get(dark);
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
v.panel_fill = t.panel;
v.window_fill = t.panel;
v.window_stroke = Stroke::new(1.0, t.border);
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;
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
}
/// Style the context for the dark or light theme.
pub fn apply(ctx: &egui::Context, dark: bool) {
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(visuals(dark));
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 3.0);
});
}
#[cfg(test)]
mod tests {
use super::*;
/// Every element kind has a colour, and edges stand out from the faces they lie on.
#[test]
fn element_colours_contrast_with_edges() {
let luma = |c: Color32| u32::from(c.r()) * 3 + u32::from(c.g()) * 6 + u32::from(c.b());
for dark in [true, false] {
let t = Tokens::get(dark);
for k in ElementKind::ALL {
assert!(luma(t.element(k)) > luma(t.mesh_edge) + 600, "{k:?} dark={dark}");
}
}
}
}
+187
View File
@@ -0,0 +1,187 @@
//! 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.
//!
//! 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.
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::theme::Tokens;
/// Wheel zoom per point scrolled.
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);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
navigate(app, ui, &response, rect);
let t = Tokens::get(app.ui.dark);
let painter = ui.painter_at(rect);
gradient(&painter, rect, t.viewport_top, t.viewport_bottom);
if app.viewport.gpu.is_some() {
paint_gpu(app, ui, &painter, rect);
} else {
paint_cpu(app, ui, &painter, rect);
}
if app.session.model.nodes().is_empty() {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"The model is empty.\nAdd nodes and elements from the Commands panel, or start with --sample.",
egui::FontId::proportional(15.0),
t.text_dim,
);
}
axis_indicator(app, &painter, rect, &t);
view_buttons(app, ui, rect, &t);
}
fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: Rect) {
let (w, h) = (f64::from(rect.width()), f64::from(rect.height()));
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 {
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)
{
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())) {
app.fit_view();
}
if response.hovered() {
let (scroll, pinch, at) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta(), i.pointer.hover_pos()));
let factor = (-f64::from(scroll) * ZOOM_PER_POINT).exp() / f64::from(pinch).max(1e-3);
if (factor - 1.0).abs() > 1e-9 {
let anchor = at.and_then(|p| app.cam.target_plane_point(f64::from(p.x - rect.left()), f64::from(p.y - rect.top()), w, h));
app.cam.zoom_at(factor, anchor);
}
}
}
/// A vertical gradient behind the model.
fn gradient(painter: &egui::Painter, rect: Rect, top: Color32, bottom: Color32) {
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(rect.left_top(), top);
mesh.colored_vertex(rect.right_top(), top);
mesh.colored_vertex(rect.right_bottom(), bottom);
mesh.colored_vertex(rect.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: Rect) {
let ppp = ui.ctx().pixels_per_point();
let key = app.scene().0;
let radius = app.scene.1.radius;
if app.viewport.uploaded != Some(key) {
let g = GpuScene::from_scene(&app.scene.1, ppp);
if let Ok(mut slot) = app.viewport.slot.lock() {
*slot = Some(g);
app.viewport.uploaded = Some(key);
}
}
let cam = app.cam;
let aspect = f64::from(rect.width()) / f64::from(rect.height()).max(1.0);
let eye = cam.eye();
let cb = ViewportCallback {
key,
slot: app.viewport.slot.clone(),
view_proj: cam.view_proj(aspect, radius).to_f32(),
back: cam.back().to_f32(),
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if cam.fov > 0.0 { 1.0 } else { 0.0 }],
size_px: [rect.width() * ppp, rect.height() * ppp],
};
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
}
/// Without a GPU: the CPU rasterizer into a texture, redrawn when the scene, camera or size
/// changes.
fn paint_cpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: Rect) {
let ppp = ui.ctx().pixels_per_point();
// A window larger than the renderer's limit is drawn smaller and scaled up.
let (w, h) = modelcraft_render::fit_size((rect.width() * ppp) as usize, (rect.height() * ppp) as usize);
let size = [w, h];
let cam = app.cam;
let key = app.scene().0;
let fresh = app.viewport.cpu.as_ref().is_some_and(|(_, k, c, s)| *k == key && *c == cam && *s == size);
// A scene the renderer refused is reported once and not retried until it changes.
if !fresh && app.viewport.cpu_failed != Some(key) {
let c = match modelcraft_render::render(&app.scene.1, &cam, w, h) {
Ok(c) => c,
Err(e) => {
app.viewport.cpu = None;
app.viewport.cpu_failed = Some(key);
app.set_status(format!("Viewport: {e}"), true);
return;
}
};
app.viewport.cpu_failed = None;
let image = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba);
let tex = match app.viewport.cpu.take() {
Some((mut tex, ..)) => {
tex.set(image, egui::TextureOptions::LINEAR);
tex
}
None => ui.ctx().load_texture("mc_viewport_cpu", image, egui::TextureOptions::LINEAR),
};
app.viewport.cpu = Some((tex, key, cam, size));
}
if let Some((tex, ..)) = &app.viewport.cpu {
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
/// 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) {
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);
}
}
/// 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);
}
}
ui.separator();
if view_button(ui, "Fit", t).on_hover_text("Frame the whole model (F, or double-click)").clicked() {
app.fit_view();
}
});
});
}
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.
pub fn to_screen(app: &ModelApp, p: Vec3) -> Option<Pos2> {
let rect = app.viewport.rect?;
let radius = app.scene.1.radius.max(1.0);
let (x, y, _) = app.cam.to_screen(p, f64::from(rect.width()), f64::from(rect.height()), radius)?;
Some(pos2(rect.left() + x as f32, rect.top() + y as f32))
}