forked from 1b-agent/fieldcraft-upstream
cli, mcp: snapshot --section and --field; headless MCP screenshots on the GPU
--section x|y|z[=VALUE] hides the elements on the camera's side of a plane (at the mesh's middle by default) so the element faces inside show; --field contours a nodal field of the solved step (results.contour). The MCP server's headless screenshot draws with the viewport's pipeline on the shared device and its reply, like snapshot's, names the renderer. Refs #422 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
c9163e98ad
commit
390ddcd6cc
10 files changed
+208
-73
No files matched your search
Generated
+1
@@ -1409,6 +1409,7 @@ name = "fieldcraft-mcp"
|
|||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fieldcraft-engine",
|
"fieldcraft-engine",
|
||||||
|
"fieldcraft-gpu",
|
||||||
"fieldcraft-io",
|
"fieldcraft-io",
|
||||||
"fieldcraft-render",
|
"fieldcraft-render",
|
||||||
"fieldcraft-testkit",
|
"fieldcraft-testkit",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! fieldcraft-cli commands
|
//! fieldcraft-cli commands
|
||||||
//! fieldcraft-cli run <script.json>
|
//! fieldcraft-cli run <script.json>
|
||||||
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view iso|x|y|z|front|…] [--size WxH]
|
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V] [--size WxH] [--section x|y|z[=V]] [--field F]
|
||||||
//! fieldcraft-cli features
|
//! fieldcraft-cli features
|
||||||
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||||
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||||
@@ -26,11 +26,15 @@ usage:
|
|||||||
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
|
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
|
||||||
and print the model summary
|
and print the model summary
|
||||||
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V] [--size WxH]
|
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V] [--size WxH]
|
||||||
|
[--section x|y|z[=VALUE]] [--field FIELD]
|
||||||
run a script and draw the model to a PNG with no
|
run a script and draw the model to a PNG with no
|
||||||
window: the viewport's GPU pipeline on a hardware or
|
window: the viewport's GPU pipeline on a hardware or
|
||||||
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
|
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
|
||||||
auto|hardware|software|cpu); V: iso (default), x, y, z,
|
auto|hardware|software|cpu); V: iso (default), x, y, z,
|
||||||
front, back, left, right, top, bottom; size 1280x800
|
front, back, left, right, top, bottom; size 1280x800;
|
||||||
|
--section hides the elements beyond a plane (at the
|
||||||
|
mesh's middle, or VALUE); --field contours a nodal
|
||||||
|
field of the solved step (results.contour)
|
||||||
fieldcraft-cli features print docs/features.md (every capability, ready or
|
fieldcraft-cli features print docs/features.md (every capability, ready or
|
||||||
coming soon) from the capability catalogue
|
coming soon) from the capability catalogue
|
||||||
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
|
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
|
||||||
@@ -144,6 +148,12 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
|||||||
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
|
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The JSON report of a picture: what drew it and what it shows (the MCP `screenshot` reply's
|
||||||
|
/// fields).
|
||||||
|
pub fn picture_report(r: &fieldcraft_gpu::Renderer, shown: &fieldcraft_engine::view::Shown) -> serde_json::Value {
|
||||||
|
fieldcraft_mcp::picture_report(r, shown)
|
||||||
|
}
|
||||||
|
|
||||||
/// The session after running the script at `path`.
|
/// The session after running the script at `path`.
|
||||||
fn session_of(path: &str) -> Result<Session, String> {
|
fn session_of(path: &str) -> Result<Session, String> {
|
||||||
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||||
|
|||||||
@@ -4,20 +4,25 @@
|
|||||||
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
|
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
|
||||||
//! [`fieldcraft_gpu::select`]).
|
//! [`fieldcraft_gpu::select`]).
|
||||||
|
|
||||||
use fieldcraft_engine::view::{self, Palette};
|
use fieldcraft_engine::view::{self, Palette, Section};
|
||||||
use fieldcraft_gpu::capture::Capturer;
|
use fieldcraft_gpu::Renderer;
|
||||||
|
use fieldcraft_gpu::capture::{self, Capturer};
|
||||||
use fieldcraft_gpu::select::Policy;
|
use fieldcraft_gpu::select::Policy;
|
||||||
use fieldcraft_render::{Orientation, StandardView};
|
use fieldcraft_render::{Orientation, StandardView};
|
||||||
|
|
||||||
/// The picture size when `--size` is not given.
|
/// The picture size when `--size` is not given.
|
||||||
const DEFAULT_SIZE: (u32, u32) = (1280, 800);
|
const DEFAULT_SIZE: (u32, u32) = (1280, 800);
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Options {
|
pub struct Options {
|
||||||
pub script: String,
|
pub script: String,
|
||||||
pub out: String,
|
pub out: String,
|
||||||
pub view: StandardView,
|
pub view: StandardView,
|
||||||
pub size: (u32, u32),
|
pub size: (u32, u32),
|
||||||
|
/// A section through the mesh (`--section x|y|z[=VALUE]`).
|
||||||
|
pub section: Option<Section>,
|
||||||
|
/// A nodal field contoured on the last solved step (`--field von_mises`).
|
||||||
|
pub field: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A view name: a standard view, or the axis looked along (`x`: from the right, `y`: from the
|
/// A view name: a standard view, or the axis looked along (`x`: from the right, `y`: from the
|
||||||
@@ -41,7 +46,7 @@ fn size_of(s: &str) -> Option<(u32, u32)> {
|
|||||||
|
|
||||||
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
|
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
|
||||||
pub fn options(args: &[String]) -> Result<Options, String> {
|
pub fn options(args: &[String]) -> Result<Options, String> {
|
||||||
let (mut script, mut out, mut view, mut size) = (None, None, None, None);
|
let (mut script, mut out, mut view, mut size, mut section, mut field) = (None, None, None, None, None, None);
|
||||||
let mut it = args.iter();
|
let mut it = args.iter();
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
if !arg.starts_with("--") {
|
if !arg.starts_with("--") {
|
||||||
@@ -71,6 +76,10 @@ pub fn options(args: &[String]) -> Result<Options, String> {
|
|||||||
)
|
)
|
||||||
})?)
|
})?)
|
||||||
.is_some(),
|
.is_some(),
|
||||||
|
"--section" => section
|
||||||
|
.replace(Section::parse(&value).ok_or_else(|| format!("--section: `{value}` is not x, y, z or AXIS=VALUE (x=12.5)"))?)
|
||||||
|
.is_some(),
|
||||||
|
"--field" => field.replace(value).is_some(),
|
||||||
other => return Err(format!("snapshot: unknown option `{other}`")),
|
other => return Err(format!("snapshot: unknown option `{other}`")),
|
||||||
};
|
};
|
||||||
if twice {
|
if twice {
|
||||||
@@ -82,50 +91,36 @@ pub fn options(args: &[String]) -> Result<Options, String> {
|
|||||||
out: out.ok_or("snapshot needs --out FILE.png")?,
|
out: out.ok_or("snapshot needs --out FILE.png")?,
|
||||||
view: view.unwrap_or(StandardView::Iso),
|
view: view.unwrap_or(StandardView::Iso),
|
||||||
size: size.unwrap_or(DEFAULT_SIZE),
|
size: size.unwrap_or(DEFAULT_SIZE),
|
||||||
|
section,
|
||||||
|
field,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What drew a picture.
|
/// The PNG of the session `o` describes, drawn as `policy` says ([`capture::png`]).
|
||||||
#[derive(Debug, PartialEq)]
|
pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Renderer, view::Shown), String> {
|
||||||
pub enum Drawn {
|
let mut session = crate::session_of(&o.script)?;
|
||||||
Gpu { adapter: String, tier: &'static str, samples: u32 },
|
if let Some(field) = &o.field {
|
||||||
Cpu { why: Option<String> },
|
session.execute("results.contour", &serde_json::json!({"field": field})).map_err(|e| format!("--field: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The PNG of the session `o` describes, drawn as `policy` says. Under [`Policy::Auto`] a GPU
|
|
||||||
/// that cannot be opened or fails to draw falls back to the CPU rasterizer (the reason is
|
|
||||||
/// returned); a strict policy returns the error.
|
|
||||||
pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Drawn), String> {
|
|
||||||
let session = crate::session_of(&o.script)?;
|
|
||||||
let (w, h) = o.size;
|
let (w, h) = o.size;
|
||||||
let f = view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT);
|
let f = view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section);
|
||||||
let gpu = Capturer::open(policy).and_then(|c| match c {
|
let capturer = Capturer::open(policy);
|
||||||
Some(c) => c.rgba(&f.scene, &[&f.symbols], &f.cam, w, h).map(|px| Some((px, c))),
|
let (png, renderer) = capture::png(&capturer, policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())?;
|
||||||
None => Ok(None),
|
Ok((png, renderer, f.shown))
|
||||||
});
|
|
||||||
let why = match gpu {
|
|
||||||
Ok(Some((px, c))) => {
|
|
||||||
let png = fieldcraft_render::png(w as usize, h as usize, &px).ok_or("the picture could not be encoded")?;
|
|
||||||
return Ok((png, Drawn::Gpu { adapter: c.adapter.clone(), tier: c.tier.label(), samples: c.samples() }));
|
|
||||||
}
|
|
||||||
Ok(None) => (policy == Policy::Auto).then(|| "no GPU adapter".to_string()),
|
|
||||||
Err(e) if policy == Policy::Auto => Some(e.to_string()),
|
|
||||||
Err(e) => return Err(e.to_string()),
|
|
||||||
};
|
|
||||||
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w as usize, h as usize).map_err(|e| format!("render: {e}"))?;
|
|
||||||
Ok((png, Drawn::Cpu { why }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(args: &[String]) -> Result<(), String> {
|
pub fn run(args: &[String]) -> Result<(), String> {
|
||||||
let o = options(args)?;
|
let o = options(args)?;
|
||||||
let policy = Policy::from_env().map_err(|e| e.to_string())?;
|
let policy = Policy::from_env().map_err(|e| e.to_string())?;
|
||||||
let (png, drawn) = picture(&o, policy)?;
|
let (png, renderer, shown) = picture(&o, policy)?;
|
||||||
std::fs::write(&o.out, &png).map_err(|e| format!("{}: {e}", o.out))?;
|
std::fs::write(&o.out, &png).map_err(|e| format!("{}: {e}", o.out))?;
|
||||||
let renderer = match &drawn {
|
let mut report = crate::picture_report(&renderer, &shown);
|
||||||
Drawn::Gpu { adapter, tier, samples } => serde_json::json!({"kind": tier, "adapter": adapter, "msaa": samples}),
|
if let Some(r) = report.as_object_mut() {
|
||||||
Drawn::Cpu { why } => serde_json::json!({"kind": "CPU rasterizer", "why": why}),
|
r.insert("out".into(), o.out.clone().into());
|
||||||
};
|
r.insert("width".into(), o.size.0.into());
|
||||||
let report = serde_json::json!({"out": o.out, "width": o.size.0, "height": o.size.1, "view": o.view.label(), "renderer": renderer});
|
r.insert("height".into(), o.size.1.into());
|
||||||
|
r.insert("view".into(), o.view.label().into());
|
||||||
|
}
|
||||||
println!("{report}");
|
println!("{report}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -141,7 +136,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn options_are_checked() {
|
fn options_are_checked() {
|
||||||
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
|
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
|
||||||
assert_eq!(o, Options { script: "s.json".into(), out: "a.png".into(), view: StandardView::Iso, size: DEFAULT_SIZE });
|
assert_eq!(
|
||||||
|
o,
|
||||||
|
Options { script: "s.json".into(), out: "a.png".into(), view: StandardView::Iso, size: DEFAULT_SIZE, section: None, field: None }
|
||||||
|
);
|
||||||
|
let o = opts(&["s", "--out", "o", "--section", "y=2.5", "--field", "displacement"]).unwrap();
|
||||||
|
assert_eq!((o.section, o.field.as_deref()), (Some(Section { axis: 1, at: Some(2.5) }), Some("displacement")));
|
||||||
|
assert_eq!(opts(&["s", "--out", "o", "--section=Z"]).unwrap().section, Some(Section { axis: 2, at: None }));
|
||||||
let o = opts(&["--view=z", "s.json", "--size", "640x480", "--out=b.png"]).unwrap();
|
let o = opts(&["--view=z", "s.json", "--size", "640x480", "--out=b.png"]).unwrap();
|
||||||
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
|
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
|
||||||
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
|
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
|
||||||
@@ -160,6 +161,9 @@ mod tests {
|
|||||||
vec!["s", "--out", "o", "--size", "8192x8192"],
|
vec!["s", "--out", "o", "--size", "8192x8192"],
|
||||||
vec!["s", "--out", "o", "--size", "-5x10"],
|
vec!["s", "--out", "o", "--size", "-5x10"],
|
||||||
vec!["s", "--out", "o", "--nope", "1"],
|
vec!["s", "--out", "o", "--nope", "1"],
|
||||||
|
vec!["s", "--out", "o", "--section", "w"],
|
||||||
|
vec!["s", "--out", "o", "--section", "x=inf"],
|
||||||
|
vec!["s", "--out", "o", "--section", "x=", "--field", "f"],
|
||||||
] {
|
] {
|
||||||
assert!(opts(&bad).is_err(), "{bad:?}");
|
assert!(opts(&bad).is_err(), "{bad:?}");
|
||||||
}
|
}
|
||||||
@@ -174,19 +178,31 @@ mod tests {
|
|||||||
(info.width, info.height, buf)
|
(info.width, info.height, buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn script(dir: &std::path::Path, name: &str, solve: bool) -> String {
|
||||||
|
let mut steps = fieldcraft_engine::sample::cantilever();
|
||||||
|
if solve && let Some(a) = steps.as_array_mut() {
|
||||||
|
a.push(serde_json::json!({"command": "solve.static", "params": {}}));
|
||||||
|
}
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, steps.to_string()).unwrap();
|
||||||
|
path.to_string_lossy().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opts_for(script: String) -> Options {
|
||||||
|
Options { script, out: String::new(), view: StandardView::Iso, size: (320, 200), section: None, field: None }
|
||||||
|
}
|
||||||
|
|
||||||
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
|
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
|
||||||
/// of the hex colour) and its element edges (dark pixels). `auto` draws on the GPU when the
|
/// of the hex colour) and its element edges (dark pixels). `auto` draws on the GPU when the
|
||||||
/// machine has one and on the CPU otherwise (a CI box without a software GPU); `cpu` always
|
/// machine has one and on the CPU otherwise (a CI box without a software GPU); `cpu` always
|
||||||
/// on the CPU, and both pictures show the same model.
|
/// on the CPU, and both pictures show the same model. A section and a field change it.
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshots_the_model_and_its_mesh_headless() {
|
fn snapshots_the_model_and_its_mesh_headless() {
|
||||||
let dir = std::env::temp_dir().join(format!("fieldcraft-snapshot-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("fieldcraft-snapshot-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let script = dir.join("cantilever.json");
|
let o = opts_for(script(&dir, "cantilever.json", false));
|
||||||
std::fs::write(&script, fieldcraft_engine::sample::cantilever().to_string()).unwrap();
|
|
||||||
let o = Options { script: script.to_string_lossy().into(), out: String::new(), view: StandardView::Iso, size: (320, 200) };
|
|
||||||
for policy in [Policy::Auto, Policy::Cpu] {
|
for policy in [Policy::Auto, Policy::Cpu] {
|
||||||
let (png, drawn) = picture(&o, policy).unwrap();
|
let (png, drawn, _) = picture(&o, policy).unwrap();
|
||||||
let (w, h, px) = decode(&png);
|
let (w, h, px) = decode(&png);
|
||||||
assert_eq!((w, h), (320, 200), "{drawn:?}");
|
assert_eq!((w, h), (320, 200), "{drawn:?}");
|
||||||
let hex = Palette::LIGHT.hex;
|
let hex = Palette::LIGHT.hex;
|
||||||
@@ -197,9 +213,18 @@ mod tests {
|
|||||||
assert!(faces > 500, "{drawn:?}: {faces} face pixels");
|
assert!(faces > 500, "{drawn:?}: {faces} face pixels");
|
||||||
assert!(edges > 100, "{drawn:?}: {edges} edge pixels");
|
assert!(edges > 100, "{drawn:?}: {edges} edge pixels");
|
||||||
if policy == Policy::Cpu {
|
if policy == Policy::Cpu {
|
||||||
assert_eq!(drawn, Drawn::Cpu { why: None });
|
assert_eq!(drawn, Renderer::Cpu { why: None });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A section along the beam draws half of it.
|
||||||
|
let whole = picture(&o, Policy::Cpu).unwrap().0;
|
||||||
|
let cut = picture(&Options { section: Some(Section { axis: 0, at: None }), ..o.clone() }, Policy::Cpu).unwrap().0;
|
||||||
|
assert_ne!(whole, cut);
|
||||||
|
// A field needs a solved step; on a solved one it is the contour reported.
|
||||||
|
assert!(picture(&Options { field: Some("von_mises".into()), ..o.clone() }, Policy::Cpu).is_err());
|
||||||
|
let solved = Options { field: Some("displacement".into()), ..opts_for(script(&dir, "solved.json", true)) };
|
||||||
|
let (_, _, shown) = picture(&solved, Policy::Auto).unwrap();
|
||||||
|
assert_eq!(shown.contour.map(|c| c.0).as_deref(), Some("displacement"));
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,10 +122,68 @@ pub struct Shown {
|
|||||||
pub sampled: Vec<String>,
|
pub sampled: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A section through the mesh: the elements whose centroid lies on the camera's side of a plane
|
||||||
|
/// normal to an axis are not drawn, so the element faces inside show (an element cut: whole elements, not
|
||||||
|
/// sliced ones). Geometry surfaces are not drawn over a sectioned mesh.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Section {
|
||||||
|
/// 0, 1, 2: the plane is normal to X, Y, Z.
|
||||||
|
pub axis: usize,
|
||||||
|
/// Where the plane crosses the axis; `None` at the middle of the mesh.
|
||||||
|
pub at: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Section {
|
||||||
|
/// `x`, `y`, `z` (the middle of the mesh) or `x=12.5`.
|
||||||
|
pub fn parse(s: &str) -> Option<Section> {
|
||||||
|
let (axis, at) = match s.split_once('=') {
|
||||||
|
Some((a, v)) => (a, Some(v.trim().parse::<f64>().ok().filter(|v| v.is_finite())?)),
|
||||||
|
None => (s, None),
|
||||||
|
};
|
||||||
|
let axis = ["x", "y", "z"].iter().position(|n| n.eq_ignore_ascii_case(axis.trim()))?;
|
||||||
|
Some(Section { axis, at })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The elements on the side of the plane `toward` points to (the direction toward the eye).
|
||||||
|
fn beyond(&self, m: &fieldcraft_model::Model, toward: Vec3) -> std::collections::BTreeSet<u32> {
|
||||||
|
let sign = match [toward.x, toward.y, toward.z].get(self.axis) {
|
||||||
|
Some(v) if *v < 0.0 => -1.0,
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
|
let coord = |label: &u32| m.node(*label).and_then(|n| n.xyz.get(self.axis).copied());
|
||||||
|
let centroid = |nodes: &[u32]| {
|
||||||
|
let v: Vec<f64> = nodes.iter().filter_map(coord).collect();
|
||||||
|
(!v.is_empty()).then(|| v.iter().sum::<f64>() / v.len() as f64)
|
||||||
|
};
|
||||||
|
let at = self.at.unwrap_or_else(|| {
|
||||||
|
let (lo, hi) = m
|
||||||
|
.nodes()
|
||||||
|
.values()
|
||||||
|
.filter_map(|n| n.xyz.get(self.axis).copied())
|
||||||
|
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(v), b.max(v)));
|
||||||
|
if lo <= hi { (lo + hi) * 0.5 } else { 0.0 }
|
||||||
|
});
|
||||||
|
m.elements().iter().filter(|(_, e)| centroid(&e.nodes).is_some_and(|c| (c - at) * sign > 0.0)).map(|(l, _)| *l).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The model's display data with the open design's bodies and the imported surfaces.
|
/// The model's display data with the open design's bodies and the imported surfaces.
|
||||||
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
|
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
|
||||||
let mut d = DisplayMesh::from_model_hiding(&s.model, &s.hidden_elements());
|
display_cut(s, None, Vec3::Z)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`display`] with a section through the mesh, cut away on the side `toward` points to.
|
||||||
|
pub fn display_cut(s: &Session, section: Option<Section>, toward: Vec3) -> (DisplayMesh, Vec<String>) {
|
||||||
|
let mut hidden = s.hidden_elements();
|
||||||
|
let cut = section.filter(|_| !s.model.elements().is_empty());
|
||||||
|
if let Some(c) = cut {
|
||||||
|
hidden.extend(c.beyond(&s.model, toward));
|
||||||
|
}
|
||||||
|
let mut d = DisplayMesh::from_model_hiding(&s.model, &hidden);
|
||||||
let mut failed = Vec::new();
|
let mut failed = Vec::new();
|
||||||
|
if cut.is_some() {
|
||||||
|
return (d, failed);
|
||||||
|
}
|
||||||
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
|
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
|
||||||
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
|
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
|
||||||
match mesh {
|
match mesh {
|
||||||
@@ -185,8 +243,8 @@ impl Picture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn picture(s: &Session, p: &Palette) -> Picture {
|
fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) -> Picture {
|
||||||
let (base, failed) = display(s);
|
let (base, failed) = display_cut(s, section, toward);
|
||||||
let mut shown = Shown { failed, ..Shown::default() };
|
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 plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r)));
|
||||||
// Over a results plot, the symbols of what its step used.
|
// Over a results plot, the symbols of what its step used.
|
||||||
@@ -238,7 +296,7 @@ fn picture(s: &Session, p: &Palette) -> Picture {
|
|||||||
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
|
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
|
||||||
/// at the model's own symbol unit, the box around it, and what it shows.
|
/// at the model's own symbol unit, the box around it, and what it shows.
|
||||||
pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
|
pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
|
||||||
let mut pic = picture(s, p);
|
let mut pic = picture(s, p, None, Vec3::Z);
|
||||||
let mut symbols = Scene::default();
|
let mut symbols = Scene::default();
|
||||||
let b = pic.symbols(pic.glyphs.model_unit(), &mut symbols);
|
let b = pic.symbols(pic.glyphs.model_unit(), &mut symbols);
|
||||||
let mut scene = pic.scene;
|
let mut scene = pic.scene;
|
||||||
@@ -277,10 +335,12 @@ pub struct Framed {
|
|||||||
/// datums (they are drawn at the model's extent, and framing them would only add margin), or from
|
/// datums (they are drawn at the model's extent, and framing them would only add margin), or from
|
||||||
/// `at`'s distance when it has one. The symbols are sized for their default size on screen
|
/// `at`'s distance when it has one. The symbols are sized for their default size on screen
|
||||||
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera. The CPU rasterizer ([`render_png`]) and
|
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera. The CPU rasterizer ([`render_png`]) and
|
||||||
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`].
|
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`]. A `section` draws the mesh cut
|
||||||
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Framed {
|
/// ([`Section`]).
|
||||||
let mut pic = picture(s, p);
|
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette, section: Option<Section>) -> Framed {
|
||||||
let mut cam = Camera::default();
|
let mut cam = Camera::default();
|
||||||
|
cam.orient(at);
|
||||||
|
let mut pic = picture(s, p, section, cam.back());
|
||||||
let aspect = w as f64 / h.max(1) as f64;
|
let aspect = w as f64 / h.max(1) as f64;
|
||||||
let unit = |cam: &Camera| glyphs::unit_for(cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN);
|
let unit = |cam: &Camera| glyphs::unit_for(cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN);
|
||||||
// Framed on the model, then again with its symbols at that camera's size, which reach a
|
// Framed on the model, then again with its symbols at that camera's size, which reach a
|
||||||
@@ -302,7 +362,7 @@ pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) ->
|
|||||||
|
|
||||||
/// A PNG of the session seen from `at`, `w` × `h` pixels ([`frame`]), drawn on the CPU.
|
/// A PNG of the session seen from `at`, `w` × `h` pixels ([`frame`]), drawn on the CPU.
|
||||||
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
|
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
|
||||||
let f = frame(s, at, w, h, p);
|
let f = frame(s, at, w, h, p, None);
|
||||||
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
|
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
|
||||||
Ok((png, f.shown))
|
Ok((png, f.shown))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fieldcraft-engine = { workspace = true }
|
fieldcraft-engine = { workspace = true }
|
||||||
|
fieldcraft-gpu = { workspace = true }
|
||||||
fieldcraft-io = { workspace = true }
|
fieldcraft-io = { workspace = true }
|
||||||
fieldcraft-render = { workspace = true }
|
fieldcraft-render = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
+48
-15
@@ -9,11 +9,48 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use fieldcraft_engine::Session;
|
use fieldcraft_engine::Session;
|
||||||
use fieldcraft_engine::view::{Palette, render_png};
|
use fieldcraft_engine::view::{Palette, Shown};
|
||||||
|
use fieldcraft_gpu::Renderer;
|
||||||
use fieldcraft_io::roots::Roots;
|
use fieldcraft_io::roots::Roots;
|
||||||
use fieldcraft_render::{Orientation, StandardView};
|
use fieldcraft_render::{Orientation, StandardView};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// A framed picture as PNG: the viewport's GPU pipeline on the process's shared device, or the
|
||||||
|
/// CPU rasterizer (`FIELDCRAFT_GPU`).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn draw(f: &fieldcraft_engine::view::Framed, w: u32, h: u32) -> Result<(Vec<u8>, Renderer), String> {
|
||||||
|
use fieldcraft_gpu::capture;
|
||||||
|
let policy = fieldcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
|
||||||
|
capture::png(capture::shared(), policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A framed picture as PNG, drawn by the CPU rasterizer (the browser has no headless device).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn draw(f: &fieldcraft_engine::view::Framed, w: u32, h: u32) -> Result<(Vec<u8>, Renderer), String> {
|
||||||
|
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w as usize, h as usize).map_err(|e| format!("render: {e}"))?;
|
||||||
|
Ok((png, Renderer::Cpu { why: None }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What drew a picture and what it shows, as JSON: `renderer` (`kind` hardware, software or cpu;
|
||||||
|
/// the adapter, or why the CPU drew it), `results` (step, scale, contour), `undrawn_bodies` and
|
||||||
|
/// `sampled_symbols`. The MCP `screenshot` reply and `fieldcraft-cli snapshot` print it.
|
||||||
|
pub fn picture_report(r: &Renderer, shown: &Shown) -> Value {
|
||||||
|
let renderer = match r {
|
||||||
|
Renderer::Gpu { adapter, samples, .. } => json!({"kind": r.kind(), "adapter": adapter, "msaa": samples}),
|
||||||
|
Renderer::Cpu { why } => json!({"kind": r.kind(), "why": why}),
|
||||||
|
};
|
||||||
|
let contour = shown
|
||||||
|
.contour
|
||||||
|
.as_ref()
|
||||||
|
.map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}}));
|
||||||
|
json!({
|
||||||
|
"renderer": renderer,
|
||||||
|
"results": shown.step.as_ref().map(|step| json!({"step": step, "scale": shown.scale, "contour": contour})),
|
||||||
|
"undrawn_bodies": shown.failed,
|
||||||
|
"sampled_symbols": shown.sampled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// What a picture shows.
|
/// What a picture shows.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct PictureRequest {
|
pub struct PictureRequest {
|
||||||
@@ -110,19 +147,15 @@ impl Backend for Headless {
|
|||||||
}
|
}
|
||||||
let w = r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE);
|
let w = r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE);
|
||||||
let h = r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE);
|
let h = r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE);
|
||||||
let (png, shown) = render_png(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?;
|
let f = fieldcraft_engine::view::frame(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT, None);
|
||||||
let contour = shown
|
let (png, renderer) = draw(&f, w, h)?;
|
||||||
.contour
|
let mut info = picture_report(&renderer, &f.shown);
|
||||||
.map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}}));
|
if let Some(o) = info.as_object_mut() {
|
||||||
let info = json!({
|
o.insert("source".into(), "model".into());
|
||||||
"source": "model",
|
o.insert("view".into(), json!(self.view));
|
||||||
"view": self.view,
|
o.insert("width".into(), w.into());
|
||||||
"width": w,
|
o.insert("height".into(), h.into());
|
||||||
"height": h,
|
}
|
||||||
"results": shown.step.map(|step| json!({"step": step, "scale": shown.scale, "contour": contour})),
|
|
||||||
"undrawn_bodies": shown.failed,
|
|
||||||
"sampled_symbols": shown.sampled,
|
|
||||||
});
|
|
||||||
Ok(Picture { png, info })
|
Ok(Picture { png, info })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +168,7 @@ impl Backend for Headless {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn describe(&self) -> String {
|
fn describe(&self) -> String {
|
||||||
"a headless in-process FieldCraft session (no window; screenshots are CPU renders)".into()
|
"a headless in-process FieldCraft session (no window; screenshots drawn offscreen with the viewport's GPU pipeline, or on the CPU without a GPU)".into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ mod backend;
|
|||||||
mod server;
|
mod server;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
pub use backend::{Backend, Headless, Picture, PictureRequest, Remote};
|
pub use backend::{Backend, Headless, Picture, PictureRequest, Remote, picture_report};
|
||||||
pub use server::{MAX_BATCH_MESSAGES, MAX_LINE, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
|
pub use server::{MAX_BATCH_MESSAGES, MAX_LINE, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
|
||||||
|
|
||||||
use fieldcraft_io::roots::Roots;
|
use fieldcraft_io::roots::Roots;
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ fn screenshot_takes_a_camera_orientation() {
|
|||||||
let (turned, info) = picture(&mut s, json!({"camera": {"yaw": 30, "pitch": 1e9, "distance": 1e300}, "width": 64, "height": 48}));
|
let (turned, info) = picture(&mut s, json!({"camera": {"yaw": 30, "pitch": 1e9, "distance": 1e300}, "width": 64, "height": 48}));
|
||||||
assert_ne!(iso, turned);
|
assert_ne!(iso, turned);
|
||||||
assert_eq!(info["view"], json!({"yaw": 30.0, "pitch": 90.0, "distance": 1e9}), "{info}");
|
assert_eq!(info["view"], json!({"yaw": 30.0, "pitch": 90.0, "distance": 1e9}), "{info}");
|
||||||
|
// The reply names what drew it: the viewport's pipeline on a GPU, or the CPU rasterizer.
|
||||||
|
assert!(matches!(info["renderer"]["kind"].as_str(), Some("hardware" | "software" | "cpu")), "{info}");
|
||||||
let (again, _) = picture(&mut s, json!({"width": 64, "height": 48}));
|
let (again, _) = picture(&mut s, json!({"width": 64, "height": 48}));
|
||||||
assert_eq!(again, turned);
|
assert_eq!(again, turned);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -10,7 +10,8 @@ runs it.
|
|||||||
## Start it
|
## Start it
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Headless (the default): an in-process session, no window; screenshots are CPU renders.
|
# Headless (the default): an in-process session, no window; screenshots are drawn offscreen
|
||||||
|
# with the viewport's GPU pipeline (hardware or software GPU), or on the CPU without one.
|
||||||
fieldcraft-cli mcp
|
fieldcraft-cli mcp
|
||||||
fieldcraft-cli mcp --headless
|
fieldcraft-cli mcp --headless
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ software on the computer.
|
|||||||
| `show_results` | `step?`, `field?`, `bands?`, `range?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for (`deformed: false` draws the undeformed mesh; `scale` is a factor, 0 for the undeformed shape so a sequence from 0 swings the deformation out, or `"auto"`; `range: {min?, max?}` clamps the legend, values above `max` drawn in the over colour); the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. |
|
| `show_results` | `step?`, `field?`, `bands?`, `range?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for (`deformed: false` draws the undeformed mesh; `scale` is a factor, 0 for the undeformed shape so a sequence from 0 swings the deformation out, or `"auto"`; `range: {min?, max?}` clamps the legend, values above `max` drawn in the over colour); the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. |
|
||||||
| `probe` | `step?`, `nodes?`, `at?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, at the nodes nearest to points `at` (structural steps), elements (explicit) or points (fluid). |
|
| `probe` | `step?`, `nodes?`, `at?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, at the nodes nearest to points `at` (structural steps), elements (explicit) or points (fluid). |
|
||||||
| `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. |
|
| `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. |
|
||||||
| `screenshot` | `view?`, `camera?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; `sampled_symbols` lists definitions drawn with a sample) and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`). `view` turns the camera to a standard view first; `camera` `{yaw, pitch, distance?}` to any orientation, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units (`ui.camera`; headless, the picture is framed unless `distance` is given). Give one of the two; the orientation is kept for the next picture, so a sequence of `camera` angles orbits smoothly. |
|
| `screenshot` | `view?`, `camera?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: an offscreen render with the viewport's GPU pipeline (`FIELDCRAFT_GPU=auto|hardware|software|cpu`; the reply's `renderer` names the tier and adapter, or why the CPU drew it) framing the model, the geometry, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; `sampled_symbols` lists definitions drawn with a sample) and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`). `view` turns the camera to a standard view first; `camera` `{yaw, pitch, distance?}` to any orientation, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units (`ui.camera`; headless, the picture is framed unless `distance` is given). Give one of the two; the orientation is kept for the next picture, so a sequence of `camera` angles orbits smoothly. |
|
||||||
| `select` | `filter?`, `box_mode?`, `mode?`, `clear?`, `geometry?`, `nodes?`, `elements?`, `faces?`, `at?`, `box?` | Bridged only: changes the app's viewport selection (`ui.select`; control-protocol.md, Selection) and returns it. Headless it is an error: selection belongs to the window. |
|
| `select` | `filter?`, `box_mode?`, `mode?`, `clear?`, `geometry?`, `nodes?`, `elements?`, `faces?`, `at?`, `box?` | Bridged only: changes the app's viewport selection (`ui.select`; control-protocol.md, Selection) and returns it. Headless it is an error: selection belongs to the window. |
|
||||||
| `selection` | — | Bridged only: the viewport selection (`ui.selection`), with `params`, the selection as a set's members for `model.set.create`. |
|
| `selection` | — | Bridged only: the viewport selection (`ui.selection`), with `params`, the selection as a set's members for `model.set.create`. |
|
||||||
| `undo`, `redo` | — | `edit.undo`, `edit.redo`. |
|
| `undo`, `redo` | — | `edit.undo`, `edit.redo`. |
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ pub const TABLE: &[(&str, Class)] = &[
|
|||||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
||||||
// The window draws through the GPU crate's pipelines.
|
// The window draws through the GPU crate's pipelines.
|
||||||
&["gpu", "ui-egui"],
|
&["gpu", "ui-egui"],
|
||||||
|
// The MCP server's headless screenshots draw with them too.
|
||||||
|
&["gpu", "mcp"],
|
||||||
];
|
];
|
||||||
|
|
||||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||||
|
|||||||
Reference in new issue
Block a user