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
@@ -2,17 +2,12 @@
|
||||
//! to tell reachable surface from surface under something else.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcraft_engine::SurfaceAnalysis;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::gpu::AccessMap;
|
||||
|
||||
/// Map resolution (pixels per side).
|
||||
const SIZE: u32 = 1024;
|
||||
|
||||
thread_local! {
|
||||
/// The last map, by (model revision, direction).
|
||||
static CACHE: RefCell<Option<((u64, [u64; 3]), AccessMap)>> = const { RefCell::new(None) };
|
||||
@@ -33,74 +28,7 @@ pub fn map(app: &SolveApp) -> Option<AccessMap> {
|
||||
v.set(v.get() + 1);
|
||||
v.get()
|
||||
});
|
||||
let m = build(&meshes.iter().map(|m| m.as_ref()).collect::<Vec<_>>(), dir, version)?;
|
||||
let m = AccessMap::build(&meshes.iter().map(|m| m.as_ref()).collect::<Vec<_>>(), dir, version)?;
|
||||
CACHE.with(|c| *c.borrow_mut() = Some((key, m.clone())));
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Rasterise the triangles seen along `dir` (orthographic), keeping the highest point per pixel.
|
||||
fn build(meshes: &[&solidcraft_engine::geom::Mesh], dir: Vec3, version: u64) -> Option<AccessMap> {
|
||||
let d = dir.normalized()?;
|
||||
let u = d.cross(if d.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized()?;
|
||||
let v = d.cross(u);
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for m in meshes {
|
||||
for p in &m.positions {
|
||||
let (a, b) = (p.dot(u), p.dot(v));
|
||||
lo = [lo[0].min(a), lo[1].min(b)];
|
||||
hi = [hi[0].max(a), hi[1].max(b)];
|
||||
}
|
||||
}
|
||||
let ext = (hi[0] - lo[0]).max(hi[1] - lo[1]);
|
||||
if !(ext.is_finite() && ext > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let n = SIZE as usize;
|
||||
let scale = (SIZE - 2) as f64 / ext;
|
||||
// One pixel of margin on each side.
|
||||
let (ou, ov) = (-lo[0] + 1.0 / scale, -lo[1] + 1.0 / scale);
|
||||
let mut depth = vec![f32::NEG_INFINITY; n * n];
|
||||
for m in meshes {
|
||||
for t in &m.triangles {
|
||||
let q: Vec<(f64, f64, f64)> =
|
||||
t.iter().filter_map(|i| m.positions.get(*i as usize)).map(|p| ((p.dot(u) + ou) * scale, (p.dot(v) + ov) * scale, p.dot(d))).collect();
|
||||
let [a, b, c] = q[..] else { continue };
|
||||
let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
|
||||
let (x0, x1) = (a.0.min(b.0).min(c.0).floor().max(0.0) as usize, (a.0.max(b.0).max(c.0).ceil() as usize).min(n - 1));
|
||||
let (y0, y1) = (a.1.min(b.1).min(c.1).floor().max(0.0) as usize, (a.1.max(b.1).max(c.1).ceil() as usize).min(n - 1));
|
||||
// Edge-on triangles: their vertices still count.
|
||||
if area.abs() < 1e-12 {
|
||||
for p in [a, b, c] {
|
||||
let (x, y) = (p.0 as usize, p.1 as usize);
|
||||
if let Some(z) = depth.get_mut(y * n + x) {
|
||||
*z = z.max(p.2 as f32);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area;
|
||||
let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area;
|
||||
let w2 = 1.0 - w0 - w1;
|
||||
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
let z = (w0 * a.2 + w1 * b.2 + w2 * c.2) as f32;
|
||||
if let Some(cell) = depth.get_mut(y * n + x) {
|
||||
*cell = cell.max(z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(AccessMap {
|
||||
version,
|
||||
size: SIZE,
|
||||
depth: Arc::new(depth),
|
||||
u: [u.x as f32, u.y as f32, u.z as f32, ou as f32],
|
||||
v: [v.x as f32, v.y as f32, v.z as f32, ov as f32],
|
||||
scale: scale as f32,
|
||||
})
|
||||
}
|
||||
@@ -50,7 +50,7 @@ const MAX_PROMPT: usize = 100_000;
|
||||
/// up on a request after five minutes).
|
||||
const APPROVAL_TIMEOUT_MS: f64 = 240_000.0;
|
||||
/// Size of the picture of the view sent with a message.
|
||||
const VIEW_IMAGE: (usize, usize) = (960, 600);
|
||||
const VIEW_IMAGE: (u32, u32) = (960, 600);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
@@ -521,8 +521,11 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
|
||||
|
||||
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
|
||||
let mut parts = vec![PromptPart::Text(text.clone())];
|
||||
if image && let Some(png) = crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
|
||||
parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) });
|
||||
if image {
|
||||
match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
|
||||
Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }),
|
||||
Err(e) => log::warn!("the view's picture is left out of the prompt: {e}"),
|
||||
}
|
||||
}
|
||||
let label: String = text.lines().next().unwrap_or("").chars().take(48).collect();
|
||||
app.agent_chat.turn =
|
||||
|
||||
@@ -43,7 +43,7 @@ use std::sync::mpsc::Sender;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_engine::render::{StandardView, render_png};
|
||||
use solidcraft_engine::render::StandardView;
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
@@ -172,19 +172,35 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result<Option<vfs::PathS
|
||||
Ok(Some(vfs::PathScope::new(roots).with_design(app.session.path.as_deref())))
|
||||
}
|
||||
|
||||
/// A model render of the view as the window shows it (theme, section, grid and axes), as PNG.
|
||||
pub fn view_png(app: &SolveApp, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let guides =
|
||||
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() };
|
||||
let scene = solidcraft_engine::view::scene_with_palette_and_guides(
|
||||
/// What a picture of the view shows: the model as the window shows it (theme and colour vision
|
||||
/// mode, the section the viewport shows: an open Section Analysis dialog's preview, a sketch's
|
||||
/// Slice), with the grid and the origin's axes as `guides` says.
|
||||
pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides) -> solidcraft_engine::render::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,
|
||||
);
|
||||
render_png(&scene, &app.cam, w, h)
|
||||
)
|
||||
}
|
||||
|
||||
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
|
||||
fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides {
|
||||
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }
|
||||
}
|
||||
|
||||
/// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's
|
||||
/// pipelines (the renderer the app installs).
|
||||
pub(crate) fn view_rgba(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
solidcraft_engine::capture::rgba(&view_scene(app, window_guides(app)), &app.cam, w, h).map(|p| p.rgba)
|
||||
}
|
||||
|
||||
/// [`view_rgba`] as PNG.
|
||||
pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
let rgba = view_rgba(app, w, h)?;
|
||||
solidcraft_engine::render::png(w as usize, h as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
@@ -694,31 +710,32 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
_ => Outcome::Screenshot { path: s("path").map(str::to_string) },
|
||||
},
|
||||
"ui.render" => {
|
||||
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize;
|
||||
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.
|
||||
// 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 w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as u32;
|
||||
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as u32;
|
||||
// As the window shows it (`view_scene`). The grid and the axes as the window shows
|
||||
// them, unless the request says: `grid` and `axes` leave them out or put them in.
|
||||
let shown = window_guides(app);
|
||||
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()),
|
||||
grid: p.get("grid").and_then(Value::as_bool).unwrap_or(shown.grid),
|
||||
axes: p.get("axes").and_then(Value::as_bool).unwrap_or(shown.axes),
|
||||
};
|
||||
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") };
|
||||
// A path outside the request's folders is refused before anything is drawn.
|
||||
if let Some(Err(e)) = s("path").map(vfs::check) {
|
||||
return err(e);
|
||||
}
|
||||
let scene = view_scene(app, guides);
|
||||
let png = match solidcraft_engine::capture::png(&scene, &app.cam, w, h) {
|
||||
Ok(png) => png,
|
||||
Err(e) => return err(format!("render failed: {e}")),
|
||||
};
|
||||
// What drew it (gpu, gpu-software or cpu), and the warning a CPU render carries.
|
||||
let (renderer, warning) = (png.renderer, solidcraft_engine::capture::warning(png.renderer));
|
||||
match s("path") {
|
||||
Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png)) {
|
||||
Ok(()) => ok(json!({"path": path, "bytes": png.len()})),
|
||||
Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png.bytes)) {
|
||||
Ok(()) => ok(json!({"path": path, "bytes": png.bytes.len(), "renderer": renderer, "warning": warning})),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
None => ok(json!({"bytes": png.len()})),
|
||||
None => ok(json!({"bytes": png.bytes.len(), "renderer": renderer, "warning": warning})),
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
@@ -949,6 +966,9 @@ mod tests {
|
||||
/// or dark.
|
||||
#[test]
|
||||
fn model_render_is_in_the_windows_theme() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
@@ -975,6 +995,9 @@ mod tests {
|
||||
/// preview too, not only a committed `inspect.section`.
|
||||
#[test]
|
||||
fn model_render_shows_the_section_dialog_preview() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
@@ -1014,6 +1037,9 @@ mod tests {
|
||||
/// in the request say otherwise for that render.
|
||||
#[test]
|
||||
fn model_render_follows_the_grid_and_origin_switches_unless_asked() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
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());
|
||||
@@ -1246,7 +1272,9 @@ mod tests {
|
||||
};
|
||||
let export = |p: String| json!({"command": "file.export", "params": {"path": p}});
|
||||
assert_eq!(call("engine.execute", export(at(&root, "a.step")), true)["ok"], true);
|
||||
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
|
||||
if crate::gpu::test_picture_renderer() {
|
||||
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
|
||||
}
|
||||
assert_eq!(call("ui.screenshot", json!({"path": at(&root, "b.png")}), true)["ok"], true);
|
||||
for (method, params) in [
|
||||
("engine.execute", export(at(&out, "a.step"))),
|
||||
@@ -1279,7 +1307,6 @@ mod tests {
|
||||
s.execute("component.activate", &json!({"component": "root"})).unwrap();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app = SolveApp::new(s, Services::default());
|
||||
h.app.viewport.no_pixels = true;
|
||||
h.frames(2);
|
||||
let before = h.app.cam;
|
||||
// Reading during an animated view change leaves it running (no frames run between).
|
||||
@@ -1341,7 +1368,6 @@ mod tests {
|
||||
}
|
||||
// Nothing shown: nothing to fit.
|
||||
let mut empty = crate::scenario::Harness::new();
|
||||
empty.app.viewport.no_pixels = true;
|
||||
let before = empty.app.cam;
|
||||
let r = empty.call("ui.camera", json!({"yaw": 1, "fit": true}));
|
||||
assert!(r["ok"] == false && empty.app.cam == before, "{r}");
|
||||
@@ -1364,7 +1390,6 @@ mod tests {
|
||||
s.execute("joint.create", &json!({"type": "slider", "a": at("B", 55), "b": at("C", 55), "flip": true, "name": "Rail"})).unwrap();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app = SolveApp::new(s, Services::default());
|
||||
h.app.viewport.no_pixels = true;
|
||||
let before = h.app.session.doc.clone();
|
||||
let drive = |h: &crate::scenario::Harness| match h.app.dialog.as_ref().map(|d| &d.kind) {
|
||||
Some(crate::dialogs::Kind::Assembly(crate::dialogs_assembly::Asm::Drive { joint, values, .. })) => {
|
||||
|
||||
@@ -330,7 +330,6 @@ fn drive_value_takes_the_keyboard() {
|
||||
fn drive_slider_previews_intermediate_positions_before_release() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app = two_boxes();
|
||||
h.app.viewport.no_pixels = true;
|
||||
let (a, b) = (occ(&h.app, "A"), occ(&h.app, "B"));
|
||||
h.app
|
||||
.run(
|
||||
|
||||
@@ -554,7 +554,6 @@ fn flat_app(preset: &str) -> SolveApp {
|
||||
app.start("sheet.flat_pattern");
|
||||
let Some(Kind::Sheet(Sm::FlatPattern { data, .. })) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("flat dialog") };
|
||||
*data = Some(Ok(flat));
|
||||
app.viewport.no_pixels = true;
|
||||
app.preferences.nav = preset.into();
|
||||
app.preferences.zoom_to_cursor = true;
|
||||
app
|
||||
|
||||
@@ -554,8 +554,7 @@ fn partial_picture(world: &solidcraft_engine::doc::ModelState, stop: &dyn Fn() -
|
||||
if stop() || !preview_work_fits(&scene, &camera) {
|
||||
return None;
|
||||
}
|
||||
let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200);
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba))
|
||||
preview_image(&scene, &camera)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -601,12 +600,24 @@ fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option<egui::
|
||||
if !preview_work_fits(&scene, &camera) {
|
||||
return None;
|
||||
}
|
||||
let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200);
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba))
|
||||
preview_image(&scene, &camera)
|
||||
}
|
||||
|
||||
/// Bound raster work as well as file size: repeated full-screen triangles must
|
||||
/// not monopolize the file worker. Match the rasterizer's 2x sampling rectangles.
|
||||
/// The preview picture, drawn on the GPU by the renderer the app installs (none: no picture).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn preview_image(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> Option<egui::ColorImage> {
|
||||
match solidcraft_engine::capture::rgba(scene, camera, 320, 200) {
|
||||
Ok(picture) => Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &picture.rgba)),
|
||||
Err(e) => {
|
||||
log::warn!("no preview picture: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound drawing work as well as file size: repeated full-screen triangles (a hostile file) must
|
||||
/// not monopolize the file worker or a software GPU. Counts each triangle's pixel box at twice the
|
||||
/// picture's size.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> bool {
|
||||
let mut pixels = 0.0;
|
||||
@@ -950,6 +961,9 @@ mod tests {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn provisional_picture_renders_without_adopting_cached_meshes() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let preview = solidcraft_engine::io::DesignPreview {
|
||||
meshes: vec![solidcraft_engine::doc::MeshData {
|
||||
name: "Saved triangle".into(),
|
||||
@@ -965,7 +979,7 @@ mod tests {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn repeated_large_preview_triangles_are_a_cache_miss_before_rasterization() {
|
||||
fn repeated_large_preview_triangles_are_a_cache_miss_before_drawing() {
|
||||
let preview = solidcraft_engine::io::DesignPreview {
|
||||
meshes: vec![solidcraft_engine::doc::MeshData {
|
||||
name: "Hostile overdraw".into(),
|
||||
|
||||
@@ -18,7 +18,6 @@ impl Win {
|
||||
fn new() -> Self {
|
||||
let mut app = SolveApp::new(Session::default(), Default::default());
|
||||
app.custom_titlebar = true;
|
||||
app.viewport.no_pixels = true;
|
||||
let mut w = Win { ctx: egui::Context::default(), app, time: 0.0 };
|
||||
for _ in 0..3 {
|
||||
w.frame(vec![]);
|
||||
|
||||
+82
-1472
File diff suppressed because it is too large.
Load diff
@@ -69,8 +69,9 @@ pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
|
||||
// Fill the card: the fit leaves room for a whole window's margins.
|
||||
cam.distance *= 0.7;
|
||||
let scene = solidcraft_engine::view::scene(s, &cam);
|
||||
let c = solidcraft_engine::render::render(&scene, &cam, THUMB[0], THUMB[1]);
|
||||
(c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba))
|
||||
// Drawn on the GPU by the renderer the app installs; none (a browser): no picture.
|
||||
let picture = solidcraft_engine::capture::rgba(&scene, &cam, THUMB[0] as u32, THUMB[1] as u32).ok()?;
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied(THUMB, &picture.rgba))
|
||||
}
|
||||
|
||||
/// File identity, not just its path: saving or replacing a design invalidates its picture.
|
||||
@@ -570,6 +571,9 @@ mod tests {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-thumb-{}-{nonce}.solidcraft", std::process::id()));
|
||||
// Deliberately invalid design: a cached recent must not parse/evaluate it.
|
||||
@@ -594,6 +598,9 @@ mod tests {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn portable_thumbnail_loads_without_a_local_picture_or_model_rebuild() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
@@ -630,8 +637,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Samples build in the background and make a picture; picking one opens its design.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sample_card_makes_a_picture_and_opens() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let (key, img, session) = make("sample:0");
|
||||
assert_eq!(key, "sample:0");
|
||||
let img = img.unwrap();
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
//! Original GPU translation of the L1 opaque surface and fixed studio reference.
|
||||
//! Appended to the existing viewport shader so camera/output conventions are shared.
|
||||
|
||||
pub const VERTEX_SIZE: usize = 48;
|
||||
|
||||
pub const SOURCE: &str = r#"
|
||||
struct PbrOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) n: vec3<f32>,
|
||||
@location(1) base: vec3<f32>,
|
||||
@location(2) wp: vec3<f32>,
|
||||
@location(3) parameters: vec3<f32>,
|
||||
};
|
||||
|
||||
fn pbr_unit(v: vec3<f32>) -> vec3<f32> {
|
||||
let scale = max(max(abs(v.x), abs(v.y)), abs(v.z));
|
||||
if (scale == 0.0) { return vec3<f32>(0.0, 0.0, 1.0); }
|
||||
let s = v / scale;
|
||||
return s / length(s);
|
||||
}
|
||||
|
||||
fn pbr_encode(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(c * 12.92, 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), c > vec3<f32>(0.0031308));
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) base: vec3<f32>, @location(3) parameters: vec3<f32>) -> PbrOut {
|
||||
var o: PbrOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
o.n = n;
|
||||
o.base = base;
|
||||
o.wp = p;
|
||||
o.parameters = parameters;
|
||||
return o;
|
||||
}
|
||||
|
||||
fn pbr_fresnel(f0: vec3<f32>, cosine: f32) -> vec3<f32> {
|
||||
let t = 1.0 - cosine;
|
||||
return f0 + (vec3<f32>(1.0) - f0) * (t * t * t * t * t);
|
||||
}
|
||||
|
||||
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
|
||||
let nv = clamp(dot(n, v), -1.0, 1.0);
|
||||
let nl = clamp(dot(n, l), -1.0, 1.0);
|
||||
if (nv <= 0.0 || nl <= 0.0) { return vec3<f32>(0.0); }
|
||||
let h = pbr_unit(v + l);
|
||||
let nh = clamp(dot(n, h), 0.0, 1.0);
|
||||
let vh = clamp(dot(v, h), 0.0, 1.0);
|
||||
let alpha = parameters.y * parameters.y;
|
||||
let a2 = alpha * alpha;
|
||||
let denominator = (1.0 - nh * nh) + nh * nh * a2;
|
||||
let distribution = a2 / (3.141592653589793 * denominator * denominator);
|
||||
let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv));
|
||||
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
|
||||
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
|
||||
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
|
||||
let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl);
|
||||
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
|
||||
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl)) * diffuse;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_pbr(i: PbrOut) -> @location(0) vec4<f32> {
|
||||
let n = pbr_unit(i.n);
|
||||
var view = u.back.xyz;
|
||||
if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; }
|
||||
let v = pbr_unit(view);
|
||||
let lights = array<vec4<f32>, 3>(vec4<f32>(0.6, 0.0, 0.8, 3.0), vec4<f32>(-0.8, 0.0, 0.6, 1.0), vec4<f32>(0.0, -0.6, 0.8, 1.5));
|
||||
var radiance = vec3<f32>(0.0);
|
||||
for (var k = 0u; k < 3u; k += 1u) {
|
||||
let light = lights[k];
|
||||
radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0));
|
||||
}
|
||||
let mapped = radiance / (vec3<f32>(1.0) + radiance);
|
||||
if (u.back.w > 0.5) { return vec4<f32>(mapped, 1.0); }
|
||||
return vec4<f32>(pbr_encode(mapped), 1.0);
|
||||
}
|
||||
"#;
|
||||
@@ -11,7 +11,7 @@ use solidcraft_engine::render::{
|
||||
ScratchLayout,
|
||||
};
|
||||
|
||||
use crate::gpu::{GpuScene, SceneSlot, ViewportCallback};
|
||||
use crate::gpu::{Frame, GpuScene, SceneSlot, ViewportCallback};
|
||||
use crate::{SolveApp, theme::Tokens};
|
||||
|
||||
const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024;
|
||||
@@ -518,7 +518,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
|
||||
let eye = camera.eye();
|
||||
let slot = app.render_workspace.slot.clone();
|
||||
let callback = ViewportCallback {
|
||||
let callback = ViewportCallback(Frame {
|
||||
product: true,
|
||||
key,
|
||||
slot: slot.clone(),
|
||||
@@ -540,7 +540,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
outline: false,
|
||||
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }],
|
||||
ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 },
|
||||
};
|
||||
});
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
@@ -496,7 +496,6 @@ fn nav_app(render: bool, preset: &str, around: crate::prefs::OrbitAround) -> cra
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap();
|
||||
let mut app = crate::SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
if render {
|
||||
enter(&mut app, true).unwrap();
|
||||
assert!(active(&app));
|
||||
|
||||
@@ -179,8 +179,6 @@ impl Harness {
|
||||
/// File pickers answer like a person would: save into the scenario's folder, open the file
|
||||
/// the scenario chose.
|
||||
fn install(&mut self) {
|
||||
// Frames are laid out, never painted.
|
||||
self.app.viewport.no_pixels = true;
|
||||
let dir = self.dir.clone();
|
||||
self.app.services.pick_save =
|
||||
Some(Box::new(move |name, _| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned())))));
|
||||
@@ -444,8 +442,19 @@ impl Harness {
|
||||
} else if let Some(name) = s.get("shot").and_then(Value::as_str) {
|
||||
// With SOLIDCRAFT_SCENARIO_SHOTS set: a render of the model as the camera sees it.
|
||||
if let Ok(dir) = solidcraft_engine::geom::env::var("SCENARIO_SHOTS").ok_or(()) {
|
||||
// Drawn on the GPU: the headless device, as the CLI draws.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if solidcraft_engine::capture::installed().is_none() {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
|
||||
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
|
||||
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
|
||||
}));
|
||||
}
|
||||
let path = std::path::Path::new(&dir).join(format!("{name}.png"));
|
||||
self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
|
||||
let r = self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
|
||||
if r["ok"] != true {
|
||||
return Err(format!("shot {name}: {}", r["error"]));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
} else if let Some(c) = s.get("camera") {
|
||||
|
||||
@@ -108,18 +108,8 @@ pub fn srgb(c: [f32; 3]) -> Color32 {
|
||||
}
|
||||
|
||||
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them (exact, so they read
|
||||
/// the same as when they were literals in the WGSL).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnalysisColors {
|
||||
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
|
||||
pub draft: [[f32; 3]; 3],
|
||||
/// Accessibility Analysis: reachable, blocked.
|
||||
pub access: [[f32; 3]; 2],
|
||||
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius.
|
||||
pub curvature: [[f32; 3]; 3],
|
||||
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
|
||||
pub hatch: bool,
|
||||
}
|
||||
/// the same as when they were literals in the WGSL). The light theme's are the headless default.
|
||||
pub use solidcraft_engine::render::AnalysisColors;
|
||||
|
||||
/// The colours icons draw of their own (`icons::paint` takes ink, fill and accent from its
|
||||
/// caller). Every icon colour is a token so a colour vision mode can reach it.
|
||||
|
||||
+68
-150
@@ -10,7 +10,7 @@ use solidcraft_engine::sketch::CurveKind;
|
||||
use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback};
|
||||
use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback};
|
||||
use crate::navigation::{CameraInput, NavCaps, OrbitSnap, orbit_drag};
|
||||
use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
|
||||
use crate::theme::Tokens;
|
||||
@@ -69,9 +69,6 @@ pub struct ViewportState {
|
||||
pub(crate) display: std::sync::Mutex<crate::sketch_display::DisplayCache>,
|
||||
hl_slot: SceneSlot,
|
||||
hl_key: u64,
|
||||
cpu: Option<(u64, egui::TextureHandle)>,
|
||||
/// Nothing will look at the frame (the headless scenario harness): skip the software render.
|
||||
pub no_pixels: bool,
|
||||
pub nav: Option<NavMode>,
|
||||
/// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken
|
||||
/// when the button went down, so the drag neither selects nor moves sketch geometry.
|
||||
@@ -1501,20 +1498,9 @@ pub fn selection_bounds(app: &SolveApp) -> Option<solidcraft_engine::geom::Aabb3
|
||||
}
|
||||
|
||||
/// The section as the shader's clip plane (normal, d), zero when there is none.
|
||||
/// The surface analysis for the shader (see `gpu::ViewportCallback::analysis`).
|
||||
/// The surface analysis for the shader (see `gpu::Frame::analysis`).
|
||||
fn analysis_uniform(app: &SolveApp) -> [f32; 8] {
|
||||
use solidcraft_engine::SurfaceAnalysis as A;
|
||||
match app.session.analysis {
|
||||
Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0],
|
||||
Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
Some(A::Access { dir }) => {
|
||||
let (scale, size, eps) = crate::access::map(app).map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0));
|
||||
[5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0]
|
||||
}
|
||||
None => [0.0; 8],
|
||||
}
|
||||
solidcraft_gpu::view::analysis_uniform(app.session.analysis.map(|a| a.mode()), crate::access::map(app).as_ref())
|
||||
}
|
||||
|
||||
/// While Draft, Accessibility or Curvature Map analysis is on, what each of its colours means and
|
||||
@@ -2193,7 +2179,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
update_refinement(app, ui.ctx(), &proj);
|
||||
let key = scene_key(app);
|
||||
let t0 = crate::now_ms();
|
||||
if let Some(_g) = app.viewport.gpu {
|
||||
// Drawn on the GPU; without one (the headless harness, tests) the model is not drawn.
|
||||
if app.viewport.gpu.is_some() {
|
||||
if key != app.viewport.key {
|
||||
let sc = build_scene(app);
|
||||
if let Ok(mut slot) = app.viewport.slot.lock() {
|
||||
@@ -2221,7 +2208,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let size_px = [rect.width() * ppp, rect.height() * ppp];
|
||||
let ssaa = supersampling(app, ui.ctx(), proj.vp.to_f32(), size_px);
|
||||
let cb = ViewportCallback {
|
||||
let cb = ViewportCallback(Frame {
|
||||
ssaa,
|
||||
product: false,
|
||||
key: app.viewport.key,
|
||||
@@ -2246,10 +2233,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let e = proj.cam.eye().to_f32();
|
||||
[e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]
|
||||
},
|
||||
};
|
||||
});
|
||||
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
|
||||
} else {
|
||||
cpu_render(app, ui.ctx(), &painter, rect, &proj);
|
||||
}
|
||||
analysis_legend(app, &painter, rect);
|
||||
|
||||
@@ -2952,44 +2937,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
|
||||
if app.viewport.no_pixels {
|
||||
return;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
sketch_scene_key(app, proj).hash(&mut h);
|
||||
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
(rect.width() as u32, rect.height() as u32).hash(&mut h);
|
||||
app.ui.dark.hash(&mut h);
|
||||
app.preferences.color_vision.hash(&mut h);
|
||||
let key = h.finish();
|
||||
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576);
|
||||
let palette = scene_palette(app.ui.dark, app.preferences.color_vision);
|
||||
let scene = solidcraft_engine::view::scene_with_palette(
|
||||
&app.session,
|
||||
&proj.cam,
|
||||
section_plane(app),
|
||||
|sk, index, plane| crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts,
|
||||
&palette,
|
||||
);
|
||||
let w = (rect.width() as usize).clamp(16, 4096);
|
||||
let hh = (rect.height() as usize).clamp(16, 4096);
|
||||
let c = solidcraft_engine::render::render(&scene, &proj.cam, w, hh);
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba);
|
||||
let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR);
|
||||
app.viewport.cpu = Some((key, tex));
|
||||
}
|
||||
if let Some((_, tex)) = &app.viewport.cpu {
|
||||
painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of a CPU picture of the model (`ui.render` and the CPU viewport) in the dark or
|
||||
/// the light theme and a colour vision mode, from the tokens the window draws with, so the picture
|
||||
/// matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
|
||||
/// The colours of a headless picture of the model (`ui.render`, the agent's view image) in the
|
||||
/// dark or the light theme and a colour vision mode, from the tokens the window draws with, so the
|
||||
/// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
|
||||
/// render is unchanged.
|
||||
pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> solidcraft_engine::view::Palette {
|
||||
let t = Tokens::themed(dark, mode);
|
||||
@@ -3011,6 +2961,7 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli
|
||||
sketch_centerline: rgb(t.sketch_centerline),
|
||||
axes: t.triad.map(rgb),
|
||||
cap: rgb(t.section_cap),
|
||||
analysis: t.analysis,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3337,7 +3288,6 @@ mod tests {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.run_script(&solidcraft_engine::sample::script()).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
const SIZE: u32 = 256;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
|
||||
@@ -3400,7 +3350,6 @@ mod tests {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.run_script(&solidcraft_engine::sample::script()).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||||
const SIZE: u32 = 256;
|
||||
@@ -3439,7 +3388,6 @@ mod tests {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.run_script(&solidcraft_engine::sample::script()).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
|
||||
const SIZE: u32 = 256;
|
||||
@@ -3498,7 +3446,6 @@ mod tests {
|
||||
s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
const SIZE: u32 = 256;
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32));
|
||||
@@ -3599,13 +3546,13 @@ mod tests {
|
||||
crate::sketch_palette::set_mode(&mut h.app, 0);
|
||||
}
|
||||
|
||||
/// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974)
|
||||
/// and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a
|
||||
/// Headless pictures of the view (`ui.render`, the agent's view image) are drawn in the
|
||||
/// window's theme (#974) and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a
|
||||
/// light picture in Off is unchanged; otherwise each part takes the token the window draws it
|
||||
/// with. Sketch states follow what the window does with them (`sketch_color`) and the axes the
|
||||
/// mode's triad, so the two can't drift apart.
|
||||
#[test]
|
||||
fn cpu_pictures_are_drawn_in_the_windows_theme() {
|
||||
fn pictures_are_drawn_in_the_windows_theme() {
|
||||
use crate::theme::ColorVision;
|
||||
use solidcraft_engine::view::Palette;
|
||||
assert_eq!(scene_palette(false, ColorVision::Off), Palette::DEFAULT, "the light theme is the engine's colours");
|
||||
@@ -3620,6 +3567,7 @@ mod tests {
|
||||
for (dark, mode) in [false, true].into_iter().flat_map(|d| ColorVision::ALL.map(|m| (d, m))) {
|
||||
let (p, t) = (scene_palette(dark, mode), Tokens::themed(dark, mode));
|
||||
assert_eq!(p.axes, t.triad.map(rgb), "dark {dark}, {mode:?}: the axes");
|
||||
assert_eq!(p.analysis, t.analysis, "dark {dark}, {mode:?}: the surface analysis");
|
||||
for (tag, cpu) in [
|
||||
(colors::SKETCH, p.sketch),
|
||||
(colors::SKETCH_FIXED, p.sketch_fixed),
|
||||
@@ -3636,65 +3584,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU viewport follows a theme or colour vision mode change at once: both are part of its
|
||||
/// picture's key.
|
||||
/// The view's picture follows a theme change: its background is the window's.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cpu_viewport_redraws_in_the_new_theme() {
|
||||
fn the_view_picture_follows_the_theme() {
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(160.0, 120.0));
|
||||
let cam = solidcraft_engine::view::home_camera(&h.app.session);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
h.app.viewport.no_pixels = false;
|
||||
// The new picture's top row, most common colour first (the background's top); None when
|
||||
// nothing new was drawn.
|
||||
let top_row = |h: &mut crate::scenario::Harness| {
|
||||
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||||
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
|
||||
});
|
||||
let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id());
|
||||
let top = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map(|upload| {
|
||||
let egui::ImageData::Color(image) = &upload.image;
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for px in image.pixels.iter().take(image.size[0]) {
|
||||
*counts.entry(*px).or_insert(0) += 1;
|
||||
}
|
||||
counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default()
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
top
|
||||
};
|
||||
h.app.cam = solidcraft_engine::view::home_camera(&h.app.session);
|
||||
for dark in [false, true, false] {
|
||||
h.app.ui.dark = dark;
|
||||
let Some(px) = top_row(&mut h) else { panic!("dark {dark}: the CPU viewport kept the old picture") };
|
||||
let img = crate::control::view_rgba(&h.app, 160, 120).unwrap();
|
||||
// The top row's most common colour: the background's top.
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for px in img.as_chunks::<4>().0.iter().take(160) {
|
||||
*counts.entry([px[0], px[1], px[2]]).or_insert(0) += 1;
|
||||
}
|
||||
let px = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default();
|
||||
let want = if dark { Tokens::dark() } else { Tokens::light() }.viewport_top;
|
||||
let near = [px.r(), px.g(), px.b()].iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
|
||||
let near = px.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
|
||||
assert!(near, "dark {dark}: top row {px:?}, the window's background {want:?}");
|
||||
}
|
||||
// And a colour vision mode change, on its own.
|
||||
h.app.preferences.color_vision = crate::theme::ColorVision::Deutan;
|
||||
assert!(top_row(&mut h).is_some(), "a new mode kept the old picture");
|
||||
assert!(top_row(&mut h).is_none(), "no change, no new picture");
|
||||
}
|
||||
|
||||
/// The view's picture is cut by the section the viewport shows: an open Section Analysis
|
||||
/// dialog's preview, a sketch's Slice.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cpu_viewport_uses_section_preview_and_sketch_slice() {
|
||||
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<Color32> {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
h.app.viewport.no_pixels = false;
|
||||
// Force a texture upload so this test reads the actual CPU rasterizer result.
|
||||
h.app.viewport.cpu = None;
|
||||
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||||
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
|
||||
});
|
||||
let id = h.app.viewport.cpu.as_ref().unwrap().1.id();
|
||||
let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap();
|
||||
let egui::ImageData::Color(image) = &upload.image;
|
||||
assert_eq!(image.size, [320, 240]);
|
||||
let pixels = image.pixels.clone();
|
||||
out.textures_delta.clear();
|
||||
pixels
|
||||
fn the_view_picture_uses_section_preview_and_sketch_slice() {
|
||||
fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<[u8; 4]> {
|
||||
h.app.cam = cam;
|
||||
let img = crate::control::view_rgba(&h.app, 320, 240).unwrap();
|
||||
img.as_chunks::<4>().0.to_vec()
|
||||
}
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
@@ -3708,12 +3634,12 @@ mod tests {
|
||||
*offset = "15 mm".into();
|
||||
assert!(h.app.session.section.is_none());
|
||||
let preview = pixels(&mut h, cam);
|
||||
assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview");
|
||||
assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "the picture ignored the uncommitted section preview");
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc));
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap();
|
||||
h.app.dialog = None;
|
||||
assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically");
|
||||
assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must draw identically");
|
||||
|
||||
h.app.session.section = None;
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
@@ -3721,7 +3647,7 @@ mod tests {
|
||||
h.app.run("sketch.options", json!({"slice":true})).unwrap();
|
||||
assert!(h.app.session.section.is_none());
|
||||
let sliced = pixels(&mut h, cam);
|
||||
assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice");
|
||||
assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "the picture ignored the active Sketch Slice");
|
||||
h.app.run("sketch.options", json!({"slice":false})).unwrap();
|
||||
assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels");
|
||||
}
|
||||
@@ -3876,7 +3802,6 @@ mod tests {
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
if gpu {
|
||||
// The callback is queued by egui; no device is needed to inspect scene updates.
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
|
||||
@@ -4106,7 +4031,6 @@ mod tests {
|
||||
s.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.preferences.nav = "touchpad".into();
|
||||
app.viewport.no_pixels = true;
|
||||
app.start("solid.move");
|
||||
app.dialog.as_mut().unwrap().focus = false;
|
||||
let p = pos2(100.0, 100.0);
|
||||
@@ -4341,7 +4265,6 @@ mod tests {
|
||||
};
|
||||
for tess in [true, false] {
|
||||
let mut app = cylinder_app();
|
||||
app.viewport.no_pixels = true;
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess });
|
||||
let body = app.session.world_state().bodies[0].clone();
|
||||
let before = build_scene(&app).merged();
|
||||
@@ -4444,8 +4367,9 @@ mod tests {
|
||||
if wireframe {
|
||||
scene.meshes.clear();
|
||||
}
|
||||
let png = solidcraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap();
|
||||
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap();
|
||||
assert!(crate::gpu::test_picture_renderer(), "SMOOTH_SCREENSHOTS needs a GPU");
|
||||
let png = solidcraft_engine::capture::png(&scene, &cam, 1200, 800).unwrap();
|
||||
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png.bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4846,7 +4770,6 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
assert!(app.preferences.trackpad_pan);
|
||||
app.viewport.no_pixels = true;
|
||||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||
let mut time = 0.0;
|
||||
let pan =
|
||||
@@ -4905,7 +4828,6 @@ mod tests {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.preferences.smooth_wheel_zoom = smooth;
|
||||
app.preferences.trackpad_pan = false;
|
||||
app.viewport.no_pixels = true;
|
||||
crate::help::run(&mut app, &ctx, "shortcuts");
|
||||
let mut time = 0.0;
|
||||
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
|
||||
@@ -5396,7 +5318,6 @@ mod tests {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.run_script(&solidcraft_engine::sample::script()).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
app.run("inspect.draft", json!({"pull": [-1, -1, 0]})).unwrap();
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
|
||||
@@ -5448,46 +5369,39 @@ mod tests {
|
||||
assert!(changed.iter().all(|&i| b[4 * i..4 * i + 3].iter().zip(&c[4 * i..4 * i + 3]).all(|(x, y)| x <= y)), "stripes are darker");
|
||||
}
|
||||
|
||||
/// The CPU viewport draws in the colour vision mode: a fixed sketch line in the light theme
|
||||
/// The view's picture draws in the colour vision mode: a fixed sketch line in the light theme
|
||||
/// takes Deutan's value, and Off's is gone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cpu_viewport_draws_in_the_colour_vision_mode() {
|
||||
fn the_view_picture_draws_in_the_colour_vision_mode() {
|
||||
use crate::theme::ColorVision;
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.dark = false;
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
|
||||
let mut cam = solidcraft_engine::view::home_camera(&h.app.session);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
h.app.viewport.no_pixels = false;
|
||||
let count = |h: &mut crate::scenario::Harness, c: Color32| {
|
||||
h.app.viewport.cpu = None;
|
||||
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||||
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
|
||||
});
|
||||
let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id());
|
||||
let n = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map_or(0, |upload| {
|
||||
let egui::ImageData::Color(image) = &upload.image;
|
||||
image.pixels.iter().filter(|p| [p.r(), p.g(), p.b()].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
n
|
||||
h.app.cam = cam;
|
||||
let count = |h: &crate::scenario::Harness, c: Color32| {
|
||||
let img = crate::control::view_rgba(&h.app, 320, 240).unwrap();
|
||||
img.as_chunks::<4>().0.iter().filter(|p| p[..3].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
|
||||
};
|
||||
let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked);
|
||||
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
|
||||
let (a, b) = (count(&h, off), count(&h, deutan));
|
||||
assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour");
|
||||
h.app.preferences.color_vision = ColorVision::Deutan;
|
||||
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
|
||||
let (a, b) = (count(&h, off), count(&h, deutan));
|
||||
assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's");
|
||||
}
|
||||
|
||||
/// A mode's table reaches the CPU picture: the sketch states and axes it changes, and nothing
|
||||
/// else (the light theme in Off is the engine's own colours).
|
||||
/// A mode's table reaches the headless picture's palette: the sketch states and axes it
|
||||
/// changes, and nothing else (the light theme in Off is the engine's own colours).
|
||||
#[test]
|
||||
fn cpu_picture_follows_the_colour_vision_mode() {
|
||||
fn picture_palette_follows_the_colour_vision_mode() {
|
||||
use crate::theme::ColorVision;
|
||||
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
|
||||
let off = scene_palette(false, ColorVision::Off);
|
||||
@@ -5505,6 +5419,7 @@ mod tests {
|
||||
|
||||
/// The mode is on from the next frame (theme, widgets, caches, `ui.render`), and Cancel puts
|
||||
/// it back.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn colour_vision_mode_reaches_the_frame_and_the_view_caches() {
|
||||
use crate::theme::ColorVision;
|
||||
@@ -5524,8 +5439,11 @@ mod tests {
|
||||
h.frame();
|
||||
assert_eq!((h.app.preferences.color_vision, crate::theme::vision()), (ColorVision::Off, ColorVision::Off));
|
||||
assert_eq!(keys(&h.app), before);
|
||||
// `ui.render` (the CPU picture) shows the mode too: a fixed sketch line in Deutan's value
|
||||
// (in the light theme, where the red-green table changes it; the render follows the theme).
|
||||
// `ui.render` shows the mode too: a fixed sketch line in Deutan's value (in the light
|
||||
// theme, where the red-green table changes it; the render follows the theme).
|
||||
if !crate::gpu::test_picture_renderer() {
|
||||
return;
|
||||
}
|
||||
h.call("ui.prefs", json!({"theme": "light"}));
|
||||
h.frame();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
|
||||
Reference in new issue
Block a user