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:
1 parent
d824d36821
commit
b3df2c382b
10 files changed
+569
-54
No files matched your search
+124
-24
@@ -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() {
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user