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
@@ -122,10 +122,68 @@ pub struct Shown {
|
||||
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.
|
||||
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();
|
||||
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)) {
|
||||
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
|
||||
match mesh {
|
||||
@@ -185,8 +243,8 @@ impl Picture {
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(s: &Session, p: &Palette) -> Picture {
|
||||
let (base, failed) = display(s);
|
||||
fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) -> Picture {
|
||||
let (base, failed) = display_cut(s, section, toward);
|
||||
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)));
|
||||
// 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
|
||||
/// 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) {
|
||||
let mut pic = picture(s, p);
|
||||
let mut pic = picture(s, p, None, Vec3::Z);
|
||||
let mut symbols = Scene::default();
|
||||
let b = pic.symbols(pic.glyphs.model_unit(), &mut symbols);
|
||||
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
|
||||
/// `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
|
||||
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`].
|
||||
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Framed {
|
||||
let mut pic = picture(s, p);
|
||||
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`]. A `section` draws the mesh cut
|
||||
/// ([`Section`]).
|
||||
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette, section: Option<Section>) -> Framed {
|
||||
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 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
|
||||
@@ -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.
|
||||
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}")))?;
|
||||
Ok((png, f.shown))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-gpu = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-render = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
+48
-15
@@ -9,11 +9,48 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
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_render::{Orientation, StandardView};
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
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 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 contour = shown
|
||||
.contour
|
||||
.map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}}));
|
||||
let info = json!({
|
||||
"source": "model",
|
||||
"view": self.view,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"results": shown.step.map(|step| json!({"step": step, "scale": shown.scale, "contour": contour})),
|
||||
"undrawn_bodies": shown.failed,
|
||||
"sampled_symbols": shown.sampled,
|
||||
});
|
||||
let f = fieldcraft_engine::view::frame(&self.session, &self.view, w as usize, h as usize, &Palette::LIGHT, None);
|
||||
let (png, renderer) = draw(&f, w, h)?;
|
||||
let mut info = picture_report(&renderer, &f.shown);
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("source".into(), "model".into());
|
||||
o.insert("view".into(), json!(self.view));
|
||||
o.insert("width".into(), w.into());
|
||||
o.insert("height".into(), h.into());
|
||||
}
|
||||
Ok(Picture { png, info })
|
||||
}
|
||||
|
||||
@@ -135,7 +168,7 @@ impl Backend for Headless {
|
||||
}
|
||||
|
||||
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;
|
||||
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};
|
||||
|
||||
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}));
|
||||
assert_ne!(iso, turned);
|
||||
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}));
|
||||
assert_eq!(again, turned);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user