forked from 1b-agent/fieldcraft-upstream
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:
29 files changed
+2405
-49
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user