snapshot --camera and saved views; grid: false / axes: false for headless renders (#1313)

solidcraft-cli snapshot takes --camera JSON and --view with a saved view name (through view.apply, as the MCP screenshot does), plus --no-grid and --no-axes; the MCP screenshot and ui.render take grid/axes, the headless render skips a hidden origin axis, and the app's ui.render follows the Grid and Origin switches. Closes #1245.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-15 02:59:56 -04:00
1 parent d824d36821
commit b3df2c382b
10 files changed
+569 -54

No files matched your search

+34 -14
View File
@@ -6,7 +6,7 @@
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
@@ -34,7 +34,8 @@ use serde_json::{Value, json};
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::render::render_png;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
@@ -47,9 +48,12 @@ const USAGE: &str = "usage:
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error)
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
@@ -172,7 +176,7 @@ fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
continue;
}
return Some(a);
@@ -266,7 +270,7 @@ fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str]) -> Result<(), String> {
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
@@ -274,8 +278,8 @@ fn known_flags(cmd: &str, args: &[String], valued: &[&str]) -> Result<(), String
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") {
return Err(format!("{cmd} doesn't take {a} (it takes {})", valued.join(", ")));
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
} else {
i += 1;
}
@@ -331,19 +335,35 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view"])?;
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
}
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: usize| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let s = open(flag(args, "--in"), positional(args))?;
let mut cam = solidcraft_engine::view::home_camera(&s);
if let Some(v) = flag(args, "--view") {
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
let mut s = open(flag(args, "--in"), positional(args))?;
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
// standard view, a view saved in the design, or an explicit camera. A script that applies a
// view itself sets the camera too; without any, the iso view framing the model.
match (flag(args, "--view"), flag(args, "--camera")) {
(Some(_), Some(_)) => return Err("snapshot: give --view or --camera, not both".into()),
(Some(v), None) => {
let hint = "standard views: iso, front, back, top, bottom, left, right";
s.execute("view.apply", &json!({"view": v})).map_err(|e| format!("--view {v}: {e} ({hint}; or a view saved with view.save)"))?;
}
(None, Some(c)) => {
let camera: Value = serde_json::from_str(c).map_err(|e| format!("--camera: not JSON: {e}"))?;
s.execute("view.apply", &json!({"camera": camera})).map_err(|e| format!("--camera: {e}"))?;
}
(None, None) => {}
}
let scene = solidcraft_engine::view::scene(&s, &cam);
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
@@ -0,0 +1,217 @@
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
//! design or one the script applies itself, and without the ground grid and the origin's axes
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_mcp::{Headless, Server};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (usize, usize) = (160, 100);
fn camera() -> Value {
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
/// saved in the design, plus `more` steps.
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
let mut steps = vec![
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
];
steps.extend(more.iter().cloned());
let path = self.0.join(name);
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
path
}
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(self.0.join(out))
.args(["--width", &w, "--height", &h])
.args(extra)
.output()
.unwrap()
}
/// The PNG `snapshot` writes.
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
let output = self.run(script, out, extra);
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
let png = std::fs::read(self.0.join(out)).unwrap();
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
png
}
/// The PNG the MCP `screenshot` tool writes for the same design.
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
let mut session = Session::default();
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
let mut server = Server::new(Box::new(Headless::new(session)));
let mut arguments = arguments;
arguments["path"] = json!(self.0.join(out).to_string_lossy());
arguments["width"] = json!(SIZE.0);
arguments["height"] = json!(SIZE.1);
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
std::fs::read(self.0.join(out)).unwrap()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
/// not the home view the option used to be ignored for.
#[test]
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let home = dir.snapshot(&script, "home.png", &[]);
let json = camera().to_string();
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
assert_ne!(shot, home, "--camera was ignored");
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
// A perspective camera too, and a different eye gives a different picture.
let mut near = camera();
near["fov"] = json!(0.6);
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
assert_ne!(perspective, shot);
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
}
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
/// standard view still looks as it did.
#[test]
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
assert_eq!(saved, by_camera);
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
// A standard view frames the model from its direction, as before: the home camera turned.
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
assert_ne!(top, saved);
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
assert_ne!(iso, top);
}
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
/// of its snapshot; `--view` and `--camera` still decide when given.
#[test]
fn a_script_can_carry_its_camera() {
let dir = Scratch::new();
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
let plain = dir.script("plain.json", &[]);
let carried = dir.snapshot(&script, "carried.png", &[]);
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
}
/// The grid and the axes each go when asked, and a render without both from any side shows the
/// model alone: an empty design is then the same blank picture from every camera.
#[test]
fn the_grid_and_the_axes_can_be_left_out() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
let (all, no_grid, no_axes, bare) = (
with(&[], "all.png"),
with(&["--no-grid"], "no-grid.png"),
with(&["--no-axes"], "no-axes.png"),
with(&["--no-grid", "--no-axes"], "bare.png"),
);
let different = [&all, &no_grid, &no_axes, &bare];
for (i, a) in different.iter().enumerate() {
for b in &different[i + 1..] {
assert_ne!(a, b, "{view:?}: two pictures are the same");
}
}
// The same as the MCP's `grid` and `axes`.
let mcp = |extra: Value, out: &str| {
let mut a = match view[0] {
"--view" => json!({"view": "front"}),
_ => json!({"camera": camera()}),
};
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
dir.mcp(&script, out, a)
};
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
}
let empty = dir.0.join("empty.json");
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
}
#[test]
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let cases: [(&[&str], &str); 7] = [
(&["--view", "Nowhere"], "Nowhere"),
(&["--view", "iso", "--camera", "{}"], "not both"),
(&["--camera", "not json"], "--camera"),
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
(&["--camera", "[1, 2, 3]"], "camera"),
];
for (extra, expect) in cases {
let output = dir.run(&script, "never.png", extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
}
}
/// The usage text says what the new options do.
#[test]
fn the_usage_names_the_camera_and_the_guides() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
}
}
+124 -24
View File
@@ -287,7 +287,29 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
/// the app passes the cut its viewport shows (an open Section Analysis dialog's preview, a
/// sketch's Slice), so model renders match the window.
pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene {
scene_with_section_and_sketch_sampler(s, cam, section, world_curve_parts)
scene_with_guides(s, cam, section, Guides::default())
}
/// What a headless render draws besides the model: the ground grid and the origin's axes.
/// Both are on by default; the grid lies on Z 0, so it cuts through a model that straddles it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Guides {
/// The ground grid.
pub grid: bool,
/// The origin's axes. An axis hidden with `browser.visibility` (`X`, `Y`, `Z`, or the whole
/// `origin` folder) stays hidden whatever this says, as in the viewport.
pub axes: bool,
}
impl Default for Guides {
fn default() -> Self {
Guides { grid: true, axes: true }
}
}
/// [`scene_with_section`] with the grid and the axes shown or left out.
pub fn scene_with_guides(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>, guides: Guides) -> Scene {
scene_with_palette_and_guides(s, cam, section, world_curve_parts, &Palette::DEFAULT, guides)
}
/// A headless scene using supplied display paths for its visible sketch curves.
@@ -312,6 +334,29 @@ pub fn scene_with_palette(
s: &Session,
cam: &Camera,
section: Option<(Vec3, Vec3)>,
sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec<Vec<Vec3>>,
p: &Palette,
) -> Scene {
build_scene(s, cam, section, Guides::default(), sampler, p)
}
/// [`scene_with_palette`] with the ground grid and the origin's axes shown or left out.
pub fn scene_with_palette_and_guides(
s: &Session,
cam: &Camera,
section: Option<(Vec3, Vec3)>,
sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec<Vec<Vec3>>,
p: &Palette,
guides: Guides,
) -> Scene {
build_scene(s, cam, section, guides, sampler, p)
}
fn build_scene(
s: &Session,
cam: &Camera,
section: Option<(Vec3, Vec3)>,
guides: Guides,
mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec<Vec<Vec3>>,
p: &Palette,
) -> Scene {
@@ -321,31 +366,36 @@ pub fn scene_with_palette(
let b = bounds(s);
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
// Grid on the ground plane (XY with Z up, XZ with Y up).
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
let tf = cam.to_frame(cam.target);
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
let w = |p: Vec3| cam.from_frame(p);
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
let col = if (t / major).fract().abs() < 1e-9 { p.grid_major } else { p.grid };
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
if guides.grid {
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
let tf = cam.to_frame(cam.target);
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
let w = |p: Vec3| cam.from_frame(p);
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
let col = if (t / major).fract().abs() < 1e-9 { p.grid_major } else { p.grid };
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
sc.lines.push(SceneLine {
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
color: col,
width: 0.6,
on_top: false,
});
}
}
// The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport.
let axis = cam.half_height() * 0.25;
for (d, col) in [Vec3::X, Vec3::Y, Vec3::Z].into_iter().zip(p.axes) {
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
for ((name, d), col) in [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().zip(p.axes) {
if guides.axes && !s.visibility.hidden_origin.iter().any(|h| h == name) {
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
}
}
let selected_occurrences: Vec<String> = s
.selection
@@ -551,6 +601,56 @@ mod tests {
assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels");
}
/// A render can leave out the ground grid and the origin's axes (a box straddling Z 0 is cut
/// through by the grid), each on its own; the body's own lines stay.
#[test]
fn guides_leave_the_grid_and_the_axes_out() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]})).unwrap();
let cam = home_camera(&s);
let grid = [colors::GRID, colors::GRID_MAJOR];
let axes = [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z];
let count = |sc: &Scene, colours: &[Rgb]| sc.lines.iter().filter(|l| colours.contains(&l.color)).count();
let all = scene(&s, &cam);
let (grid_lines, edges) = (count(&all, &grid), count(&all, &[colors::EDGE]));
assert!(grid_lines > 20 && edges >= 12, "{grid_lines} grid lines, {edges} edges");
assert_eq!(count(&all, &axes), 3);
assert_eq!(all.lines.len(), scene_with_guides(&s, &cam, s.section, Guides::default()).lines.len());
let render = |g: Guides| scene_with_guides(&s, &cam, s.section, g);
let no_grid = render(Guides { grid: false, axes: true });
assert_eq!((count(&no_grid, &grid), count(&no_grid, &axes), count(&no_grid, &[colors::EDGE])), (0, 3, edges));
let no_axes = render(Guides { grid: true, axes: false });
assert_eq!((count(&no_axes, &grid), count(&no_axes, &axes), count(&no_axes, &[colors::EDGE])), (grid_lines, 0, edges));
let bare = render(Guides { grid: false, axes: false });
assert_eq!(bare.lines.len(), edges);
// The picture changes with each.
let png = |sc: &Scene| solidcraft_render::render_png(sc, &cam, 160, 100);
assert!(png(&all) != png(&no_grid) && png(&all) != png(&no_axes) && png(&no_grid) != png(&bare));
}
/// An origin axis hidden with `browser.visibility` stays out of headless renders, as it does
/// out of the viewport, whatever the render's own switch says; the grid is not the origin.
#[test]
fn hidden_origin_axes_stay_out_of_renders() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let cam = home_camera(&s);
let axes = [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z];
let shown = |s: &Session| -> Vec<Rgb> {
let sc = scene(s, &cam);
axes.into_iter().filter(|c| sc.lines.iter().any(|l| l.color == *c)).collect()
};
assert_eq!(shown(&s), axes);
s.execute("browser.visibility", &json!({"items": ["Y"], "visible": false})).unwrap();
assert_eq!(shown(&s), [colors::AXIS_X, colors::AXIS_Z]);
s.execute("browser.visibility", &json!({"folder": "origin", "visible": false})).unwrap();
assert!(shown(&s).is_empty());
let sc = scene_with_guides(&s, &cam, None, Guides { grid: true, axes: true });
assert!(sc.lines.iter().any(|l| l.color == colors::GRID), "the grid is not part of the origin");
s.execute("browser.visibility", &json!({"folder": "origin", "visible": true})).unwrap();
assert_eq!(shown(&s), axes);
}
/// Curved surfaces get a dark silhouette line in snapshots where they turn away.
#[test]
fn snapshots_draw_silhouettes_on_curved_surfaces() {
+5 -1
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::view::Guides;
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`.
@@ -275,7 +276,10 @@ impl Backend for Headless {
cam.set_view(self.view);
cam
});
let scene = solidcraft_engine::view::scene(s, &cam);
// The grid and the axes are drawn unless the request leaves them out.
let shown = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
let guides = Guides { grid: shown("grid"), axes: shown("axes") };
let scene = solidcraft_engine::view::scene_with_guides(s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
let path = str_p("path").ok_or("missing `path`")?;
solidcraft_engine::io::vfs::write(path, &png).map_err(|e| format!("{path}: {e}"))?;
+91
View File
@@ -705,6 +705,97 @@ fn screenshot_renders_a_saved_named_view() {
assert_eq!(shot(&mut s, "iso"), iso);
}
/// The ground grid lies on Z 0 and cuts through a part that straddles it: `grid: false` and
/// `axes: false` leave it and the origin's axes out of the render. An axis hidden with
/// `browser.visibility` stays out whatever `axes` says.
#[test]
fn screenshot_can_leave_the_grid_and_the_axes_out() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}}));
let shot = |s: &mut Server, extra: Value| {
let mut args = json!({"view": "front", "width": 96, "height": 64});
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
tool(s, "screenshot", args)["data"].as_str().unwrap_or_default().to_string()
};
let all = shot(&mut s, json!({}));
let no_grid = shot(&mut s, json!({"grid": false}));
let no_axes = shot(&mut s, json!({"axes": false}));
let bare = shot(&mut s, json!({"grid": false, "axes": false}));
assert!(all.starts_with("iVBORw0KGgo"), "not a PNG");
let pictures = [&all, &no_grid, &no_axes, &bare];
for (i, a) in pictures.iter().enumerate() {
for b in &pictures[i + 1..] {
assert_ne!(a, b, "two of the pictures are the same");
}
}
// Asking for them is the default.
assert_eq!(shot(&mut s, json!({"grid": true, "axes": true})), all);
// Hiding the origin leaves the axes out, with or without `axes`; the grid is not the origin.
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "origin", "visible": false}}));
assert_eq!(shot(&mut s, json!({})), no_axes);
assert_eq!(shot(&mut s, json!({"axes": true})), no_axes);
assert_eq!(shot(&mut s, json!({"grid": false})), bare);
// Other than booleans are schema errors.
for bad in [json!({"grid": "no"}), json!({"axes": 0})] {
assert_eq!(raw_tool(&mut s, "screenshot", bad)["error"]["code"], -32602);
}
}
/// Bridged to an app, `grid` and `axes` ask for a model render and travel with it; without them
/// the window is captured as before; the window cannot be asked to leave things out.
#[test]
fn bridged_screenshots_with_grid_or_axes_are_model_renders() {
struct App {
calls: Vec<(String, Value)>,
}
impl crate::backend::Backend for App {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.calls.push((method.to_string(), params.clone()));
match method {
"ui.render" | "ui.screenshot" => {
let path = params["path"].as_str().ok_or("missing `path`")?;
std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": 64, "height": 48}))
}
other => Err(format!("unknown method `{other}`")),
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"app".into()
}
}
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-guides-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png").to_string_lossy().to_string();
let shoot = |extra: Value| {
let mut args = json!({"path": path, "width": 64, "height": 48});
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let mut app = App { calls: Vec::new() };
let r = crate::tools::call_tool(&mut app, "screenshot", &args);
(app.calls, r)
};
let (calls, r) = shoot(json!({}));
assert!(r["isError"] != json!(true), "{r}");
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["ui.screenshot"]);
let (calls, r) = shoot(json!({"grid": false, "axes": false}));
assert!(r["isError"] != json!(true), "{r}");
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false, "axes": false}))]);
let (calls, _) = shoot(json!({"axes": true}));
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "axes": true}))]);
let (calls, r) = shoot(json!({"source": "model", "grid": false}));
assert!(r["isError"] != json!(true), "{r}");
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false}))]);
// The window shows what it shows: nothing is called, and the error says what to ask for.
let (calls, r) = shoot(json!({"source": "window", "grid": false, "view": "top"}));
assert_eq!(r["isError"], json!(true), "{r}");
assert!(r["content"][0]["text"].as_str().unwrap().contains("source"), "{r}");
assert!(calls.is_empty(), "{calls:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #530: in a design with many bodies every feature result lists all of them, so a batch reply
/// is mostly the same body list once per step. A compact batch reply names only what each step
/// added or changed.
+21 -7
View File
@@ -171,7 +171,8 @@ pub fn tool_definitions() -> Vec<Value> {
"Render the design and return it as a PNG image. Headless: a CPU render of the model, fitted to the view. \
Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \
with the app's camera (source \"model\"). Both show an active section (inspect.section) with its cut face \
capped. `view` first turns the camera to a standard view or a saved named view (view.save), `camera` to any camera (as view.apply).",
capped. `view` first turns the camera to a standard view or a saved named view (view.save), `camera` to any camera (as view.apply). \
`grid: false` and `axes: false` leave the ground grid (on Z 0) and the origin's axes out of a model render.",
obj(
json!({
"view": string(
@@ -182,6 +183,8 @@ pub fn tool_definitions() -> Vec<Value> {
"width": {"type": "integer", "description": "Render width in pixels (model renders; default 960)"},
"height": {"type": "integer", "description": "Render height in pixels (model renders; default 600)"},
"source": {"type": "string", "enum": ["window", "model"], "description": "window = app screenshot, model = model render"},
"grid": {"type": "boolean", "description": "false: leave the ground grid out of the render. It lies on Z 0 and cuts through a part that straddles it. Model renders only: default true, or as the app shows it when connected; with `grid` or `axes` a connected screenshot is a model render, and source \"window\" is an error"},
"axes": {"type": "boolean", "description": "false: leave the origin's three axes out of the render (see `grid`). Axes hidden with browser.visibility stay hidden"},
}),
&[],
),
@@ -579,6 +582,16 @@ fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Value), String> {
// `grid` and `axes` shape a model render; the window shows what it shows.
let guides: Vec<(&str, bool)> = ["grid", "axes"].into_iter().filter_map(|k| a.get(k).and_then(Value::as_bool).map(|v| (k, v))).collect();
let window = match str_arg(a, "source") {
Some("model") => false,
Some("window") if !guides.is_empty() => {
return Err("screenshot: `grid` and `axes` apply to model renders; use source \"model\" (the window shows what it shows)".into());
}
Some("window") => true,
_ => b.has_ui() && guides.is_empty(),
};
let apply = |b: &mut dyn Backend, params: Value| b.call("engine.execute", json!({"command": "view.apply", "params": params}));
match (str_arg(a, "view"), a.get("camera").filter(|c| !c.is_null())) {
(Some(_), Some(_)) => return Err("screenshot: give `view` or `camera`, not both".into()),
@@ -604,13 +617,14 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Valu
let dir = crate::scratch_dir().unwrap_or_else(vfs::temp_dir);
dir.join(format!("shot-{n}.png")).to_string_lossy().to_string()
});
let window = match str_arg(a, "source") {
Some("model") => false,
Some("window") => true,
_ => b.has_ui(),
};
let size = |k: &str, d: u64| a.get(k).and_then(Value::as_u64).unwrap_or(d).clamp(16, 4096);
let render = |b: &mut dyn Backend| b.call("ui.render", json!({"path": path, "width": size("width", 960), "height": size("height", 600)}));
let render = |b: &mut dyn Backend| {
let mut params = json!({"path": path, "width": size("width", 960), "height": size("height", 600)});
for (k, v) in &guides {
params[*k] = json!(v);
}
b.call("ui.render", params)
};
let (info, source) = if window {
match b.call("ui.screenshot", json!({"path": path})) {
Ok(v) => (v, "window"),
+64 -4
View File
@@ -19,9 +19,10 @@
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`: real
//! pointer input in screen points; `ui.scroll {x, y, delta}`: mouse-wheel input in
//! points (zooms); `ui.key {key, cmd?, shift?}`, `ui.text {text}`
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless
//! CPU render of the model with the current camera, in the window's theme, with the section
//! the viewport shows (no window needed)
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?, grid?, axes?}`:
//! headless CPU render of the model with the current camera, in the window's theme, with the
//! section the viewport shows (no window needed); the ground grid and the origin's axes as the
//! window shows them, unless `grid` or `axes` says
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
@@ -680,12 +681,19 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize;
// In the window's theme and colour vision mode, and cut by the section the viewport shows (an open Section
// Analysis dialog's preview, a sketch's Slice), not only the committed one.
let scene = solidcraft_engine::view::scene_with_palette(
// The grid and the axes as the window shows them (the Grid and Origin switches), unless
// the request says: `grid` and `axes` leave them out of the picture or put them in.
let guides = solidcraft_engine::view::Guides {
grid: p.get("grid").and_then(Value::as_bool).unwrap_or(app.ui.show_grid && !crate::viewport::active_view(app).hide_grid),
axes: p.get("axes").and_then(Value::as_bool).unwrap_or_else(|| app.origin_visible()),
};
let scene = solidcraft_engine::view::scene_with_palette_and_guides(
&app.session,
&app.cam,
crate::viewport::section_plane(app),
solidcraft_engine::view::world_curve_parts,
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
guides,
);
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
match s("path") {
@@ -970,6 +978,58 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// `ui.render` with extra request parameters.
fn render_with(app: &mut SolveApp, path: &std::path::Path, extra: serde_json::Value) -> Vec<u8> {
let mut params = json!({"path": path.to_string_lossy(), "width": 200, "height": 140});
for (k, v) in extra.as_object().into_iter().flatten() {
params[k.as_str()] = v.clone();
}
let (req, _rx) = ControlRequest::new("ui.render", params);
match handle(app, &egui::Context::default(), &req) {
Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"),
Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"),
}
std::fs::read(path).unwrap()
}
/// A model render draws the ground grid (it cuts through a part that straddles Z 0) and the
/// origin's axes as the window does, by the Grid and Origin switches, and `grid` and `axes`
/// in the request say otherwise for that render.
#[test]
fn model_render_follows_the_grid_and_origin_switches_unless_asked() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]})).unwrap();
let mut app = SolveApp::new(s, Services::default());
app.cam.set_view(solidcraft_engine::render::StandardView::Front);
let dir = std::env::temp_dir().join(format!("solidcraft-render-guides-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let shot = |app: &mut SolveApp, extra: serde_json::Value| render_with(app, &path, extra);
let all = shot(&mut app, json!({}));
let no_grid = shot(&mut app, json!({"grid": false}));
let no_axes = shot(&mut app, json!({"axes": false}));
let bare = shot(&mut app, json!({"grid": false, "axes": false}));
let pictures = [&all, &no_grid, &no_axes, &bare];
for (i, a) in pictures.iter().enumerate() {
for b in &pictures[i + 1..] {
assert!(a != b, "two of the pictures are the same");
}
}
// With the Grid switch off the render has no grid, and `grid: true` brings it back.
app.ui.show_grid = false;
assert!(shot(&mut app, json!({})) == no_grid);
assert!(shot(&mut app, json!({"grid": true})) == all);
// With the Origin off too it has neither; `axes: true` puts the axes back.
app.ui.show_origin = false;
assert!(shot(&mut app, json!({})) == bare);
assert!(shot(&mut app, json!({"axes": true})) == no_grid);
// The Origin and the Grid back on: the first picture again.
app.ui.show_grid = true;
app.ui.show_origin = true;
assert!(shot(&mut app, json!({})) == all);
let _ = std::fs::remove_dir_all(&dir);
}
fn batch(h: &mut crate::scenario::Harness, params: serde_json::Value) -> serde_json::Value {
h.call("engine.batch", params)
}
+9
View File
@@ -118,6 +118,15 @@ A script is a list of commands:
![The bracket example, rendered by solidcraft-cli snapshot](../generated/bracket.png)
`snapshot` looks at the model from the iso view. `--view NAME` takes another standard view (`front`, `back`, `top`, `bottom`, `left`, `right`) or the name of a view saved in the design with `view.save`. `--camera JSON` takes any camera, as the MCP `screenshot` tool does (its conventions are in [the MCP page](mcp.md#camera-and-close-up-recipe)). A script that applies a view itself with `view.apply` sets the camera too. The ground grid lies on Z 0, so it cuts through a part that straddles it: `--no-grid` leaves it out of the picture, and `--no-axes` the origin's axes.
```sh
solidcraft-cli snapshot part.solidcraft --out photo.png --view Photo
solidcraft-cli snapshot part.solidcraft --out close-up.png --no-grid --no-axes --camera '{"target": [30, -20, 0], "yaw": 0.6, "pitch": 0.25, "distance": 60}'
```
Windows PowerShell 5.1 drops the double quotes inside an argument it passes to a program: write them as `\"` there.
A command that fails stops the script. A feature can also fail later, when a parameter change
rebuilds it; `run` then exits with status 1, lists the failing features and writes none of
its `--out` and `--save` files, because the bodies lack what those features should have made.
+1 -1
View File
@@ -125,7 +125,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | |
| `ui.text` | `text` | typed text |
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped |
| `ui.render` | `path`, `width?`, `height?`, `grid?`, `axes?` | CPU render of the model with the current camera (no window needed), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped. The ground grid and the origin's axes are drawn as the window shows them (`ui.set` `showGrid` and `showOrigin`, `hiddenOrigin`); `grid` and `axes` (booleans) say otherwise for this render |
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
+3 -3
View File
@@ -149,7 +149,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. |
| `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). |
| `undo`, `redo` | — | Undo / redo one change. |
| `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). |
@@ -177,7 +177,7 @@ the batch added to the undo history.)
## Camera and close-up recipe
The `camera` of `screenshot` (the object `view.apply` and `view.save` take too) is an orbit camera, `{target, yaw, pitch, distance, fov?}`, in millimetres and **radians**. Unlike the `angle` of `solid.move`, a bare number is not degrees here. The eye is at `target + distance * (-sin(yaw) * cos(pitch), -cos(yaw) * cos(pitch), sin(pitch))`, looking at `target` with Z up on the screen. So `yaw: 0, pitch: 0` is the front view: the eye at -Y, looking along +Y, with +X to the right. A yaw of π/2 looks from -X, π from +Y and -π/2 from +X. The pitch is the elevation angle up from the XY plane, and π/2 looks straight down with +X to the right and +Y up. `fov` is the vertical field of view in radians. `0`, or no `fov`, is an orthographic view whose visible half-height is 0.4 × `distance`; with a perspective `fov` the half-height at the target is `distance * tan(fov / 2)`. `inspect.section` removes what lies on the side its normal points to: it keeps the points `p` with `n · p < d`, where `d = n · origin` (`flip: true` keeps the other side). To look into the cut, put the eye on the removed side.
The `camera` of `screenshot` (the object `view.apply`, `view.save` and `solidcraft-cli snapshot --camera` take too) is an orbit camera, `{target, yaw, pitch, distance, fov?}`, in millimetres and **radians**. Unlike the `angle` of `solid.move`, a bare number is not degrees here. The eye is at `target + distance * (-sin(yaw) * cos(pitch), -cos(yaw) * cos(pitch), sin(pitch))`, looking at `target` with Z up on the screen. So `yaw: 0, pitch: 0` is the front view: the eye at -Y, looking along +Y, with +X to the right. A yaw of π/2 looks from -X, π from +Y and -π/2 from +X. The pitch is the elevation angle up from the XY plane, and π/2 looks straight down with +X to the right and +Y up. `fov` is the vertical field of view in radians. `0`, or no `fov`, is an orthographic view whose visible half-height is 0.4 × `distance`; with a perspective `fov` the half-height at the target is `distance * tan(fov / 2)`. `inspect.section` removes what lies on the side its normal points to: it keeps the points `p` with `n · p < d`, where `d = n · origin` (`flip: true` keeps the other side). To look into the cut, put the eye on the removed side.
To look closely at one part of a large model, such as a STEP assembly, work in a headless session. These steps change only the open design in memory:
@@ -187,7 +187,7 @@ To look closely at one part of a large model, such as a STEP assembly, work in a
4. If the model lies on its back (many STEP files have +Y up), open it with `up: "y"` (the `open` tool, `doc.open`, `file.insert_step`), which turns the file's bodies a quarter turn about X (Y becomes Z). One already open is stood Z up with `solid.move`, listing every body you keep, e.g. `{"bodies": ["Housing", "Plate"], "axis": [1, 0, 0], "angle": 90}`. It turns the bodies about the world origin, and a bare angle is in degrees. A turn of +90° about X takes +Y to +Z.
5. Colour the parts that matter with `appearance.assign`, e.g. `{"bodies": ["Housing"], "color": "#3a7bd5"}`, and hide helper bodies or sketches with `browser.visibility`, e.g. `{"items": ["Plate"], "visible": false}`.
6. Cut a section with `inspect.section`, e.g. `{"plane": {"origin": [50, 30, 0], "normal": [0, 1, 0]}}` or `{"plane": "XY", "at": 5}`.
7. Take the picture with `screenshot`: a `camera` whose `target` is the centre of the region and whose eye is on the cut side, `source: "model"`, and `path`, `width` and `height` as needed. An orthographic picture is 0.8 × `distance` high, so a `distance` of about 1.5 times the region's size frames it. Look at the image, adjust `yaw`, `pitch` and `distance`, and shoot again. Headless, the picture is always a render of the model; bridged to the app, `source: "model"` asks for that render instead of the app's window.
7. Take the picture with `screenshot`: a `camera` whose `target` is the centre of the region and whose eye is on the cut side, `source: "model"`, `grid: false` if the region straddles Z 0 (the ground grid would cut through it), and `path`, `width` and `height` as needed. An orthographic picture is 0.8 × `distance` high, so a `distance` of about 1.5 times the region's size frames it. Look at the image, adjust `yaw`, `pitch` and `distance`, and shoot again. Headless, the picture is always a render of the model; bridged to the app, `source: "model"` asks for that render instead of the app's window.
## Compact replies