forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * THIRD-PARTY-LICENSES: the desktop app's dependencies Co-Authored-By: Claude Opus 5.5 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
27 files changed
+13965
-895
No files matched your search
@@ -96,6 +96,10 @@ People trust ModelCraft with their models; a crash loses their work. **This outr
|
||||
- `cargo run -p modelcraft-cli -- commands` lists every command with its parameters.
|
||||
- `cargo run -p modelcraft-cli -- run script.json` runs a script (a JSON array of
|
||||
`{"command", "params"}`) and prints the model summary.
|
||||
- `cargo run --release -p modelcraft -- --sample --control PORT` opens the desktop app with the
|
||||
sample model and a control channel on `127.0.0.1:PORT` (docs/control-protocol.md). For UI work,
|
||||
look at the result: `ui.screenshot {path}` (needs a presented frame) or `ui.render {path}`
|
||||
(CPU render, no window needed), then read the PNG.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Parallel agents: separate target dirs, one
|
||||
worktree each.
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ is committed and listed here with its exports.
|
||||
| xtask/src/assets.rs, xtask/src/licences.rs, xtask/src/test.rs, xtask/src/main.rs | SolidCraft `xtask/src/` | MIT OR Apache-2.0 |
|
||||
| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 |
|
||||
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
|
||||
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs, crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots) | SolidCraft `crates/ui-egui/src/` | MIT OR Apache-2.0 |
|
||||
| apps/modelcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
|
||||
|
||||
## Test oracles
|
||||
|
||||
|
||||
Generated
+3328
-14
File diff suppressed because it is too large.
Load diff
+16
-3
@@ -17,6 +17,8 @@ modelcraft-geometry = { path = "crates/geometry" }
|
||||
modelcraft-mesh = { path = "crates/mesh" }
|
||||
modelcraft-engine = { path = "crates/engine" }
|
||||
modelcraft-testkit = { path = "crates/testkit" }
|
||||
modelcraft-render = { path = "crates/render" }
|
||||
modelcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -28,6 +30,14 @@ log = "0.4"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
|
||||
# images (PNG snapshots and screenshots)
|
||||
png = "0.18"
|
||||
|
||||
# ui: the same egui/eframe/egui-wgpu line as SolidCraft
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
|
||||
# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it
|
||||
# (plan/adr/0001-libraries.md).
|
||||
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
@@ -74,11 +84,14 @@ lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = "line-tables-only"
|
||||
|
||||
# SolidCraft builds truck with local fixes (its vendor/ directory). Patches don't reach a
|
||||
# dependent workspace, so ModelCraft applies the same ones from the same pinned commit; keep this
|
||||
# list and the rev in step with SolidCraft's [patch.crates-io].
|
||||
# SolidCraft builds truck and egui-wgpu with local fixes (its vendor/ directory). Patches don't
|
||||
# reach a dependent workspace, so ModelCraft applies the same ones from the same pinned commit;
|
||||
# keep this list and the rev in step with SolidCraft's [patch.crates-io].
|
||||
[patch.crates-io]
|
||||
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
# egui-wgpu with SolidCraft's fixes (vendor/egui-wgpu there): MSAA clamped to what the device
|
||||
# supports and 1x on OpenGL adapters, so the requested 4x never fails pipeline validation at start.
|
||||
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
+1
-1
@@ -12,5 +12,5 @@ LS-PrePost, with hexahedral meshing as the centre of gravity, plus its own linea
|
||||
| M4 Model definition | Materials, sections, sets and surfaces, boundary conditions, loads, steps, output requests | planned |
|
||||
| M5 Solver decks | Abaqus input, Nastran bulk data, LS-DYNA keyword: writers first, then readers | planned |
|
||||
| M6 Linear solver | Element library, sparse assembly, linear static and modal analysis, verified against analytic solutions | planned |
|
||||
| M7 Desktop and agents | egui desktop app, control channel, MCP server, browser build | planned |
|
||||
| M7 Desktop and agents | egui desktop app, control channel, MCP server, browser build | started: desktop app (viewport, model tree, commands, status bar) and control channel |
|
||||
| M8 Post-processing | Results display: deformed shape, contours, probes | planned |
|
||||
+6945
-873
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "modelcraft"
|
||||
description = "ModelCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
modelcraft-engine = { workspace = true }
|
||||
modelcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line. Agents and
|
||||
//! tests drive the app through it; the UI thread answers each request between frames
|
||||
//! (`modelcraft_ui_egui::control`).
|
||||
//!
|
||||
//! Adapted from SolidCraft's `apps/solidcraft/src/control_server.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use modelcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest request line (bytes).
|
||||
const MAX_LINE: usize = 16 * 1024 * 1024;
|
||||
/// Connections served at once. The channel has no authentication beyond being bound to
|
||||
/// loopback, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
/// How long a request waits for the UI thread.
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("modelcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("modelcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(REPLY_TIMEOUT).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() || bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
/// Lines split on newlines, blank lines kept, no trailing newline needed; a line spanning
|
||||
/// several reads arrives whole.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::with_capacity(3, text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
let mut r = BufReader::with_capacity(4, b"0123456789\n".as_slice());
|
||||
assert!(read_line(&mut r, 5).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! ModelCraft desktop app.
|
||||
//!
|
||||
//! Usage: `modelcraft [--sample] [--control PORT]`
|
||||
//!
|
||||
//! `--sample` opens a sample model (`modelcraft_engine::sample`). `--control PORT` (or
|
||||
//! `MODELCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"model.info","params":{}}}`. See
|
||||
//! `modelcraft_ui_egui::control` and docs/control-protocol.md.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod control_server;
|
||||
|
||||
use modelcraft_engine::Session;
|
||||
use modelcraft_ui_egui::ModelApp;
|
||||
|
||||
const USAGE: &str = "usage: modelcraft [--sample] [--control PORT]";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
struct App {
|
||||
app: ModelApp,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Command-line options.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
sample: bool,
|
||||
control: Option<u16>,
|
||||
}
|
||||
|
||||
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Result<Option<Args>, String> {
|
||||
let mut out = Args { control: env_port.and_then(|p| p.trim().parse().ok()), ..Default::default() };
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--sample" => out.sample = true,
|
||||
"--control" => {
|
||||
let port = args.next().ok_or("--control needs a port")?;
|
||||
out.control = Some(port.parse().map_err(|_| format!("--control: `{port}` is not a port number"))?);
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
println!("modelcraft {}", modelcraft_ui_egui::VERSION);
|
||||
return Ok(None);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("{USAGE}");
|
||||
return Ok(None);
|
||||
}
|
||||
other => return Err(format!("unknown argument `{other}`\n{USAGE}")),
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let args = match parse_args(std::env::args().skip(1), std::env::var("MODELCRAFT_CONTROL_PORT").ok()) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return Ok(()),
|
||||
Err(e) => {
|
||||
eprintln!("modelcraft: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default().with_title("ModelCraft").with_inner_size([1400.0, 900.0]).with_min_inner_size([800.0, 500.0]),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
multisampling: MSAA,
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"ModelCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
eprintln!("modelcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
}
|
||||
if let Some(port) = args.control {
|
||||
app = app.with_control(control_server::start(port, cc.egui_ctx.clone()));
|
||||
}
|
||||
if args.sample {
|
||||
// The status bar reports a failure; the app still opens.
|
||||
let _ = app.run_script(&modelcraft_engine::sample::script());
|
||||
}
|
||||
Ok(Box::new(App { app }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(a: &[&str], env: Option<&str>) -> Result<Option<Args>, String> {
|
||||
parse_args(a.iter().map(|s| s.to_string()), env.map(str::to_string))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments() {
|
||||
assert_eq!(parse(&[], None), Ok(Some(Args::default())));
|
||||
assert_eq!(parse(&["--sample", "--control", "7070"], None), Ok(Some(Args { sample: true, control: Some(7070) })));
|
||||
assert_eq!(parse(&[], Some("9000")), Ok(Some(Args { sample: false, control: Some(9000) })));
|
||||
assert_eq!(parse(&[], Some("nope")), Ok(Some(Args::default())));
|
||||
assert!(parse(&["--control"], None).is_err());
|
||||
assert!(parse(&["--control", "99999"], None).is_err());
|
||||
assert!(parse(&["--frobnicate"], None).is_err());
|
||||
assert_eq!(parse(&["--help"], None), Ok(None));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cmd;
|
||||
pub mod sample;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The sample model the desktop app opens with `--sample`: a script of ordinary commands, so it
|
||||
//! builds the same way a user's script does.
|
||||
//!
|
||||
//! Two parts side by side: a 40 × 20 × 10 block of 5 mm hex8 cells, and a 15 mm cube split into
|
||||
//! 162 tet4 elements (each 5 mm cell into six tetrahedra around its main diagonal, the Kuhn
|
||||
//! subdivision, which is conforming between neighbouring cells). Every element is positively
|
||||
//! oriented.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Hex block: cells along x, y, z and the cell size (mm).
|
||||
const HEX_CELLS: [u32; 3] = [8, 4, 2];
|
||||
/// Tet cube: cells per side and its offset from the origin (mm).
|
||||
const TET_CELLS: u32 = 3;
|
||||
const TET_ORIGIN: [f64; 3] = [50.0, 2.5, 0.0];
|
||||
const CELL: f64 = 5.0;
|
||||
|
||||
/// The script: `mesh.node.add` and `mesh.element.add` steps.
|
||||
pub fn script() -> Value {
|
||||
let mut steps = Vec::new();
|
||||
let mut node = 0u32;
|
||||
let mut element = 0u32;
|
||||
// Hex block.
|
||||
let [nx, ny, nz] = HEX_CELLS;
|
||||
let first = node + 1;
|
||||
let hid = |i: u32, j: u32, k: u32| first + i + j * (nx + 1) + k * (nx + 1) * (ny + 1);
|
||||
for k in 0..=nz {
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
node += 1;
|
||||
steps.push(json!({"command": "mesh.node.add", "params": {"label": node, "xyz": [f64::from(i) * CELL, f64::from(j) * CELL, f64::from(k) * CELL]}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..nz {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
element += 1;
|
||||
let nodes = [
|
||||
hid(i, j, k),
|
||||
hid(i + 1, j, k),
|
||||
hid(i + 1, j + 1, k),
|
||||
hid(i, j + 1, k),
|
||||
hid(i, j, k + 1),
|
||||
hid(i + 1, j, k + 1),
|
||||
hid(i + 1, j + 1, k + 1),
|
||||
hid(i, j + 1, k + 1),
|
||||
];
|
||||
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "hex8", "nodes": nodes}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tet cube.
|
||||
let n = TET_CELLS;
|
||||
let first = node + 1;
|
||||
let tid = |i: u32, j: u32, k: u32| first + i + j * (n + 1) + k * (n + 1) * (n + 1);
|
||||
let at = |i: u32, j: u32, k: u32| [TET_ORIGIN[0] + f64::from(i) * CELL, TET_ORIGIN[1] + f64::from(j) * CELL, TET_ORIGIN[2] + f64::from(k) * CELL];
|
||||
for k in 0..=n {
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
node += 1;
|
||||
steps.push(json!({"command": "mesh.node.add", "params": {"label": node, "xyz": at(i, j, k)}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The six axis orders: each walks from the cell's corner (0,0,0) to (1,1,1) one axis at a time.
|
||||
const ORDERS: [[usize; 3]; 6] = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
|
||||
for k in 0..n {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
for order in ORDERS {
|
||||
let mut c = [0u32; 3];
|
||||
let mut corners = vec![c];
|
||||
for axis in order {
|
||||
if let Some(x) = c.get_mut(axis) {
|
||||
*x = 1;
|
||||
}
|
||||
corners.push(c);
|
||||
}
|
||||
let mut nodes: Vec<u32> = corners.iter().map(|c| tid(i + c[0], j + c[1], k + c[2])).collect();
|
||||
let p: Vec<[f64; 3]> = corners.iter().map(|c| at(i + c[0], j + c[1], k + c[2])).collect();
|
||||
if signed_volume(&p) < 0.0 {
|
||||
nodes.swap(1, 2);
|
||||
}
|
||||
element += 1;
|
||||
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "tet4", "nodes": nodes}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(steps)
|
||||
}
|
||||
|
||||
/// Six times the signed volume of the tetrahedron `p[0..4]` (positive when p3 lies on the side
|
||||
/// p0 → p1 → p2 turns counter-clockwise around).
|
||||
fn signed_volume(p: &[[f64; 3]]) -> f64 {
|
||||
let (Some(a), Some(b), Some(c), Some(d)) = (p.first(), p.get(1), p.get(2), p.get(3)) else { return 0.0 };
|
||||
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||||
let w = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
|
||||
u[0] * (v[1] * w[2] - v[2] * w[1]) - u[1] * (v[0] * w[2] - v[2] * w[0]) + u[2] * (v[0] * w[1] - v[1] * w[0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn the_sample_builds_positive_elements_that_fill_both_parts() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script()).unwrap();
|
||||
let info = s.execute("model.info", &json!({})).unwrap();
|
||||
assert_eq!(info["elements"]["hex8"], 64);
|
||||
assert_eq!(info["elements"]["tet4"], 162);
|
||||
assert_eq!(info["nodes"], 9 * 5 * 3 + 4 * 4 * 4);
|
||||
// Tet volumes are positive and add up to the cube's.
|
||||
let mut volume = 0.0;
|
||||
for e in s.model.elements().values().filter(|e| e.kind == modelcraft_model::ElementKind::Tet4) {
|
||||
let p: Vec<[f64; 3]> = e.nodes.iter().map(|n| s.model.node(*n).unwrap().xyz).collect();
|
||||
let v = signed_volume(&p) / 6.0;
|
||||
assert!(v > 0.0);
|
||||
volume += v;
|
||||
}
|
||||
assert!((volume - 15.0f64.powi(3)).abs() < 1e-9, "{volume}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "modelcraft-render"
|
||||
description = "ModelCraft display data: the visible faces, edges and nodes of a finite element model, an orbit camera, and a CPU rasterizer for headless snapshots (no GPU dependency)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
modelcraft-model = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
png = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,327 @@
|
||||
//! An orbit camera about a target point, Z up, with perspective or orthographic projection.
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/render/src/camera.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Aabb3, Vec3};
|
||||
|
||||
/// Column-major 4×4 matrix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4(pub [[f64; 4]; 4]);
|
||||
|
||||
impl Mat4 {
|
||||
pub fn mul(&self, o: &Mat4) -> Mat4 {
|
||||
let mut r = [[0.0; 4]; 4];
|
||||
for (c, col) in r.iter_mut().enumerate() {
|
||||
for (rr, cell) in col.iter_mut().enumerate() {
|
||||
*cell = (0..4).map(|k| self.0[k][rr] * o.0[c][k]).sum();
|
||||
}
|
||||
}
|
||||
Mat4(r)
|
||||
}
|
||||
/// Transform a point; returns (x, y, z, w).
|
||||
pub fn apply(&self, p: Vec3) -> [f64; 4] {
|
||||
let v = [p.x, p.y, p.z, 1.0];
|
||||
let mut out = [0.0; 4];
|
||||
for (r, o) in out.iter_mut().enumerate() {
|
||||
*o = (0..4).map(|c| self.0[c][r] * v[c]).sum();
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn to_f32(&self) -> [[f32; 4]; 4] {
|
||||
self.0.map(|c| c.map(|x| x as f32))
|
||||
}
|
||||
}
|
||||
|
||||
/// Named view orientations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StandardView {
|
||||
Front,
|
||||
Back,
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
Iso,
|
||||
}
|
||||
|
||||
impl StandardView {
|
||||
pub const ALL: [StandardView; 7] = [
|
||||
StandardView::Front,
|
||||
StandardView::Back,
|
||||
StandardView::Left,
|
||||
StandardView::Right,
|
||||
StandardView::Top,
|
||||
StandardView::Bottom,
|
||||
StandardView::Iso,
|
||||
];
|
||||
|
||||
pub fn parse(s: &str) -> Option<StandardView> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"front" => StandardView::Front,
|
||||
"back" => StandardView::Back,
|
||||
"top" => StandardView::Top,
|
||||
"bottom" => StandardView::Bottom,
|
||||
"left" => StandardView::Left,
|
||||
"right" => StandardView::Right,
|
||||
"iso" | "isometric" => StandardView::Iso,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StandardView::Front => "Front",
|
||||
StandardView::Back => "Back",
|
||||
StandardView::Top => "Top",
|
||||
StandardView::Bottom => "Bottom",
|
||||
StandardView::Left => "Left",
|
||||
StandardView::Right => "Right",
|
||||
StandardView::Iso => "Iso",
|
||||
}
|
||||
}
|
||||
/// (yaw, pitch) in radians. Yaw 0 looks along +Y (front view); the iso view looks from the
|
||||
/// front-right-top corner. Pitch is the elevation above the XY plane.
|
||||
pub fn angles(self) -> (f64, f64) {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
match self {
|
||||
StandardView::Front => (0.0, 0.0),
|
||||
StandardView::Back => (PI, 0.0),
|
||||
StandardView::Right => (-FRAC_PI_2, 0.0),
|
||||
StandardView::Left => (FRAC_PI_2, 0.0),
|
||||
StandardView::Top => (0.0, FRAC_PI_2),
|
||||
StandardView::Bottom => (0.0, -FRAC_PI_2),
|
||||
// atan(1/√2): the elevation of the cube diagonal.
|
||||
StandardView::Iso => (-FRAC_PI_4, 0.6154797086703874),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target`, Z up.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Camera {
|
||||
pub target: Vec3,
|
||||
/// Rotation about Z; 0 = looking along +Y (from the front).
|
||||
pub yaw: f64,
|
||||
/// Elevation above the XY plane, in [−π/2, π/2].
|
||||
pub pitch: f64,
|
||||
/// Distance from target to eye.
|
||||
pub distance: f64,
|
||||
/// Vertical field of view (radians); 0 = orthographic.
|
||||
pub fov: f64,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
let (yaw, pitch) = StandardView::Iso.angles();
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// Direction from the target toward the eye.
|
||||
pub fn back(&self) -> Vec3 {
|
||||
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
|
||||
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
|
||||
}
|
||||
pub fn eye(&self) -> Vec3 {
|
||||
self.target + self.back() * self.distance
|
||||
}
|
||||
/// Camera basis: right, up, back (orthonormal).
|
||||
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
|
||||
let b = self.back();
|
||||
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
|
||||
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
|
||||
(right, up, b)
|
||||
}
|
||||
pub fn view(&self) -> Mat4 {
|
||||
let (r, u, b) = self.basis();
|
||||
let e = self.eye();
|
||||
Mat4([[r.x, u.x, b.x, 0.0], [r.y, u.y, b.y, 0.0], [r.z, u.z, b.z, 0.0], [-r.dot(e), -u.dot(e), -b.dot(e), 1.0]])
|
||||
}
|
||||
/// Half the visible height at the target distance.
|
||||
pub fn half_height(&self) -> f64 {
|
||||
if self.fov > 0.0 { self.distance * (self.fov * 0.5).tan() } else { self.distance * 0.4 }
|
||||
}
|
||||
/// Near/far planes that keep a scene of radius `radius` around the target visible.
|
||||
pub fn clip(&self, radius: f64) -> (f64, f64) {
|
||||
let r = radius.max(1e-3);
|
||||
if self.fov > 0.0 {
|
||||
((self.distance - r * 2.0).max(self.distance * 1e-3).max(1e-3), self.distance + r * 2.0)
|
||||
} else {
|
||||
(self.distance - r * 4.0 - 1.0, self.distance + r * 4.0 + 1.0)
|
||||
}
|
||||
}
|
||||
/// Projection to clip space (wgpu style, depth 0..1).
|
||||
pub fn proj(&self, aspect: f64, near: f64, far: f64) -> Mat4 {
|
||||
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 };
|
||||
let (n, f) = (near, far.max(near + 1e-6));
|
||||
if self.fov > 0.0 {
|
||||
let t = 1.0 / (self.fov * 0.5).tan();
|
||||
Mat4([[t / aspect, 0.0, 0.0, 0.0], [0.0, t, 0.0, 0.0], [0.0, 0.0, f / (n - f), -1.0], [0.0, 0.0, n * f / (n - f), 0.0]])
|
||||
} else {
|
||||
let h = self.half_height();
|
||||
let w = h * aspect;
|
||||
Mat4([[1.0 / w, 0.0, 0.0, 0.0], [0.0, 1.0 / h, 0.0, 0.0], [0.0, 0.0, 1.0 / (n - f), 0.0], [0.0, 0.0, n / (n - f), 1.0]])
|
||||
}
|
||||
}
|
||||
pub fn view_proj(&self, aspect: f64, radius: f64) -> Mat4 {
|
||||
let (n, f) = self.clip(radius);
|
||||
self.proj(aspect, n, f).mul(&self.view())
|
||||
}
|
||||
/// World point → pixel coordinates (x right, y down) and depth (0 near … 1 far).
|
||||
pub fn to_screen(&self, p: Vec3, w: f64, h: f64, radius: f64) -> Option<(f64, f64, f64)> {
|
||||
let c = self.view_proj(w / h.max(1.0), radius).apply(p);
|
||||
if c[3].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]);
|
||||
(c[3] > 0.0).then_some(((x + 1.0) * 0.5 * w, (1.0 - y) * 0.5 * h, z))
|
||||
}
|
||||
/// Picking ray through pixel (x, y): origin and unit direction.
|
||||
pub fn ray(&self, x: f64, y: f64, w: f64, h: f64) -> (Vec3, Vec3) {
|
||||
let (r, u, b) = self.basis();
|
||||
let hh = self.half_height();
|
||||
let aspect = w / h.max(1.0);
|
||||
let nx = (x / w.max(1.0)) * 2.0 - 1.0;
|
||||
let ny = 1.0 - (y / h.max(1.0)) * 2.0;
|
||||
if self.fov > 0.0 {
|
||||
let d = (r * (nx * hh * aspect) + u * (ny * hh) - b * self.distance).normalized().unwrap_or(-b);
|
||||
(self.eye(), d)
|
||||
} else {
|
||||
let o = self.target + r * (nx * hh * aspect) + u * (ny * hh) + b * (self.distance * 4.0);
|
||||
(o, -b)
|
||||
}
|
||||
}
|
||||
/// The point under pixel (x, y) on the plane through the target facing the eye: the anchor
|
||||
/// that stays put when zooming at the cursor.
|
||||
pub fn target_plane_point(&self, x: f64, y: f64, w: f64, h: f64) -> Option<Vec3> {
|
||||
let (o, d) = self.ray(x, y, w, h);
|
||||
let b = self.back();
|
||||
let dn = d.dot(b);
|
||||
if dn.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let p = o + d * ((self.target - o).dot(b) / dn);
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
/// Zoom by `factor` (< 1 zooms in) toward a world point that stays put on screen.
|
||||
pub fn zoom_at(&mut self, factor: f64, anchor: Option<Vec3>) {
|
||||
let f = if factor.is_finite() { factor.clamp(0.05, 20.0) } else { 1.0 };
|
||||
let nd = (self.distance * f).clamp(1e-3, 1e9);
|
||||
if let Some(a) = anchor.filter(|a| a.is_finite()) {
|
||||
self.target = a + (self.target - a) * (nd / self.distance);
|
||||
}
|
||||
self.distance = nd;
|
||||
}
|
||||
/// Pan by a screen delta in pixels for a viewport of height `h`.
|
||||
pub fn pan(&mut self, dx: f64, dy: f64, h: f64) {
|
||||
if !(dx.is_finite() && dy.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (r, u, _) = self.basis();
|
||||
let k = 2.0 * self.half_height() / h.max(1.0);
|
||||
self.target = self.target - r * (dx * k) + u * (dy * k);
|
||||
}
|
||||
/// Orbit by pixel deltas. The model follows the cursor: dragging right turns the side
|
||||
/// facing the viewer to the right.
|
||||
pub fn orbit(&mut self, dx: f64, dy: f64) {
|
||||
if !(dx.is_finite() && dy.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-4;
|
||||
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
|
||||
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
|
||||
}
|
||||
pub fn set_view(&mut self, v: StandardView) {
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let (y, p) = v.angles();
|
||||
self.yaw = y;
|
||||
self.pitch = p.clamp(-lim, lim);
|
||||
}
|
||||
/// Frame a bounding box.
|
||||
pub fn fit(&mut self, b: &Aabb3) {
|
||||
if b.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.target = b.center();
|
||||
let r = (b.diagonal() * 0.5).max(1e-3);
|
||||
self.distance = if self.fov > 0.0 { r / (self.fov * 0.5).sin() * 1.1 } else { r / 0.4 * 1.15 };
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn front_view_projects_target_to_centre() {
|
||||
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
let (x, y, z) = c.to_screen(c.target, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!((x - 400.0).abs() < 1e-6 && (y - 300.0).abs() < 1e-6 && (0.0..=1.0).contains(&z));
|
||||
// Front view looks along +Y: +X is to the right, +Z up.
|
||||
let (xr, _, _) = c.to_screen(c.target + Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||||
let (_, yu, _) = c.to_screen(c.target + Vec3::Z, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!(xr > 400.0 && yu < 300.0);
|
||||
let (o, d) = c.ray(400.0, 300.0, 800.0, 600.0);
|
||||
assert!(d.dist(Vec3::Y) < 1e-9, "{d:?}");
|
||||
assert!((o - c.target).cross(d).len() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_orbit_follows_the_cursor() {
|
||||
let mut c = Camera { distance: 50.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
let near = Vec3::new(0.0, -1.0, 0.0);
|
||||
let x0 = c.to_screen(near, 800.0, 600.0, 10.0).unwrap().0;
|
||||
c.orbit(20.0, 0.0);
|
||||
let x1 = c.to_screen(near, 800.0, 600.0, 10.0).unwrap().0;
|
||||
assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_view_and_perspective() {
|
||||
let mut c = Camera { distance: 100.0, fov: 0.8, ..Default::default() };
|
||||
c.set_view(StandardView::Top);
|
||||
let (xr, _, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||||
let (_, yu, _) = c.to_screen(Vec3::Y, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!(xr > 400.0 && yu < 300.0, "{xr} {yu}");
|
||||
let near = c.to_screen(Vec3::new(0.0, 0.0, 10.0), 800.0, 600.0, 20.0).unwrap().2;
|
||||
let far = c.to_screen(Vec3::new(0.0, 0.0, -10.0), 800.0, 600.0, 20.0).unwrap().2;
|
||||
assert!(near < far);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_at_the_cursor_keeps_the_point_under_it() {
|
||||
let mut c = Camera { distance: 100.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
let a = c.target_plane_point(600.0, 200.0, 800.0, 600.0).unwrap();
|
||||
c.zoom_at(0.5, Some(a));
|
||||
let (x, y, _) = c.to_screen(a, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!((x - 600.0).abs() < 1e-6 && (y - 200.0).abs() < 1e-6, "{x} {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_hostile() {
|
||||
let mut c = Camera::default();
|
||||
let b = Aabb3 { min: Vec3::ZERO, max: Vec3::new(40.0, 30.0, 20.0) };
|
||||
c.fit(&b);
|
||||
assert!(c.target.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-9);
|
||||
c.zoom_at(f64::NAN, Some(Vec3::new(f64::NAN, 0.0, 0.0)));
|
||||
c.orbit(1e9, -1e9);
|
||||
c.orbit(f64::NAN, 0.0);
|
||||
c.pan(f64::INFINITY, 0.0, 100.0);
|
||||
assert!(c.is_valid());
|
||||
c.fit(&Aabb3::EMPTY);
|
||||
assert!(c.is_valid());
|
||||
for v in StandardView::ALL {
|
||||
assert_eq!(StandardView::parse(v.label()), Some(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! What a finite element model looks like on screen: the faces nobody shares (the skin of the
|
||||
//! solid elements, and every shell), the element edges on those faces, line elements and nodes.
|
||||
//!
|
||||
//! Faces shared by two solid elements are inside the mesh and are left out, so a model of a
|
||||
//! million elements draws its surface, not its interior. Higher-order elements (tet10, hex20,
|
||||
//! tri6, quad8) draw through their corner nodes. The output is deterministic: faces come out in
|
||||
//! element label order, edges sorted.
|
||||
|
||||
use modelcraft_model::{ElementKind, Model};
|
||||
|
||||
use crate::{Aabb3, Vec3};
|
||||
|
||||
/// One drawn triangle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DisplayTri {
|
||||
pub p: [Vec3; 3],
|
||||
/// The face's unit normal, pointing out of its element (a shell's own orientation).
|
||||
pub normal: Vec3,
|
||||
pub kind: ElementKind,
|
||||
}
|
||||
|
||||
/// A line element.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DisplayLine {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
pub kind: ElementKind,
|
||||
}
|
||||
|
||||
/// Display data for a model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DisplayMesh {
|
||||
/// Visible faces as triangles (quads are split in two).
|
||||
pub tris: Vec<DisplayTri>,
|
||||
/// Element edges on the visible faces, each once.
|
||||
pub edges: Vec<[Vec3; 2]>,
|
||||
/// Line elements (rods, beams).
|
||||
pub lines: Vec<DisplayLine>,
|
||||
/// Every node.
|
||||
pub nodes: Vec<Vec3>,
|
||||
pub bounds: Aabb3,
|
||||
}
|
||||
|
||||
/// The faces of an element as corner indices (0-based into its node list), each wound
|
||||
/// counter-clockwise seen from outside for a positively oriented element.
|
||||
fn faces(kind: ElementKind) -> &'static [&'static [usize]] {
|
||||
match kind {
|
||||
ElementKind::Rod2 | ElementKind::Beam2 => &[],
|
||||
ElementKind::Tri3 | ElementKind::Tri6 => &[&[0, 1, 2]],
|
||||
ElementKind::Quad4 | ElementKind::Quad8 => &[&[0, 1, 2, 3]],
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => &[&[0, 2, 1], &[0, 1, 3], &[1, 2, 3], &[2, 0, 3]],
|
||||
ElementKind::Wedge6 => &[&[0, 2, 1], &[3, 4, 5], &[0, 1, 4, 3], &[1, 2, 5, 4], &[2, 0, 3, 5]],
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => &[&[0, 3, 2, 1], &[4, 5, 6, 7], &[0, 1, 5, 4], &[1, 2, 6, 5], &[2, 3, 7, 6], &[3, 0, 4, 7]],
|
||||
}
|
||||
}
|
||||
|
||||
/// Most corners of any element (hex).
|
||||
const MAX_CORNERS: usize = 8;
|
||||
|
||||
/// A face's identity regardless of winding and starting corner: its sorted node labels, a
|
||||
/// triangle padded with `u32::MAX`.
|
||||
fn face_key(e: &[u32], face: &[usize]) -> Option<[u32; 4]> {
|
||||
let mut k = [u32::MAX; 4];
|
||||
for (slot, &i) in k.iter_mut().zip(face) {
|
||||
*slot = *e.get(i)?;
|
||||
}
|
||||
k.sort_unstable();
|
||||
Some(k)
|
||||
}
|
||||
|
||||
/// Newell's normal of a polygon (robust for non-planar quads); `None` when degenerate.
|
||||
fn polygon_normal(p: &[Vec3]) -> Option<Vec3> {
|
||||
let mut n = Vec3::ZERO;
|
||||
for (i, a) in p.iter().enumerate() {
|
||||
let b = p.get((i + 1) % p.len()).copied().unwrap_or(*a);
|
||||
n = n + Vec3::new((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y));
|
||||
}
|
||||
n.normalized()
|
||||
}
|
||||
|
||||
fn centre(p: &[Vec3]) -> Vec3 {
|
||||
p.iter().fold(Vec3::ZERO, |s, q| s + *q) * (1.0 / p.len().max(1) as f64)
|
||||
}
|
||||
|
||||
impl DisplayMesh {
|
||||
pub fn from_model(model: &Model) -> DisplayMesh {
|
||||
let mut out = DisplayMesh::default();
|
||||
let pos = |label: u32| model.node(label).map(|n| Vec3::from(n.xyz));
|
||||
for n in model.nodes().values() {
|
||||
let p = Vec3::from(n.xyz);
|
||||
out.nodes.push(p);
|
||||
out.bounds.add(p);
|
||||
}
|
||||
// Faces two solid elements share are inside the mesh: sort every solid face's key and
|
||||
// keep the ones that occur more than once.
|
||||
let mut keys: Vec<[u32; 4]> = model
|
||||
.elements()
|
||||
.values()
|
||||
.filter(|e| e.kind.dimension() == 3)
|
||||
.flat_map(|e| faces(e.kind).iter().filter_map(|f| face_key(&e.nodes, f)))
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
let mut inner: Vec<[u32; 4]> = keys.windows(2).filter(|w| w.first() == w.get(1)).filter_map(|w| w.first().copied()).collect();
|
||||
inner.dedup();
|
||||
drop(keys);
|
||||
let mut edges: Vec<(u32, u32)> = Vec::new();
|
||||
for e in model.elements().values() {
|
||||
// Corners: one past the highest face index (line elements: their two ends).
|
||||
let corners = faces(e.kind).iter().flat_map(|f| f.iter()).max().map_or(2, |m| m + 1).min(MAX_CORNERS);
|
||||
let mut at = [Vec3::ZERO; MAX_CORNERS];
|
||||
let mut found = 0;
|
||||
for (slot, &n) in at.iter_mut().zip(e.nodes.iter().take(corners)) {
|
||||
if let Some(p) = pos(n) {
|
||||
*slot = p;
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
if found < corners {
|
||||
continue;
|
||||
}
|
||||
let at = at.get(..corners).unwrap_or(&[]);
|
||||
if e.kind.dimension() == 1 {
|
||||
if let (Some(&a), Some(&b)) = (at.first(), at.get(1)) {
|
||||
out.lines.push(DisplayLine { a, b, kind: e.kind });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let solid = e.kind.dimension() == 3;
|
||||
let element_centre = centre(at);
|
||||
for f in faces(e.kind) {
|
||||
if solid && face_key(&e.nodes, f).is_some_and(|k| inner.binary_search(&k).is_ok()) {
|
||||
continue;
|
||||
}
|
||||
let p: Vec<Vec3> = f.iter().filter_map(|&i| at.get(i).copied()).collect();
|
||||
let Some(mut normal) = polygon_normal(&p) else { continue };
|
||||
if solid && normal.dot(centre(&p) - element_centre) < 0.0 {
|
||||
normal = -normal;
|
||||
}
|
||||
for k in 1..p.len().saturating_sub(1) {
|
||||
if let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(k), p.get(k + 1)) {
|
||||
out.tris.push(DisplayTri { p: [a, b, c], normal, kind: e.kind });
|
||||
}
|
||||
}
|
||||
for (j, &i) in f.iter().enumerate() {
|
||||
let i2 = f.get((j + 1) % f.len()).copied().unwrap_or(i);
|
||||
if let (Some(&a), Some(&b)) = (e.nodes.get(i), e.nodes.get(i2)) {
|
||||
edges.push((a.min(b), a.max(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
out.edges = edges.into_iter().filter_map(|(a, b)| Some([pos(a)?, pos(b)?])).collect();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `nx × ny × nz` unit hex8 cells.
|
||||
fn block(nx: u32, ny: u32, nz: u32) -> Model {
|
||||
let mut m = Model::default();
|
||||
let id = |i: u32, j: u32, k: u32| 1 + i + j * (nx + 1) + k * (nx + 1) * (ny + 1);
|
||||
for k in 0..=nz {
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
m.set_node(id(i, j, k), [i as f64, j as f64, k as f64]).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut label = 1;
|
||||
for k in 0..nz {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let n = vec![
|
||||
id(i, j, k),
|
||||
id(i + 1, j, k),
|
||||
id(i + 1, j + 1, k),
|
||||
id(i, j + 1, k),
|
||||
id(i, j, k + 1),
|
||||
id(i + 1, j, k + 1),
|
||||
id(i + 1, j + 1, k + 1),
|
||||
id(i, j + 1, k + 1),
|
||||
];
|
||||
m.set_element(label, ElementKind::Hex8, n).unwrap();
|
||||
label += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_shows_its_skin_only() {
|
||||
let d = DisplayMesh::from_model(&block(3, 2, 2));
|
||||
// Skin quads: 2·(3·2 + 3·2 + 2·2) = 32, two triangles each.
|
||||
assert_eq!(d.tris.len(), 64);
|
||||
assert_eq!(d.nodes.len(), 4 * 3 * 3);
|
||||
assert_eq!(d.bounds.max, Vec3::new(3.0, 2.0, 2.0));
|
||||
// Every normal points out of the block.
|
||||
let c = d.bounds.center();
|
||||
for t in &d.tris {
|
||||
let fc = (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
|
||||
assert!(t.normal.dot(fc - c) > 0.0, "{t:?}");
|
||||
}
|
||||
// Euler on the skin: V - E + F = 2 (two nodes are inside).
|
||||
let skin_nodes = 4 * 3 * 3 - 2;
|
||||
assert_eq!(skin_nodes as i64 - d.edges.len() as i64 + 32, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shells_lines_and_tets_draw() {
|
||||
let mut m = Model::default();
|
||||
for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 1., 0.]].into_iter().enumerate() {
|
||||
m.set_node(i as u32 + 1, p).unwrap();
|
||||
}
|
||||
m.set_element(1, ElementKind::Tet4, vec![1, 2, 3, 4]).unwrap();
|
||||
m.set_element(2, ElementKind::Tri3, vec![2, 5, 3]).unwrap();
|
||||
m.set_element(3, ElementKind::Beam2, vec![4, 5]).unwrap();
|
||||
let d = DisplayMesh::from_model(&m);
|
||||
assert_eq!(d.tris.len(), 5);
|
||||
assert_eq!(d.lines.len(), 1);
|
||||
assert_eq!(d.edges.len(), 6 + 2);
|
||||
let tet_normals: Vec<Vec3> = d.tris.iter().filter(|t| t.kind == ElementKind::Tet4).map(|t| t.normal).collect();
|
||||
assert!(tet_normals.contains(&Vec3::new(0.0, 0.0, -1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_deterministic() {
|
||||
assert_eq!(DisplayMesh::from_model(&Model::default()), DisplayMesh::default());
|
||||
let m = block(2, 2, 1);
|
||||
assert_eq!(DisplayMesh::from_model(&m), DisplayMesh::from_model(&m.clone()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Display data for ModelCraft, with no GPU dependency: what a finite element model looks like
|
||||
//! on screen ([`DisplayMesh`]: the visible faces, element edges, line elements and nodes), an
|
||||
//! orbit camera with perspective or orthographic projection, and a CPU rasterizer for headless
|
||||
//! snapshots. The desktop viewport draws the same [`Scene`] on the GPU.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod camera;
|
||||
mod display;
|
||||
mod raster;
|
||||
mod vec;
|
||||
|
||||
pub use camera::{Camera, Mat4, StandardView};
|
||||
pub use display::{DisplayLine, DisplayMesh, DisplayTri};
|
||||
pub use raster::{
|
||||
Canvas, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_png, shade,
|
||||
};
|
||||
pub use vec::{Aabb3, Vec3};
|
||||
@@ -0,0 +1,395 @@
|
||||
//! A drawable scene (coloured triangles, lines and points) and a CPU rasterizer for headless
|
||||
//! snapshots: z-buffered, lit triangles and depth-tested lines, rendered at 2× and downsampled
|
||||
//! for anti-aliasing. The GPU viewport draws the same [`Scene`].
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/render/src/raster.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
|
||||
use crate::{Camera, Vec3};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||
|
||||
impl Rgb {
|
||||
fn f(self) -> [f64; 3] {
|
||||
[self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SceneTri {
|
||||
pub p: [Vec3; 3],
|
||||
/// Unit normal; lighting is two-sided.
|
||||
pub normal: Vec3,
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SceneLine {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
pub color: Rgb,
|
||||
/// Width in output pixels.
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// A node marker: a square `size` output pixels across.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ScenePoint {
|
||||
pub p: Vec3,
|
||||
pub color: Rgb,
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Scene {
|
||||
pub tris: Vec<SceneTri>,
|
||||
pub lines: Vec<SceneLine>,
|
||||
pub points: Vec<ScenePoint>,
|
||||
/// Background gradient top → bottom.
|
||||
pub background: Option<(Rgb, Rgb)>,
|
||||
/// Scene radius around the camera target, for the clipping planes.
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// The lit colour of a surface with normal `n` seen along `view_dir` (toward the eye). The GPU
|
||||
/// shader computes the same.
|
||||
pub fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
|
||||
let n = if n.dot(view_dir) < 0.0 { -n } else { n };
|
||||
let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir);
|
||||
let diff = n.dot(key).max(0.0);
|
||||
let fill = n.dot(view_dir).max(0.0);
|
||||
let h = (key + view_dir).normalized().unwrap_or(view_dir);
|
||||
let spec = n.dot(h).max(0.0).powi(40) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
[(base[0] * k + spec).min(1.0), (base[1] * k + spec).min(1.0), (base[2] * k + spec).min(1.0)]
|
||||
}
|
||||
|
||||
/// RGBA pixels plus depth.
|
||||
pub struct Canvas {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
depth: Vec<f32>,
|
||||
}
|
||||
|
||||
const SS: usize = 2;
|
||||
/// Largest output side in pixels.
|
||||
pub const MAX_DIM: usize = 8192;
|
||||
/// Most output pixels (`w × h`): 16 MP, drawn at 2× into 64 MP of colour and depth (~512 MB).
|
||||
pub const MAX_PIXELS: usize = 4096 * 4096;
|
||||
/// Most primitives (triangles, lines and points together) one render draws.
|
||||
pub const MAX_PRIMITIVES: usize = 20_000_000;
|
||||
|
||||
/// Why a render was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RenderError {
|
||||
/// The image size is zero or over [`MAX_DIM`] per side or [`MAX_PIXELS`] in all.
|
||||
Size { w: usize, h: usize },
|
||||
/// The scene has more than [`MAX_PRIMITIVES`] primitives.
|
||||
SceneTooLarge { primitives: usize },
|
||||
/// The image buffers could not be allocated.
|
||||
OutOfMemory,
|
||||
/// The PNG encoder failed.
|
||||
Encode,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RenderError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RenderError::Size { w, h } => {
|
||||
write!(f, "image size {w} x {h} is out of range (1..={MAX_DIM} per side, at most {MAX_PIXELS} pixels)")
|
||||
}
|
||||
RenderError::SceneTooLarge { primitives } => {
|
||||
write!(f, "the scene has {primitives} primitives; the CPU renderer draws at most {MAX_PRIMITIVES}")
|
||||
}
|
||||
RenderError::OutOfMemory => f.write_str("not enough memory for the image"),
|
||||
RenderError::Encode => f.write_str("PNG encoding failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RenderError {}
|
||||
|
||||
/// Whether `w × h` is an image size [`render`] accepts.
|
||||
fn size_ok(w: usize, h: usize) -> bool {
|
||||
(1..=MAX_DIM).contains(&w) && (1..=MAX_DIM).contains(&h) && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS)
|
||||
}
|
||||
|
||||
/// The largest size with the aspect of `w × h` that [`render`] accepts (for drawing a window of
|
||||
/// any size: the image is scaled up on screen).
|
||||
pub fn fit_size(w: usize, h: usize) -> (usize, usize) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
if size_ok(w, h) {
|
||||
return (w, h);
|
||||
}
|
||||
let by_side = MAX_DIM as f64 / w.max(h) as f64;
|
||||
let by_area = (MAX_PIXELS as f64 / (w as f64 * h as f64)).sqrt();
|
||||
let k = by_side.min(by_area);
|
||||
let fit = |v: usize| ((v as f64 * k).floor() as usize).clamp(1, MAX_DIM);
|
||||
(fit(w), fit(h))
|
||||
}
|
||||
/// Largest half-width of a line or point, in supersampled pixels.
|
||||
const MAX_MARK: f64 = 64.0;
|
||||
/// Most samples drawn along one line segment (a segment from far off screen is bounded).
|
||||
const MAX_LINE_STEPS: usize = 20_000;
|
||||
|
||||
impl Canvas {
|
||||
/// A white canvas; the buffers are reserved fallibly, so a size the machine cannot hold is
|
||||
/// an error rather than an abort.
|
||||
fn new(w: usize, h: usize) -> Result<Canvas, RenderError> {
|
||||
let n = w.checked_mul(h).ok_or(RenderError::Size { w, h })?;
|
||||
let bytes = n.checked_mul(4).ok_or(RenderError::Size { w, h })?;
|
||||
let mut rgba = Vec::new();
|
||||
rgba.try_reserve_exact(bytes).map_err(|_| RenderError::OutOfMemory)?;
|
||||
rgba.resize(bytes, 255);
|
||||
let mut depth = Vec::new();
|
||||
depth.try_reserve_exact(n).map_err(|_| RenderError::OutOfMemory)?;
|
||||
depth.resize(n, f32::INFINITY);
|
||||
Ok(Canvas { w, h, rgba, depth })
|
||||
}
|
||||
fn put(&mut self, x: usize, y: usize, c: [f64; 3], a: f64) {
|
||||
let i = (y * self.w + x) * 4;
|
||||
if let Some(px) = self.rgba.get_mut(i..i + 3) {
|
||||
for (k, v) in px.iter_mut().enumerate() {
|
||||
let old = *v as f64 / 255.0;
|
||||
*v = ((old * (1.0 - a) + c[k] * a).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a scene with the camera into an RGBA canvas of `w × h` pixels. Sizes outside
|
||||
/// 1..=[`MAX_DIM`] per side or over [`MAX_PIXELS`], and scenes over [`MAX_PRIMITIVES`], are
|
||||
/// refused ([`fit_size`] gives an accepted size).
|
||||
pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Canvas, RenderError> {
|
||||
if !size_ok(w, h) {
|
||||
return Err(RenderError::Size { w, h });
|
||||
}
|
||||
let primitives = scene.tris.len().saturating_add(scene.lines.len()).saturating_add(scene.points.len());
|
||||
if primitives > MAX_PRIMITIVES {
|
||||
return Err(RenderError::SceneTooLarge { primitives });
|
||||
}
|
||||
let (sw, sh) = (w * SS, h * SS);
|
||||
let mut c = Canvas::new(sw, sh)?;
|
||||
let (top, bot) = scene.background.unwrap_or((Rgb(236, 240, 245), Rgb(205, 212, 222)));
|
||||
let (a, b) = (top.f(), bot.f());
|
||||
for y in 0..sh {
|
||||
let t = y as f64 / sh.max(1) as f64;
|
||||
let col = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
for x in 0..sw {
|
||||
c.put(x, y, col, 1.0);
|
||||
}
|
||||
}
|
||||
let vp = cam.view_proj(sw as f64 / sh as f64, scene.radius.max(1e-3));
|
||||
let project = |p: Vec3| -> Option<[f64; 3]> {
|
||||
let q = vp.apply(p);
|
||||
if q[3] <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]])
|
||||
};
|
||||
let view_dir = cam.back();
|
||||
for t in &scene.tris {
|
||||
let (Some(a), Some(b), Some(cc)) = (project(t.p[0]), project(t.p[1]), project(t.p[2])) else { continue };
|
||||
let col = shade(t.normal, view_dir, t.color.f());
|
||||
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
|
||||
if !(x0 <= x1 && y0 <= y1) {
|
||||
continue;
|
||||
}
|
||||
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
|
||||
if !(area.abs() >= 1e-12) {
|
||||
continue;
|
||||
}
|
||||
for y in y0 as usize..=y1 as usize {
|
||||
for x in x0 as usize..=x1 as usize {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let w0 = ((b[0] - px) * (cc[1] - py) - (b[1] - py) * (cc[0] - px)) / area;
|
||||
let w1 = ((cc[0] - px) * (a[1] - py) - (cc[1] - py) * (a[0] - px)) / area;
|
||||
let w2 = 1.0 - w0 - w1;
|
||||
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32;
|
||||
let i = y * sw + x;
|
||||
if let Some(d) = c.depth.get_mut(i)
|
||||
&& z < *d
|
||||
{
|
||||
*d = z;
|
||||
c.put(x, y, col, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &scene.lines {
|
||||
let (Some(a), Some(b)) = (project(l.a), project(l.b)) else { continue };
|
||||
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
|
||||
let steps = if len.is_finite() { (len.ceil() as usize).clamp(1, MAX_LINE_STEPS) } else { continue };
|
||||
let col = l.color.f();
|
||||
for k in 0..=steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
let p = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
dot(&mut c, p, l.width * SS as f64, col, true);
|
||||
}
|
||||
}
|
||||
for p in &scene.points {
|
||||
if let Some(q) = project(p.p) {
|
||||
dot(&mut c, q, p.size * SS as f64, p.color.f(), false);
|
||||
}
|
||||
}
|
||||
downsample(&c, w, h)
|
||||
}
|
||||
|
||||
/// A depth-tested disc (`round`) or square of diameter `size` at screen point `p` (x, y, depth).
|
||||
fn dot(c: &mut Canvas, p: [f64; 3], size: f64, col: [f64; 3], round: bool) {
|
||||
// Widths are capped so a hostile width cannot make the loop below unbounded.
|
||||
let half = if size.is_finite() { (size * 0.5).clamp(0.5, MAX_MARK) } else { 0.5 };
|
||||
let r = half.ceil() as i64;
|
||||
let (sw, sh) = (c.w as i64, c.h as i64);
|
||||
if !(p[0].is_finite() && p[1].is_finite()) || p[0] < -half || p[1] < -half || p[0] > sw as f64 + half || p[1] > sh as f64 + half {
|
||||
return;
|
||||
}
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (ix, iy) = (p[0].floor() as i64 + dx, p[1].floor() as i64 + dy);
|
||||
if ix < 0 || iy < 0 || ix >= sw || iy >= sh {
|
||||
continue;
|
||||
}
|
||||
let (ox, oy) = (ix as f64 + 0.5 - p[0], iy as f64 + 0.5 - p[1]);
|
||||
let d = if round { (ox * ox + oy * oy).sqrt() } else { ox.abs().max(oy.abs()) };
|
||||
let cov = (half + 0.5 - d).clamp(0.0, 1.0);
|
||||
let i = iy as usize * c.w + ix as usize;
|
||||
// Lines and points sit on faces: a small depth allowance keeps them in front.
|
||||
if cov > 0.0 && c.depth.get(i).is_some_and(|dd| (p[2] as f32) <= *dd + 2e-4) {
|
||||
c.put(ix as usize, iy as usize, col, cov);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn downsample(c: &Canvas, w: usize, h: usize) -> Result<Canvas, RenderError> {
|
||||
let mut out = Canvas::new(w, h)?;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0u32; 4];
|
||||
for sy in 0..SS {
|
||||
for sx in 0..SS {
|
||||
let i = ((y * SS + sy) * c.w + x * SS + sx) * 4;
|
||||
for (k, a) in acc.iter_mut().enumerate() {
|
||||
*a += c.rgba.get(i + k).copied().unwrap_or(0) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = (y * w + x) * 4;
|
||||
for (k, a) in acc.iter().enumerate() {
|
||||
if let Some(v) = out.rgba.get_mut(o + k) {
|
||||
*v = (*a / (SS * SS) as u32) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render to PNG bytes.
|
||||
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result<Vec<u8>, RenderError> {
|
||||
let c = render(scene, cam, w, h)?;
|
||||
png(c.w, c.h, &c.rgba).ok_or(RenderError::Encode)
|
||||
}
|
||||
|
||||
/// RGBA pixels (`w × h`, 8 bits per channel) to PNG bytes; `None` when the sizes disagree.
|
||||
pub fn png(w: usize, h: usize, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
if w.checked_mul(h)?.checked_mul(4)? != rgba.len() || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut buf, u32::try_from(w).ok()?, u32::try_from(h).ok()?);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = enc.write_header().ok()?;
|
||||
writer.write_image_data(rgba).ok()?;
|
||||
writer.finish().ok()?;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::StandardView;
|
||||
|
||||
fn red_triangle() -> Scene {
|
||||
Scene {
|
||||
tris: vec![SceneTri {
|
||||
p: [Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 0.0, -10.0), Vec3::new(0.0, 0.0, 10.0)],
|
||||
normal: Vec3::new(0.0, -1.0, 0.0),
|
||||
color: Rgb(200, 30, 30),
|
||||
}],
|
||||
radius: 20.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_triangle_lines_and_points() {
|
||||
let mut scene = red_triangle();
|
||||
let mut cam = Camera { distance: 50.0, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
let c = render(&scene, &cam, 64, 64).unwrap();
|
||||
let px = |c: &Canvas, x: usize, y: usize| c.rgba[(y * c.w + x) * 4..(y * c.w + x) * 4 + 4].to_vec();
|
||||
let centre = px(&c, 32, 32);
|
||||
assert!(centre[0] > centre[1] + 50, "centre pixel is red: {centre:?}");
|
||||
// A black line across the middle, in front of the triangle, and a point.
|
||||
scene.lines.push(SceneLine { a: Vec3::new(-10.0, -0.1, 0.0), b: Vec3::new(10.0, -0.1, 0.0), color: Rgb(0, 0, 0), width: 3.0 });
|
||||
scene.points.push(ScenePoint { p: Vec3::new(0.0, -0.1, 5.0), color: Rgb(0, 0, 255), size: 5.0 });
|
||||
let c = render(&scene, &cam, 64, 64).unwrap();
|
||||
assert!(px(&c, 32, 32)[0] < 40, "{:?}", px(&c, 32, 32));
|
||||
let (x, y, _) = cam.to_screen(Vec3::new(0.0, 0.0, 5.0), 64.0, 64.0, 20.0).unwrap();
|
||||
let p = px(&c, x as usize, y as usize);
|
||||
assert!(p[2] > 200 && p[0] < 60, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_bytes_and_hostile_sizes() {
|
||||
let scene = red_triangle();
|
||||
let cam = Camera::default();
|
||||
assert!(render_png(&scene, &cam, 16, 16).unwrap().starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
// Out-of-range sizes are refused before anything is allocated.
|
||||
for (w, h) in [(0, 16), (16, 0), (0, usize::MAX), (MAX_DIM + 1, 16), (100_000, 100_000), (usize::MAX, usize::MAX), (8192, 8192)] {
|
||||
assert_eq!(render(&scene, &cam, w, h).err(), Some(RenderError::Size { w, h }));
|
||||
}
|
||||
assert!(render(&scene, &cam, MAX_DIM, MAX_PIXELS / MAX_DIM).is_ok_and(|c| c.w == MAX_DIM));
|
||||
assert!(png(2, 2, &[0; 3]).is_none());
|
||||
assert!(png(usize::MAX, 2, &[]).is_none());
|
||||
// Far-away and non-finite geometry is skipped or bounded, never a panic.
|
||||
let wild = Scene {
|
||||
lines: vec![SceneLine { a: Vec3::new(-1e12, 0.0, 0.0), b: Vec3::new(1e12, 0.0, 0.0), color: Rgb(0, 0, 0), width: 1e6 }],
|
||||
points: vec![ScenePoint { p: Vec3::new(f64::NAN, 0.0, 0.0), color: Rgb(0, 0, 0), size: 4.0 }],
|
||||
radius: f64::NAN,
|
||||
..scene
|
||||
};
|
||||
let _ = render(&wild, &cam, 32, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_scenes_are_refused() {
|
||||
let line = SceneLine { a: Vec3::ZERO, b: Vec3::X, color: Rgb(0, 0, 0), width: 1.0 };
|
||||
let scene = Scene { lines: vec![line; MAX_PRIMITIVES + 1], radius: 1.0, ..Default::default() };
|
||||
let r = render_png(&scene, &Camera::default(), 16, 16);
|
||||
assert_eq!(r, Err(RenderError::SceneTooLarge { primitives: MAX_PRIMITIVES + 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_size_keeps_the_aspect_within_the_limits() {
|
||||
assert_eq!(fit_size(1280, 800), (1280, 800));
|
||||
assert_eq!(fit_size(0, 0), (1, 1));
|
||||
for (w, h) in [(7680, 4320), (100_000, 100_000), (usize::MAX, 3), (20_000, 10)] {
|
||||
let (fw, fh) = fit_size(w, h);
|
||||
assert!(size_ok(fw, fh), "{w}x{h} -> {fw}x{fh}");
|
||||
}
|
||||
let (fw, fh) = fit_size(7680, 4320);
|
||||
assert!(((fw as f64 / fh as f64) - 16.0 / 9.0).abs() < 1e-2, "{fw}x{fh}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! A small 3D vector and bounding box for view math.
|
||||
|
||||
use std::ops::{Add, Mul, Neg, Sub};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vec3 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
pub const ZERO: Vec3 = Vec3::new(0.0, 0.0, 0.0);
|
||||
pub const X: Vec3 = Vec3::new(1.0, 0.0, 0.0);
|
||||
pub const Y: Vec3 = Vec3::new(0.0, 1.0, 0.0);
|
||||
pub const Z: Vec3 = Vec3::new(0.0, 0.0, 1.0);
|
||||
|
||||
pub const fn new(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3 { x, y, z }
|
||||
}
|
||||
pub fn dot(self, o: Vec3) -> f64 {
|
||||
self.x * o.x + self.y * o.y + self.z * o.z
|
||||
}
|
||||
pub fn cross(self, o: Vec3) -> Vec3 {
|
||||
Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
|
||||
}
|
||||
pub fn len(self) -> f64 {
|
||||
self.dot(self).sqrt()
|
||||
}
|
||||
pub fn dist(self, o: Vec3) -> f64 {
|
||||
(self - o).len()
|
||||
}
|
||||
/// The unit vector, or `None` for a zero or non-finite vector.
|
||||
pub fn normalized(self) -> Option<Vec3> {
|
||||
let l = self.len();
|
||||
(l > 1e-300 && l.is_finite()).then(|| self * (1.0 / l))
|
||||
}
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
pub fn to_f32(self) -> [f32; 3] {
|
||||
[self.x as f32, self.y as f32, self.z as f32]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[f64; 3]> for Vec3 {
|
||||
fn from(a: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(a[0], a[1], a[2])
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn add(self, o: Vec3) -> Vec3 {
|
||||
Vec3::new(self.x + o.x, self.y + o.y, self.z + o.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn sub(self, o: Vec3) -> Vec3 {
|
||||
Vec3::new(self.x - o.x, self.y - o.y, self.z - o.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn mul(self, k: f64) -> Vec3 {
|
||||
Vec3::new(self.x * k, self.y * k, self.z * k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn neg(self) -> Vec3 {
|
||||
Vec3::new(-self.x, -self.y, -self.z)
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis-aligned bounding box; empty until a point is added.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Aabb3 {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Default for Aabb3 {
|
||||
fn default() -> Self {
|
||||
Aabb3::EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
impl Aabb3 {
|
||||
pub const EMPTY: Aabb3 = Aabb3 {
|
||||
min: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
|
||||
max: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
|
||||
};
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!(self.min.x <= self.max.x && self.min.y <= self.max.y && self.min.z <= self.max.z)
|
||||
}
|
||||
pub fn add(&mut self, p: Vec3) {
|
||||
if !p.is_finite() {
|
||||
return;
|
||||
}
|
||||
self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z));
|
||||
self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z));
|
||||
}
|
||||
pub fn center(&self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
pub fn diagonal(&self) -> f64 {
|
||||
if self.is_empty() { 0.0 } else { self.max.dist(self.min) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vector_algebra_and_boxes() {
|
||||
assert_eq!(Vec3::X.cross(Vec3::Y), Vec3::Z);
|
||||
assert_eq!(Vec3::new(3.0, 4.0, 0.0).len(), 5.0);
|
||||
assert!(Vec3::ZERO.normalized().is_none());
|
||||
assert!(Vec3::new(f64::NAN, 0.0, 0.0).normalized().is_none());
|
||||
let mut b = Aabb3::EMPTY;
|
||||
assert!(b.is_empty() && b.diagonal() == 0.0);
|
||||
b.add(Vec3::new(f64::NAN, 1.0, 1.0));
|
||||
assert!(b.is_empty());
|
||||
b.add(Vec3::ZERO);
|
||||
b.add(Vec3::new(2.0, 4.0, 4.0));
|
||||
assert_eq!(b.center(), Vec3::new(1.0, 2.0, 2.0));
|
||||
assert_eq!(b.diagonal(), 6.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "modelcraft-ui-egui"
|
||||
description = "ModelCraft desktop interface (egui + wgpu): viewport, model tree, commands, status bar and the control channel's request handling"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
modelcraft-model = { workspace = true }
|
||||
modelcraft-engine = { workspace = true }
|
||||
modelcraft-render = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms).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");
|
||||
}
|
||||
}
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -23,19 +23,21 @@ crates in strictly lower layers.
|
||||
| L1 | `modelcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | started |
|
||||
| L1 | `modelcraft-solver` | Element formulations, assembly, sparse linear static and modal solution. | planned |
|
||||
| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. | planned |
|
||||
| L1 | `modelcraft-render` | Display data (meshes, contour maps) without a GPU dependency. | planned |
|
||||
| L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots; contour maps later. | started |
|
||||
| L2 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
|
||||
| L3 | `modelcraft-io` | `.modelcraft` files, result files. | planned |
|
||||
| L4 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts. | started |
|
||||
| L5 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI, control channel and MCP server. | planned |
|
||||
| L5 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI (egui + wgpu viewport, model tree, commands, status bar) and control channel requests (docs/control-protocol.md); MCP server. | ui started |
|
||||
| testkit | `modelcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |
|
||||
| apps | `modelcraft`, `modelcraft-cli`, `modelcraft-web` | Desktop, command line, browser. | cli started |
|
||||
| apps | `modelcraft`, `modelcraft-cli`, `modelcraft-web` | Desktop, command line, browser. | desktop, cli started |
|
||||
|
||||
Rules beyond the layer order:
|
||||
- Nothing below L5 uses egui, eframe, winit, wgpu or rfd.
|
||||
- Only `modelcraft-geometry` uses `solidcraft-*`, pinned by rev in the workspace `Cargo.toml`.
|
||||
SolidCraft's `[patch.crates-io]` (its fixed truck crates) does not reach dependents, so the
|
||||
workspace repeats it from the same rev; bump both together.
|
||||
workspace repeats it from the same rev; bump both together. The same goes for SolidCraft's
|
||||
patched `egui-wgpu` (MSAA clamped to what the device supports, 1x on OpenGL adapters, so the
|
||||
desktop app's 4x never fails pipeline validation at start).
|
||||
- Everything below the apps builds for `wasm32-unknown-unknown`.
|
||||
- `modelcraft-geometry`'s non-default `scripts` feature builds designs from SolidCraft command
|
||||
scripts (`script_design`, through `solidcraft-engine`) for the test corpus; the library opens
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Control protocol
|
||||
|
||||
`modelcraft --control PORT` (or `MODELCRAFT_CONTROL_PORT=PORT`) listens on `127.0.0.1:PORT` for
|
||||
JSON lines. Each request is one line `{"id": …, "method": "…", "params": {…}}`; each reply is one
|
||||
line `{"id": …, "ok": true, "result": …}` or `{"id": …, "ok": false, "error": "…"}`. Requests are
|
||||
handled on the UI thread between frames; pointer and keyboard input is injected as real egui
|
||||
events, one pointer event per frame. The protocol has the same shape as SolidCraft's.
|
||||
|
||||
```text
|
||||
modelcraft --sample --control 7070
|
||||
{"id":1,"method":"engine.execute","params":{"command":"model.info","params":{}}}
|
||||
→ {"id":1,"ok":true,"result":{"elements":{"hex8":64,"tet4":162},"nodes":199}}
|
||||
{"id":2,"method":"ui.render","params":{"path":"/tmp/model.png","width":900,"height":600}}
|
||||
```
|
||||
|
||||
The channel is untrusted input. A request line may be at most 16 MiB; a longer one is answered
|
||||
with `{"ok": false, "error": "request too large"}` and the connection is closed, as are
|
||||
connections that send eight lines that are not JSON. At most 16 connections are served at once,
|
||||
at most 64 queued requests run per frame so a flood cannot starve drawing, and a request the UI
|
||||
thread has not answered within 60 seconds gets `{"ok": false, "error": "timeout"}`. Numbers are
|
||||
checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses an image size out of range.
|
||||
|
||||
## Engine
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`: command id, `params`: JSON object | the command's result |
|
||||
| `engine.script` | `commands`: `[{command, params}, …]` | stops at the first error; `journal`: commands that changed the model so far |
|
||||
| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation) |
|
||||
|
||||
Commands never open forms when run this way. `modelcraft-cli commands` prints the same registry.
|
||||
|
||||
## UI
|
||||
|
||||
| Method | Params | Notes |
|
||||
|---|---|---|
|
||||
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, version |
|
||||
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `perspective`, `dark`, `show_tree`, `show_commands` | an unknown setting or value is an error; returns the settings |
|
||||
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera |
|
||||
| `ui.start` | `command` | like clicking the command in the Commands panel: a command without parameters runs, one with parameters opens its form |
|
||||
| `ui.dialog` | `params?` (JSON object, fills the form), `run?`, `cancel?` | drives the open command form; `run` returns the command's result and closes the form on success |
|
||||
| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input |
|
||||
| `ui.move` | `x`, `y` | |
|
||||
| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits, a right or middle drag (or Shift + left) pans |
|
||||
| `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms at the point |
|
||||
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | `F` over the viewport frames the model |
|
||||
| `ui.text` | `text` | typed text (into the focused field) |
|
||||
| `ui.worldToScreen` | `points`: `[[x, y, z]…]` (at most 1000) | screen points in the viewport drawn last frame (null when not on screen) |
|
||||
| `ui.resize` | `width`, `height` | window inner size in points |
|
||||
| `ui.screenshot` | `path?` | PNG of the window, taken a few frames later; needs a presented frame (a hidden window or a locked screen answers with an error: use `ui.render`). Without `path`: `modelcraft-shot.png` in the temporary folder |
|
||||
| `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all | CPU render of the model with the current camera and view settings (no window or GPU needed). A size out of range, or a scene over 20 million primitives, is an error |
|
||||
| `app.quit` | — | closes the window |
|
||||
Reference in new issue
Block a user