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

+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() {