MCP server: modelcraft-cli mcp (headless or bridged), read/write roots, end-to-end analysis over MCP (#48)

* io: read and write roots that confine an automation client's file access

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

* engine: every file a command reads or writes is checked against the session's roots

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

* engine: headless pictures of a session (model, geometry, deformed shape and contours)

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

* mcp: MCP server over stdio with headless and connected backends, roots and caps

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

* cli: modelcraft-cli mcp [--connect ADDR | --headless] with automation read and write roots

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

* mcp: end-to-end analysis of a corpus part over MCP (open, tet mesh, define, solve, probe, render)

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

* docs: MCP server (docs/mcp.md), Claude Code setup, roots; architecture, README, roadmap, attribution

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:18:08 -04:00
1 parent e900f41ce3
commit 0899587127
29 files changed
+2405 -49

No files matched your search

+2 -1
View File
@@ -515,7 +515,8 @@ pub fn features_page() -> String {
Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by\n\
`cargo run -p modelcraft-cli -- features > docs/features.md`; a test fails when it is out of date.\n\
Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are\n\
shown greyed with the roadmap milestone that brings them (ROADMAP.md).\n\n",
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,\n\
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.\n\n",
);
let _ = writeln!(md, "{ready} ready, {} coming soon.", CATALOGUE.len() - ready);
for tab in Tab::ALL {
+3 -2
View File
@@ -2,6 +2,7 @@
use std::path::Path;
use modelcraft_io::roots::Access;
use serde_json::{Value, json};
use super::req;
@@ -22,10 +23,10 @@ fn write(s: &mut Session, p: &Value) -> Result<Value> {
let known: Vec<&str> = modelcraft_decks::FORMATS.iter().map(|f| f.name).collect();
EngineError::Params(format!("unknown deck format `{shown}`; known: {}", known.join(", ")))
})?;
let path = super::path_param(p, "path")?;
let path = super::file_param(s, p, "path", Access::Write)?;
let steps: Option<Vec<String>> = super::opt(p, "steps")?;
let deck = format.write_steps(&s.model, steps.as_deref())?;
modelcraft_io::fs::write_atomic(Path::new(path), deck.text.as_bytes(), false)?;
modelcraft_io::fs::write_atomic(Path::new(&path), deck.text.as_bytes(), false)?;
Ok(json!({"format": format.name, "path": path, "bytes": deck.text.len(), "warnings": deck.warnings}))
}
+84 -22
View File
@@ -14,7 +14,9 @@ use modelcraft_geometry::{Design, MAX_DESIGN_BYTES, open_design};
use modelcraft_io::{Geometry, ModelFile, SourceFile};
use serde_json::{Value, json};
use super::{path_param, req};
use modelcraft_io::roots::{Access, Roots};
use super::{file_param, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision};
pub const OPEN: CommandSpec = CommandSpec {
@@ -110,18 +112,27 @@ fn resolved_path(path: &str, base: Option<&Path>) -> String {
}
/// Open one geometry file again: what it gives, and the source as now read (or as stored, with
/// its path resolved, when it could not be).
/// its path resolved, when it could not be). A file outside the read root of `roots` is not
/// opened.
fn reopen<T>(
what: &str,
stored: &SourceFile,
base: Option<&Path>,
roots: &Roots,
max: usize,
open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result<T>,
warnings: &mut Vec<String>,
) -> (Option<T>, SourceFile) {
let path = resolved_path(stored.path(), base);
let kept = stored.with_path(path.clone()).unwrap_or_else(|_| stored.clone());
let bytes = match modelcraft_geometry::read_file(&path, max) {
let checked = match roots.check(Path::new(&path), Access::Read) {
Ok(p) => p.to_string_lossy().into_owned(),
Err(e) => {
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
return (None, kept);
}
};
let bytes = match modelcraft_geometry::read_file(&checked, max) {
Ok(b) => b,
Err(e) => {
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
@@ -155,19 +166,20 @@ impl Session {
}
/// A session holding what a file holds, its geometry opened again from the paths it names
/// (relative ones against `base`); problems with the geometry come back as warnings.
pub fn from_file(file: ModelFile, base: Option<&Path>) -> (Session, Vec<String>) {
/// (relative ones against `base`) where `roots` lets it read them; problems with the
/// geometry come back as warnings. The session's own file access is unconfined.
pub fn from_file(file: ModelFile, base: Option<&Path>, roots: &Roots) -> (Session, Vec<String>) {
let mut warnings = Vec::new();
let mut sources = Geometry::default();
let mut design: Option<Arc<Design>> = None;
if let Some(stored) = &file.geometry.design {
let (opened, source) = reopen("design", stored, base, MAX_DESIGN_BYTES, open_design, &mut warnings);
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES, open_design, &mut warnings);
design = opened.map(Arc::new);
sources.design = Some(source);
}
let mut surfaces = Vec::new();
for stored in &file.geometry.meshes {
let (opened, source) = reopen("mesh", stored, base, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
sources.meshes.push(source);
}
@@ -176,11 +188,12 @@ impl Session {
(session, warnings)
}
/// Replace this session with `opened`, keeping its undo memory limit and its work caps; the
/// history starts empty.
/// Replace this session with `opened`, keeping its undo memory limit, its work caps and its
/// file access; the history starts empty.
fn replace_with(&mut self, mut opened: Session) {
opened.history = std::mem::take(&mut self.history);
opened.limits = self.limits;
opened.file_access = std::mem::take(&mut self.file_access);
opened.clear_history();
*self = opened;
}
@@ -202,7 +215,7 @@ fn summary(s: &Session, warnings: &[String]) -> Value {
/// paths are relative to its folder), `saved` whether that file holds this content.
fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Result<Value> {
let file = modelcraft_io::read(bytes)?;
let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()));
let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()), &s.file_access);
opened.path = path;
if saved {
opened.saved_revision = Some(opened.revision);
@@ -212,7 +225,7 @@ fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Res
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = absolute(path_param(p, "path")?);
let path = absolute(&file_param(s, p, "path", Access::Read)?);
let max = usize::try_from(modelcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX);
let bytes = modelcraft_geometry::read_file(&path, max)?;
load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}")))
@@ -232,41 +245,45 @@ fn save_to(s: &mut Session, path: &str) -> Result<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match p.get("path") {
Some(_) => path_param(p, "path")?.to_string(),
None => s.path.clone().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?,
Some(_) => file_param(s, p, "path", Access::Write)?,
None => {
let own = s.path.as_deref().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?;
s.checked_path(own, Access::Write)?
}
};
save_to(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_param(p, "path")?.to_string();
let path = file_param(s, p, "path", Access::Write)?;
save_to(s, &path)
}
/// The recovery folder: `dir`, else the default one.
fn recovery_dir(p: &Value) -> Result<PathBuf> {
/// The recovery folder: `dir` (checked for `access`), else the default one (the app's own folder,
/// which no client names, so it is not confined).
fn recovery_dir(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
match p.get("dir") {
Some(_) => Ok(PathBuf::from(path_param(p, "dir")?)),
Some(_) => Ok(PathBuf::from(file_param(s, p, "dir", access)?)),
None => {
crate::autosave::default_dir().ok_or_else(|| EngineError::Params("no recovery folder: set MODELCRAFT_RECOVERY_DIR or give `dir`".into()))
}
}
}
fn recovery_list(_: &mut Session, p: &Value) -> Result<Value> {
let entries = modelcraft_io::recovery::orphans(&recovery_dir(p)?);
fn recovery_list(s: &mut Session, p: &Value) -> Result<Value> {
let entries = modelcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?);
serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string()))
}
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(p)?;
let dir = recovery_dir(s, p, Access::Read)?;
let (bytes, entry) = modelcraft_io::recovery::load(&dir, req::<&str>(p, "id")?)?;
// Recovered work is unsaved until it is saved. Autosaves keep geometry paths absolute.
load(s, &bytes, entry.path, false)
}
fn recovery_discard(_: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(p)?;
fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(s, p, Access::Write)?;
if p.get("all").and_then(Value::as_bool) == Some(true) {
let ids: Vec<String> = modelcraft_io::recovery::orphans(&dir).into_iter().map(|e| e.id).collect();
for id in &ids {
@@ -349,6 +366,51 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_confined_session_reads_and_writes_only_inside_its_roots() {
let d = dir("roots");
let (input, output, outside) = (d.join("in"), d.join("out"), d.join("outside"));
for f in [&input, &output, &outside] {
std::fs::create_dir_all(f).unwrap();
}
let design = plate_design(&Plate::default()).unwrap();
std::fs::write(input.join("plate.solidcraft"), &design).unwrap();
std::fs::write(outside.join("plate.solidcraft"), &design).unwrap();
let mut s = Session { file_access: Roots::new(Some(&input), Some(&output)).unwrap(), ..Session::default() };
let refused = |r: Result<Value>| {
let e = r.unwrap_err().to_string();
assert!(e.starts_with("automation"), "{e}");
};
refused(s.execute("geometry.open", &json!({"path": outside.join("plate.solidcraft")})));
refused(s.execute("geometry.import.mesh", &json!({"path": outside.join("x.3mf")})));
refused(s.execute("file.open", &json!({"path": outside.join("x.modelcraft")})));
refused(s.execute("file.recovery.list", &json!({"dir": outside})));
refused(s.execute("file.recovery.discard", &json!({"all": true, "dir": input})));
s.execute("geometry.open", &json!({"path": input.join("plate.solidcraft")})).unwrap();
defined(&mut s);
// Writes go to the write root only: not the read root, not elsewhere.
refused(s.execute("file.save_as", &json!({"path": input.join("m.modelcraft")})));
refused(s.execute("file.save_as", &json!({"path": outside.join("m.modelcraft")})));
refused(s.execute("file.save_as", &json!({"path": output.join("../outside/m.modelcraft")})));
refused(s.execute("deck.write", &json!({"format": "abaqus", "path": outside.join("m.inp")})));
let saved = output.join("m.modelcraft");
s.execute("file.save_as", &json!({"path": saved})).unwrap();
assert!(saved.is_file());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "nothing written outside");
// The roots survive opening a file; a model whose design lies outside the read root
// opens without it.
let mut other = Session::default();
other.execute("geometry.open", &json!({"path": outside.join("plate.solidcraft")})).unwrap();
other.execute("file.save_as", &json!({"path": input.join("other.modelcraft")})).unwrap();
let r = s.execute("file.open", &json!({"path": input.join("other.modelcraft")})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("automation path rejected"), "{r}");
assert!(s.design.is_none());
assert!(s.file_access.is_confined());
refused(s.execute("file.save", &json!({})));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn hostile_file_params_and_files_change_nothing() {
let d = dir("hostile");
+7 -6
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use modelcraft_geometry::tagged_mesh;
use modelcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
use modelcraft_io::SourceFile;
use modelcraft_io::roots::Access;
use serde_json::{Value, json};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
@@ -40,9 +41,9 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
};
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = super::path_param(p, "path")?;
let bytes = modelcraft_geometry::read_file(path, MAX_DESIGN_BYTES)?;
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
let path = super::file_param(s, p, "path", Access::Read)?;
let bytes = modelcraft_geometry::read_file(&path, MAX_DESIGN_BYTES)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let design = open_design(&bytes)?;
let out = json!({
"name": design.name,
@@ -96,12 +97,12 @@ fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
}
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let path = super::path_param(p, "path")?;
let path = super::file_param(s, p, "path", Access::Read)?;
if s.sources.meshes.len() >= modelcraft_io::MAX_MESH_SOURCES {
return Err(EngineError::Params(format!("a model imports at most {} meshes", modelcraft_io::MAX_MESH_SOURCES)));
}
let bytes = modelcraft_geometry::read_file(path, tagged_mesh::MAX_FILE_BYTES)?;
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
let bytes = modelcraft_geometry::read_file(&path, tagged_mesh::MAX_FILE_BYTES)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let surfaces = tagged_mesh::read_3mf(&bytes)?;
let first = s.surfaces.len();
let added: Vec<Value> = surfaces
+13 -3
View File
@@ -16,7 +16,9 @@ use modelcraft_model::MAX_LABEL;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{CommandSpec, EngineError, Result};
use modelcraft_io::roots::Access;
use crate::{CommandSpec, EngineError, Result, Session};
pub static ALL: &[CommandSpec] = &[
model::INFO,
@@ -158,14 +160,22 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
}
/// A required path parameter.
pub(crate) fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
/// A required path parameter, as given.
fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
p.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty() && s.len() <= modelcraft_io::MAX_PATH_LEN && !s.contains('\0'))
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path")))
}
/// A required path parameter naming a file (or folder) the command reads or writes: the path
/// to use, checked against the session's [`Session::file_access`] (as given while unconfined;
/// resolved, links followed, when confined). Every command that touches a file named by a
/// parameter takes its path from here.
pub(crate) fn file_param(s: &Session, p: &Value, key: &str, access: Access) -> Result<String> {
s.checked_path(path_param(p, key)?, access)
}
/// An optional number parameter.
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
match p.get(key) {
+15 -1
View File
@@ -18,6 +18,7 @@ pub mod cmd;
pub mod history;
pub mod results;
pub mod sample;
pub mod view;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -174,13 +175,17 @@ pub struct Session {
pub results_view: Option<results::ResultsView>,
/// The default work caps of commands; a file replacing the session keeps them.
pub limits: Limits,
/// The folders commands may read and write files in (an automation client's roots,
/// docs/mcp.md); unconfined by default. A file replacing the session keeps them.
pub file_access: modelcraft_io::roots::Roots,
}
impl Session {
/// Run a command. On an error or a panic the model, the design and the surfaces are left as
/// they were.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
// An id from hostile input is not echoed in full.
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?;
// Session commands (open, save, undo, redo) change the session only in their last step,
// after everything that can fail, so they need no copy of the model to restore.
let before = (spec.kind == CommandKind::Model).then(|| self.snapshot());
@@ -255,6 +260,15 @@ impl Session {
self.results.insert(result.step().to_string(), (self.revision, Arc::new(result)));
}
/// The path to use for `access` to the file at `path`, checked against [`Session::file_access`].
pub fn checked_path(&self, path: &str, access: modelcraft_io::roots::Access) -> Result<String> {
let checked = self.file_access.check(std::path::Path::new(path), access)?;
match checked.to_str() {
Some(p) => Ok(p.to_string()),
None => Err(EngineError::Params(format!("`{path}` resolves to a path that is not valid UTF-8"))),
}
}
/// The current state is what `path` now holds.
pub(crate) fn mark_saved(&mut self, path: String) {
self.path = Some(path);
+211
View File
@@ -0,0 +1,211 @@
//! Headless pictures of a session: the model, its geometry and the results display drawn with
//! the CPU rasterizer of [`modelcraft_render`], with no window or GPU (the MCP server's
//! screenshots without an app, docs/mcp.md).
//!
//! The picture follows the desktop viewport's conventions: geometry draws as faces with their
//! boundaries while the model has no element faces, then as boundaries only; elements in their
//! kind's colour with their edges; a results view ([`crate::results::ResultsView`]) draws the
//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a
//! picture shows the full deformation (a mode at its positive shape).
use modelcraft_model::ElementKind;
use modelcraft_render::contour::{Bands, band_color, split_triangle};
use modelcraft_render::{Aabb3, Camera, DisplayMesh, Rgb, Scene, SceneLine, SceneTri, StandardView, Vec3};
use crate::results::{displacements, field_values, magnitude, range};
use crate::{EngineError, Result, Session};
/// The colours of a picture.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
/// Background gradient, top to bottom.
pub background: (Rgb, Rgb),
pub hex: Rgb,
pub tet: Rgb,
pub wedge: Rgb,
pub shell: Rgb,
pub line_element: Rgb,
pub mesh_edge: Rgb,
pub geometry: Rgb,
pub geometry_edge: Rgb,
/// Faces of nodes without a value in a contour plot.
pub no_result: Rgb,
/// The contour colour scale, low to high.
pub contour: [Rgb; 5],
}
impl Palette {
/// The desktop app's light theme.
pub const LIGHT: Palette = Palette {
background: (Rgb(0xf0, 0xf3, 0xf7), Rgb(0xc8, 0xd0, 0xdc)),
hex: Rgb(0x78, 0x9c, 0xc0),
tet: Rgb(0x80, 0xb4, 0x8a),
wedge: Rgb(0xc0, 0xaa, 0x78),
shell: Rgb(0xc0, 0x8c, 0x78),
line_element: Rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
geometry: Rgb(0xb4, 0xba, 0xc4),
geometry_edge: Rgb(0x2a, 0x30, 0x3a),
no_result: Rgb(0x9a, 0xa0, 0xaa),
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
};
fn element(&self, kind: ElementKind) -> Rgb {
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::Rod3 | ElementKind::Beam2 => self.line_element,
}
}
}
/// Mesh edge, geometry edge and line element widths, in output pixels.
const EDGE_WIDTH: f64 = 1.0;
const GEOMETRY_EDGE_WIDTH: f64 = 1.5;
const LINE_ELEMENT_WIDTH: f64 = 3.0;
/// The automatic scale draws the largest displacement this fraction of the model's size.
const AUTO_FRACTION: f64 = 0.1;
/// The chordal tolerance geometry is tessellated with for display: a thousandth of its size.
fn display_tolerance(b: &Aabb3) -> f64 {
let d = b.diagonal();
if d.is_finite() { (d * 1e-3).clamp(1e-3, 1.0) } else { 0.1 }
}
/// What a picture shows besides the image.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shown {
/// The solved step drawn, when the results display is on.
pub step: Option<String>,
/// The deformation scale factor drawn (0: undeformed).
pub scale: f64,
/// The contour field drawn, with its smallest and largest value and their nodes.
pub contour: Option<(String, (u32, f64), (u32, f64))>,
/// Bodies that could not be tessellated for display.
pub failed: Vec<String>,
}
/// The model's display data with the open design's bodies and the imported surfaces.
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
let mut d = DisplayMesh::from_model(&s.model);
let mut failed = Vec::new();
for body in s.design.iter().flat_map(|d| &d.bodies) {
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh {
Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face),
Err(e) => failed.push(format!("{}: {e}", body.name)),
}
}
for surface in &s.surfaces {
d.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face);
}
(d, failed)
}
/// The model drawn as the viewport draws it (shaded with element edges).
fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
let mut s = Scene { background: Some(p.background), ..Default::default() };
s.tris = d.tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.element(t.kind) }).collect();
let faces_drawn = d.tris.is_empty();
if faces_drawn {
s.tris.extend(d.surface_tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.geometry }));
}
s.lines = d.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color: p.mesh_edge, width: EDGE_WIDTH }).collect();
let edge = if faces_drawn { p.mesh_edge } else { p.geometry_edge };
s.lines.extend(d.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color: edge, width: GEOMETRY_EDGE_WIDTH }));
s.lines.extend(d.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: p.element(l.kind), width: LINE_ELEMENT_WIDTH }));
s
}
/// The scene of the session: the model, or the results view's plot, and what it shows.
pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
let (base, failed) = display(s);
let mut shown = Shown { failed, ..Shown::default() };
let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r)));
let Some((view, result)) = plot else {
let bounds = base.bounds;
return (model_scene(&base, p), bounds, shown);
};
shown.step = Some(view.step.clone());
let pick = view.pick();
let nodes = result.nodes();
let u = displacements(result, pick).unwrap_or(&[]);
let scale = if !view.deformed {
0.0
} else if let Some(k) = view.scale {
k
} else {
let largest = u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max);
let size = base.bounds.diagonal();
if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 }
};
shown.scale = scale;
let drawn = base.displaced(|label| {
let i = nodes.binary_search(&label).ok()?;
u.get(i).map(|d| Vec3::from(*d) * scale)
});
let mut scene = model_scene(&drawn, p);
let contour = view.contour.and_then(|field| {
let values = field_values(&s.model, result, field, pick).ok()?;
let (min, max) = range(nodes, &values)?;
Some((field, min, max, values))
});
if let Some((field, min, max, values)) = contour {
shown.contour = Some((field.id().to_string(), min, max));
let bands = Bands::new(min.1, max.1, view.bands);
let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN);
scene.tris.clear();
for tri in &drawn.tris {
split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| {
let color = band.map_or(p.no_result, |k| band_color(&p.contour, k, bands.count));
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color });
}
}
});
}
}
let mut bounds = base.bounds;
bounds.add(drawn.bounds.min);
bounds.add(drawn.bounds.max);
(scene, bounds, shown)
}
/// A PNG of the session seen from `view`, framing everything drawn, `w` × `h` pixels.
pub fn render_png(s: &Session, view: StandardView, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
let (mut scene, bounds, shown) = scene(s, p);
let mut cam = Camera::default();
cam.set_view(view);
cam.fit(&bounds);
scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
let png = modelcraft_render::render_png(&scene, &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, shown))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn renders_the_model_and_its_results() {
let mut s = Session::default();
let empty = render_png(&s, StandardView::Iso, 32, 24, &Palette::LIGHT).unwrap();
assert!(empty.0.starts_with(&[0x89, b'P', b'N', b'G']));
s.run_script(&crate::sample::cantilever()).unwrap();
let (model, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap();
assert_eq!(shown, Shown::default());
s.execute("solve.static", &json!({})).unwrap();
let (plot, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap();
assert_ne!(model, plot);
assert!(shown.scale > 0.0 && shown.step.is_some(), "{shown:?}");
let (field, min, max) = shown.contour.unwrap();
assert_eq!(field, "von_mises");
assert!(max.1 > min.1);
assert!(render_png(&s, StandardView::Top, 0, 48, &Palette::LIGHT).is_err());
}
}