Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 22:19:26 -04:00
1 parent 48322f6464
commit 1a0b33fcf3
54 files changed
+3525 -1998

No files matched your search

+1 -73
View File
@@ -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,
})
}
+6 -3
View File
@@ -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 =
+57 -32
View File
@@ -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
+21 -7
View File
@@ -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(),
-1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -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();
-78
View File
@@ -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);
}
"#;
+3 -3
View File
@@ -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));
+12 -3
View File
@@ -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") {
+2 -12
View File
@@ -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
View File
@@ -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();