Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)
Thanks @jyviko.
This commit is contained in:
1 parent
48322f6464
commit
1a0b33fcf3
54 files changed
+3525
-1998
No files matched your search
@@ -0,0 +1,114 @@
|
||||
//! How headless pictures of the model are drawn: a renderer the host installs. The engine
|
||||
//! describes what a picture shows ([`crate::view::scene`]); drawing it is a GPU job the engine
|
||||
//! cannot do itself (the GPU crate sits above it), so the application, the CLI and the MCP
|
||||
//! server install one ([`install`]) that draws with the viewport's own pipelines (or, as a last
|
||||
//! resort the host chooses, the CPU rasterizer). Every picture names the renderer that drew it.
|
||||
//! Without a renderer, a picture is an error and a save simply has no thumbnail.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use solidcraft_render::{Camera, Scene};
|
||||
|
||||
/// A picture: straight RGBA, rows top to bottom, and the renderer that drew it (`gpu`,
|
||||
/// `gpu-software` or `cpu`).
|
||||
pub struct Picture {
|
||||
pub rgba: Vec<u8>,
|
||||
pub renderer: &'static str,
|
||||
}
|
||||
|
||||
/// A picture as PNG bytes, and the renderer that drew it.
|
||||
pub struct Png {
|
||||
pub bytes: Vec<u8>,
|
||||
pub renderer: &'static str,
|
||||
}
|
||||
|
||||
/// What a reply says when the CPU rasterizer drew the picture.
|
||||
pub const CPU_WARNING: &str = "drawn by the CPU rasterizer (no GPU adapter qualified, or SOLIDCRAFT_GPU=cpu): surface analysis colours, highlights and legends are not drawn; `solidcraft-cli gpu` lists the adapters";
|
||||
|
||||
/// The warning that goes with a picture drawn by `renderer`, if any.
|
||||
pub fn warning(renderer: &str) -> Option<&'static str> {
|
||||
(renderer == "cpu").then_some(CPU_WARNING)
|
||||
}
|
||||
|
||||
/// Draws `scene` seen by `cam` at `width` × `height` pixels (`width * height * 4` bytes of RGBA),
|
||||
/// or says why it could not.
|
||||
pub type Renderer = dyn Fn(&Scene, &Camera, u32, u32) -> Result<Picture, String> + Send + Sync;
|
||||
|
||||
static RENDERER: RwLock<Option<Arc<Renderer>>> = RwLock::new(None);
|
||||
|
||||
/// Make `renderer` draw this process's headless pictures (replacing any installed before).
|
||||
pub fn install(renderer: Arc<Renderer>) {
|
||||
if let Ok(mut r) = RENDERER.write() {
|
||||
*r = Some(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed renderer, if any.
|
||||
pub fn installed() -> Option<Arc<Renderer>> {
|
||||
RENDERER.read().ok().and_then(|r| r.clone())
|
||||
}
|
||||
|
||||
/// `scene` drawn by the installed renderer.
|
||||
pub fn rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
|
||||
rgba_with(installed().as_deref(), scene, cam, width, height)
|
||||
}
|
||||
|
||||
/// `scene` drawn by the installed renderer, as PNG.
|
||||
pub fn png(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
|
||||
png_with(installed().as_deref(), scene, cam, width, height)
|
||||
}
|
||||
|
||||
/// [`rgba`] with `renderer` instead of the installed one.
|
||||
pub fn rgba_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
|
||||
let renderer = renderer.ok_or("no renderer is installed to draw pictures in this process")?;
|
||||
let picture = renderer(scene, cam, width, height)?;
|
||||
if picture.rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
|
||||
return Err(format!("the renderer returned {} bytes for {width} × {height} pixels", picture.rgba.len()));
|
||||
}
|
||||
if let Some(w) = warning(picture.renderer) {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
Ok(picture)
|
||||
}
|
||||
|
||||
/// [`png`] with `renderer` instead of the installed one.
|
||||
pub fn png_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
|
||||
let Picture { rgba, renderer } = rgba_with(renderer, scene, cam, width, height)?;
|
||||
let bytes = solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?;
|
||||
Ok(Png { bytes, renderer })
|
||||
}
|
||||
|
||||
/// For tests: a stand-in for the renderer the hosts install, a flat picture whose first byte
|
||||
/// counts the scene's meshes.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_test_renderer() {
|
||||
install(Arc::new(|scene: &Scene, _: &Camera, w: u32, h: u32| {
|
||||
let mut rgba = vec![200; (w * h * 4) as usize];
|
||||
if let Some(first) = rgba.first_mut() {
|
||||
*first = scene.meshes.len() as u8;
|
||||
}
|
||||
Ok(Picture { rgba, renderer: "gpu" })
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_a_renderer_a_picture_is_an_error_not_a_crash() {
|
||||
let e = png_with(None, &Scene::default(), &Camera::default(), 16, 16).err().unwrap();
|
||||
assert!(e.contains("no renderer"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renderer_that_returns_the_wrong_size_is_an_error() {
|
||||
let short: &Renderer = &|_, _, _, _| Ok(Picture { rgba: vec![0; 3], renderer: "gpu" });
|
||||
assert!(rgba_with(Some(short), &Scene::default(), &Camera::default(), 2, 2).is_err());
|
||||
let plain: &Renderer = &|_, _, w, h| Ok(Picture { rgba: vec![255; (w * h * 4) as usize], renderer: "cpu" });
|
||||
let png = png_with(Some(plain), &Scene::default(), &Camera::default(), 2, 2).unwrap();
|
||||
assert!(png.bytes.starts_with(b"\x89PNG"));
|
||||
assert_eq!(png.renderer, "cpu");
|
||||
assert_eq!((warning("cpu"), warning("gpu"), warning("gpu-software")), (Some(CPU_WARNING), None, None));
|
||||
}
|
||||
}
|
||||
@@ -738,6 +738,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_and_save_as_require_explicit_native_cache_opt_in() {
|
||||
// Thumbnails are drawn by the renderer the host installs.
|
||||
crate::capture::install_test_renderer();
|
||||
let mut session = Session::new(Document::new("Cache opt-in"));
|
||||
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
|
||||
@@ -4,6 +4,15 @@ use solidcraft_geom::Aabb3;
|
||||
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
|
||||
|
||||
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
|
||||
let (scene, camera) = scene(s)?;
|
||||
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
|
||||
// Drawn by the renderer the host installed (the GPU); none installed, no thumbnail.
|
||||
let renderer = crate::capture::installed()?;
|
||||
crate::capture::png_with(Some(&*renderer), &scene, &camera, u32::try_from(width).ok()?, u32::try_from(height).ok()?).ok().map(|p| p.bytes)
|
||||
}
|
||||
|
||||
/// What the thumbnail shows: the shown bodies' warm display meshes in their colours, iso, fitted.
|
||||
fn scene(s: &Session) -> Option<(Scene, Camera)> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
return None;
|
||||
}
|
||||
@@ -38,76 +47,16 @@ pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
|
||||
camera.distance *= 0.7;
|
||||
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
|
||||
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
|
||||
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
|
||||
if !within_raster_budget(&scene, &camera, width, height) {
|
||||
return None;
|
||||
}
|
||||
let image = solidcraft_render::render(&scene, &camera, width, height);
|
||||
solidcraft_render::png(width, height, image.rgba)
|
||||
}
|
||||
|
||||
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
|
||||
// 2x supersampling; conservatively count each projected triangle's pixel box.
|
||||
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
|
||||
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
|
||||
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
|
||||
let mut pixels = 0usize;
|
||||
let mut colors = 0usize;
|
||||
for entry in &scene.meshes {
|
||||
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
|
||||
if colors > 1_000_000 {
|
||||
return false;
|
||||
}
|
||||
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
|
||||
for point in &entry.mesh.positions {
|
||||
let q = matrix.apply(*point);
|
||||
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
|
||||
if pixel.iter().any(|x| !x.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
projected.push(pixel);
|
||||
}
|
||||
for triangle in &entry.mesh.triangles {
|
||||
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
|
||||
return false;
|
||||
};
|
||||
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
|
||||
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
|
||||
pixels = pixels.saturating_add(dx.saturating_mul(dy));
|
||||
if pixels > 4_000_000 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
Some((scene, camera))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn portable_thumbnail_refuses_excessive_overdraw() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let mesh = session.world_state().bodies[0].mesh();
|
||||
let bounds = mesh.bounds();
|
||||
let mut camera = Camera::default();
|
||||
camera.set_view(StandardView::Iso);
|
||||
camera.fit(&bounds);
|
||||
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
|
||||
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
|
||||
assert!(within_raster_budget(&scene, &camera, 240, 150));
|
||||
let mut repeated = (*mesh).clone();
|
||||
repeated.triangles = vec![repeated.triangles[0]; 100_000];
|
||||
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
|
||||
assert!(!within_raster_budget(&scene, &camera, 240, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_preserves_saved_visibility() {
|
||||
crate::capture::install_test_renderer();
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
|
||||
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
|
||||
@@ -116,6 +65,7 @@ mod tests {
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
assert_eq!(scene(&session).unwrap().0.meshes.len(), 1, "the hidden body is not in the picture");
|
||||
let expected = saved(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let doc = solidcraft_io::read_design(&bytes).unwrap();
|
||||
@@ -131,6 +81,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
|
||||
crate::capture::install_test_renderer();
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
@@ -147,4 +98,17 @@ mod tests {
|
||||
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
|
||||
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
|
||||
}
|
||||
|
||||
/// Without a renderer installed (a browser, a tool that draws nothing) a save has no
|
||||
/// thumbnail and does not fail.
|
||||
#[test]
|
||||
fn without_a_renderer_a_save_has_no_thumbnail() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
for body in &session.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let (scene, camera) = scene(&session).unwrap();
|
||||
assert!(crate::capture::png_with(None, &scene, &camera, 240, 150).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod capture;
|
||||
pub mod cmd;
|
||||
pub mod control_auth;
|
||||
pub mod crash;
|
||||
@@ -290,6 +291,20 @@ pub enum SurfaceAnalysis {
|
||||
Access { dir: Vec3 },
|
||||
}
|
||||
|
||||
impl SurfaceAnalysis {
|
||||
/// How a picture shades the faces for this analysis.
|
||||
pub fn mode(self) -> render::AnalysisMode {
|
||||
use render::AnalysisMode as M;
|
||||
match self {
|
||||
SurfaceAnalysis::Zebra { stripes } => M::Zebra { stripes },
|
||||
SurfaceAnalysis::Draft { pull, angle } => M::Draft { pull, angle },
|
||||
SurfaceAnalysis::Curvature { radius } => M::Curvature { radius },
|
||||
SurfaceAnalysis::Environment => M::Environment,
|
||||
SurfaceAnalysis::Access { dir } => M::Access { dir },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_UNDO: usize = 200;
|
||||
/// Checkpoints kept (the oldest go first).
|
||||
const MAX_CHECKPOINTS: usize = 16;
|
||||
|
||||
+58
-32
@@ -5,7 +5,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcraft_geom::{Aabb3, Plane, Vec3};
|
||||
use solidcraft_render::{Camera, Rgb, Scene, SceneLine, SceneMesh};
|
||||
use solidcraft_render::{AnalysisColors, Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
|
||||
use solidcraft_sketch::Sketch;
|
||||
|
||||
use crate::{Sel, Session};
|
||||
@@ -68,6 +68,8 @@ pub struct Palette {
|
||||
pub axes: [Rgb; 3],
|
||||
/// What the section cap is tinted toward.
|
||||
pub cap: Rgb,
|
||||
/// The surface analysis colours.
|
||||
pub analysis: AnalysisColors,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
@@ -89,6 +91,7 @@ impl Palette {
|
||||
sketch_centerline: colors::SKETCH_CENTERLINE,
|
||||
axes: [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z],
|
||||
cap: colors::SECTION_CAP,
|
||||
analysis: AnalysisColors::DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,10 +365,17 @@ fn build_scene(
|
||||
) -> Scene {
|
||||
let st = s.world_state();
|
||||
let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9);
|
||||
let mut sc = Scene { background: Some(p.background), clip: section.map(|(o, n)| (n, n.dot(o))), cap: Some(p.cap), ..Default::default() };
|
||||
let mut sc = Scene {
|
||||
background: Some(p.background),
|
||||
clip: section.map(|(o, n)| (n, n.dot(o))),
|
||||
cap: Some(p.cap),
|
||||
analysis: s.analysis.map(|a| SceneAnalysis { mode: a.mode(), colors: p.analysis }),
|
||||
..Default::default()
|
||||
};
|
||||
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).
|
||||
// Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the
|
||||
// viewport.
|
||||
if guides.grid {
|
||||
let (minor, major) = grid_step(cam.half_height());
|
||||
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
|
||||
@@ -375,19 +385,12 @@ fn build_scene(
|
||||
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,
|
||||
});
|
||||
let major_line = (t / major).fract().abs() < 1e-9;
|
||||
let col = if major_line { p.grid_major } else { p.grid };
|
||||
// The viewport's widths (pixels).
|
||||
let width = if major_line { 1.0 } else { 0.7 };
|
||||
sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], color: col, width, on_top: false });
|
||||
sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))], color: col, width, on_top: false });
|
||||
}
|
||||
}
|
||||
// The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport.
|
||||
@@ -583,8 +586,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and
|
||||
/// faces with looks of their own.
|
||||
/// Headless pictures show appearances as the viewport does: a body's colour and faces with
|
||||
/// looks of their own (the picture itself: `solidcraft-gpu`'s capture tests).
|
||||
#[test]
|
||||
fn snapshot_scene_carries_appearances() {
|
||||
let mut s = Session::default();
|
||||
@@ -596,9 +599,6 @@ mod tests {
|
||||
let sc = scene(&s, &cam);
|
||||
assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40)));
|
||||
assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80))));
|
||||
// And the rendered picture has the green top.
|
||||
let img = solidcraft_render::render(&sc, &cam, 200, 140);
|
||||
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
|
||||
@@ -610,7 +610,7 @@ mod tests {
|
||||
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 count = |sc: &Scene, colours: &[Rgb]| sc.grid.iter().chain(&sc.lines).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");
|
||||
@@ -622,10 +622,7 @@ mod tests {
|
||||
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));
|
||||
assert_eq!((bare.lines.len(), bare.grid.len()), (edges, 0));
|
||||
}
|
||||
|
||||
/// An origin axis hidden with `browser.visibility` stays out of headless renders, as it does
|
||||
@@ -646,14 +643,43 @@ mod tests {
|
||||
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");
|
||||
assert!(sc.grid.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.
|
||||
/// A section cuts the headless scene: the clip plane and the cap colour, and the outline
|
||||
/// where the plane cuts each body (the picture: `solidcraft-gpu`'s capture tests).
|
||||
#[test]
|
||||
fn snapshots_draw_silhouettes_on_curved_surfaces() {
|
||||
fn the_section_cuts_the_scene() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
assert!(scene(&s, &cam).clip.is_none());
|
||||
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
|
||||
let sc = scene(&s, &cam);
|
||||
let (n, d) = sc.clip.unwrap();
|
||||
assert!((n - Vec3::new(0.0, -1.0, 0.0)).len() < 1e-12 && (d + 15.0).abs() < 1e-12, "{n:?} {d}");
|
||||
assert_eq!(sc.cap, Some(colors::SECTION_CAP));
|
||||
assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty());
|
||||
}
|
||||
|
||||
/// The surface analysis shades the headless scene too, in the palette's colours.
|
||||
#[test]
|
||||
fn the_surface_analysis_shades_the_scene() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
assert!(scene(&s, &cam).analysis.is_none());
|
||||
s.analysis = Some(crate::SurfaceAnalysis::Curvature { radius: 5.0 });
|
||||
let a = scene(&s, &cam).analysis.unwrap();
|
||||
assert_eq!((a.mode, a.colors), (solidcraft_render::AnalysisMode::Curvature { radius: 5.0 }, Palette::DEFAULT.analysis));
|
||||
}
|
||||
|
||||
/// The CPU rasterizer (the last tier of headless pictures, `SOLIDCRAFT_GPU=cpu` or no adapter
|
||||
/// under `auto`): curved surfaces get a dark silhouette line where they turn away.
|
||||
#[test]
|
||||
fn cpu_pictures_draw_silhouettes_on_curved_surfaces() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.sphere", &json!({"diameter": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
@@ -662,9 +688,9 @@ mod tests {
|
||||
assert!(dark > 40, "silhouette pixels: {dark}");
|
||||
}
|
||||
|
||||
/// A section shows in snapshots: the cut part is gone and the cut face is drawn.
|
||||
/// The CPU rasterizer: a section shows; the cut part is gone and the cut face is drawn.
|
||||
#[test]
|
||||
fn snapshots_show_the_section() {
|
||||
fn cpu_pictures_show_the_section() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
@@ -681,7 +707,7 @@ mod tests {
|
||||
/// plane's depth, not on the far wall like a pane of glass), shows in the cap colour, and
|
||||
/// the cut's outline is drawn.
|
||||
#[test]
|
||||
fn snapshot_section_caps_hide_the_inside() {
|
||||
fn cpu_picture_section_caps_hide_the_inside() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
// Cut away the front half (y < 15), so the iso camera looks into the cut.
|
||||
|
||||
@@ -24,6 +24,12 @@ const MARK: Palette = Palette {
|
||||
sketch_centerline: Rgb(43, 44, 45),
|
||||
axes: [Rgb(46, 47, 48), Rgb(49, 50, 51), Rgb(52, 53, 54)],
|
||||
cap: Rgb(55, 56, 57),
|
||||
analysis: solidcraft_render::AnalysisColors {
|
||||
draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
|
||||
access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]],
|
||||
curvature: [[0.17, 0.18, 0.19], [0.21, 0.22, 0.23], [0.24, 0.25, 0.26]],
|
||||
hatch: true,
|
||||
},
|
||||
};
|
||||
|
||||
/// Each curve's lines at x = its index, so a line tells which curve it draws.
|
||||
@@ -49,6 +55,7 @@ fn the_default_palette_is_the_colour_table() {
|
||||
(colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE)
|
||||
);
|
||||
assert_eq!((p.axes, p.cap), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP));
|
||||
assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,11 +161,13 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() {
|
||||
MARK.sketch_construction,
|
||||
MARK.sketch_centerline,
|
||||
];
|
||||
let stray: Vec<Rgb> = marked.lines.iter().map(|l| l.color).filter(|c| !allowed.contains(c)).collect();
|
||||
let all_lines = || marked.grid.iter().chain(&marked.lines);
|
||||
let stray: Vec<Rgb> = all_lines().map(|l| l.color).filter(|c| !allowed.contains(c)).collect();
|
||||
assert!(stray.is_empty(), "line colours not from the palette: {stray:?}");
|
||||
for want in [MARK.grid, MARK.grid_major, MARK.edge, MARK.axes[0], MARK.axes[1], MARK.axes[2], MARK.sketch_done] {
|
||||
assert!(marked.lines.iter().any(|l| l.color == want), "no line in {want:?}");
|
||||
assert!(all_lines().any(|l| l.color == want), "no line in {want:?}");
|
||||
}
|
||||
assert!(marked.grid.iter().all(|l| [MARK.grid, MARK.grid_major].contains(&l.color)), "only the grid is drawn under the model");
|
||||
assert_eq!(mesh(&marked, "Grey").0, colors::BODY, "an appearance equal to the default grey is the design's");
|
||||
assert_eq!(mesh(&marked, "Picked").0, MARK.body_selected);
|
||||
let (plain, faces) = mesh(&marked, "Plain");
|
||||
@@ -172,8 +181,8 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() {
|
||||
(default.background, default.cap, default.clip, default.radius),
|
||||
(plain_scene.background, plain_scene.cap, plain_scene.clip, plain_scene.radius)
|
||||
);
|
||||
assert_eq!(default.lines.len(), plain_scene.lines.len());
|
||||
for (a, b) in default.lines.iter().zip(&plain_scene.lines) {
|
||||
assert_eq!((default.lines.len(), default.grid.len()), (plain_scene.lines.len(), plain_scene.grid.len()));
|
||||
for (a, b) in default.lines.iter().zip(&plain_scene.lines).chain(default.grid.iter().zip(&plain_scene.grid)) {
|
||||
assert_eq!((&a.points, a.color, a.width, a.on_top), (&b.points, b.color, b.width, b.on_top));
|
||||
}
|
||||
for name in &names {
|
||||
|
||||
Reference in new issue
Block a user