diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index b3096ff0..9d320069 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -6,7 +6,7 @@ //! solidcraft-cli script [--out script.json] the design's history as a command script //! solidcraft-cli bench-open [--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 [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…] +//! solidcraft-cli snapshot [--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 [json-params] run one command on an empty design //! solidcraft-cli commands the command registry as JSON //! solidcraft-cli recipe 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 [--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 [--parse-only] [--display LEVEL [--edit NAME EXPR]] solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json] - solidcraft-cli snapshot [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] + solidcraft-cli snapshot [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes] solidcraft-cli snapshot --in --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 --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS] solidcraft-cli exec [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 { /// 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()}))); diff --git a/apps/solidcraft-cli/tests/snapshot_camera.rs b/apps/solidcraft-cli/tests/snapshot_camera.rs new file mode 100644 index 00000000..8f70b1b5 --- /dev/null +++ b/apps/solidcraft-cli/tests/snapshot_camera.rs @@ -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 { + 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 { + 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}"); + } +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 3bafe47d..026ae084 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -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>, + 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>, + 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>, 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 = 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 { + 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() { diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index e17b350c..b71a67ef 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -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}"))?; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 9f58b772..5ed9cf94 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -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 { + 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::>(), ["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. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e5a6ffb0..13737beb 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -171,7 +171,8 @@ pub fn tool_definitions() -> Vec { "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 { "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) -> Value { } fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, 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) -> Result<(Vec, 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"), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 04eaae59..4bdde165 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 { + 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) } diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index 92d62bc8..f550209c 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -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. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 289d6418..2524a9a2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 | diff --git a/docs/mcp.md b/docs/mcp.md index de096851..65025811 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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