Files
OneCAD/crates/ui-egui/src/render_workspace_tests.rs
T
selune a261aaa841 Render: integrated workspace, preview image jobs and opaque PBR (#1046)
Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change.

- Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added.
- Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing.
- Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it.
- Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru.

Supersedes #706 and #800. Refs #620, #673, #756, #1004.

Contributed by @0xlillith.

Co-authored-by: lillith <[email protected]>
2026-10-14 19:24:36 -04:00

473 lines
22 KiB
Rust

use super::*;
use crate::scenario::Harness;
use solidcraft_engine::Session;
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
use solidcraft_engine::render::{
StandardView,
job::{CancelToken, ImageRequest, ImageTask, TaskPoll},
};
let mut s = Session::default();
s.execute("solid.sphere", &json!({"radius":10,"body_name":"Original sphere"})).unwrap();
const SIZE: u32 = 256;
let mut previous = None;
for (metallic, roughness) in [(0.0, 0.2), (0.0, 0.65), (1.0, 0.2), (1.0, 0.65)] {
s.execute(
"appearance.assign",
&json!({"bodies":["Original sphere"],"color":"#b47846",
"pbr":{"metallic":metallic,"roughness":roughness,"dielectric_ior":1.5}}),
)
.unwrap();
let scene = solidcraft_engine::product_scene::extract(&s).unwrap();
let (key, gpu) = gpu_scene(&scene).unwrap();
assert!(!gpu.pbr.is_empty() && gpu.tris.is_empty());
assert_ne!(previous, Some(key));
previous = Some(key);
let mut camera = Camera { fov: 0.0, ..Default::default() };
camera.set_view(StandardView::Iso);
camera.fit(&scene.bounds);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
let eye = camera.eye().to_f32();
let mut task = ImageTask::new(
scene,
ImageRequest { camera, width: SIZE, height: SIZE, background: [255; 3], transparent_background: false },
CancelToken::default(),
)
.unwrap();
let mut png = None;
for _ in 0..100_000 {
if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() {
png = Some(bytes);
break;
}
}
let cpu = image::load_from_memory(&png.expect("bounded original fixture completes")).unwrap().to_rgba8();
assert_eq!(cpu.dimensions(), (SIZE, SIZE));
let cpu = cpu.into_raw();
for srgb in [false, true] {
let Some(gpu) = crate::gpu::render_product_offscreen(
gpu.clone(),
camera.view_proj(1.0, radius).to_f32(),
camera.back().to_f32(),
[eye[0], eye[1], eye[2], 0.0],
SIZE,
srgb,
) else {
eprintln!("skipping: no GPU adapter");
return;
};
let at = |pixels: &[u8], x: usize, y: usize| {
let i = (y * SIZE as usize + x) * 4;
[pixels[i], pixels[i + 1], pixels[i + 2]]
};
let mut core = 0usize;
let mut error = 0usize;
let mut outliers = 0usize;
for y in 2..SIZE as usize - 2 {
for x in 2..SIZE as usize - 2 {
// Exclude antialiased silhouettes: CPU uses 2x supersampling, GPU uses 1x.
if (y - 1..=y + 1).any(|ny| (x - 1..=x + 1).any(|nx| at(&cpu, nx, ny) == [255; 3] || at(&gpu, nx, ny) == [255; 3])) {
continue;
}
let delta = at(&cpu, x, y).into_iter().zip(at(&gpu, x, y)).map(|(a, b)| usize::from(a.abs_diff(b))).collect::<Vec<_>>();
error += delta.iter().sum::<usize>();
outliers += usize::from(delta.iter().any(|d| *d > 12));
core += 1;
}
}
assert!(core > 1000);
let mean = error as f64 / (3 * core) as f64;
eprintln!("PBR m={metallic} r={roughness} srgb={srgb}: core={core}, mean={mean}, outliers={outliers}");
assert!(mean < 2.0 && outliers * 100 < core * 2, "CPU/GPU color or material interpretation differs");
}
}
}
#[test]
fn control_render_export_respects_upstream_file_scope() {
let mut h = Harness::new();
let root = h.dir.join("allowed");
let outside = h.dir.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let roots = json!([root.to_string_lossy()]);
let response = h.call("ui.workspace", json!({"workspace":"render","allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
let response = h.call("engine.execute", json!({"command":"render.start","params":{"settings":{"width":16,"height":16}},"allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
let job = response["result"]["job"].clone();
for _ in 0..50 {
h.frames(1);
}
assert_eq!(h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"], "completed");
for path in [outside.join("denied.png"), root.join("../outside/traversal.png")] {
for overwrite in [false, true] {
let response = h.call(
"engine.execute",
json!({"command":"render.export","params":{"job":job,"path":path,"overwrite":overwrite},"allow_paths":roots}),
);
assert_eq!(response["ok"], false, "{response}");
assert!(response.to_string().contains("outside the folders this agent may use"), "{response}");
}
}
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
let path = root.join("allowed.png");
let response = h.call("engine.execute", json!({"command":"render.export","params":{"job":job,"path":path},"allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
assert!(path.is_file());
let original = std::fs::read(&path).unwrap();
let response = h.call("engine.execute", json!({"command":"render.export","params":{"job":job,"path":path,"overwrite":true},"allow_paths":roots}));
assert_eq!(response["ok"], true, "{response}");
assert_eq!(std::fs::read(&path).unwrap(), original);
}
#[test]
fn adopting_into_blank_document_resets_render_draft_and_preserves_hosts() {
let mut h = Harness::new();
h.app.render_workspace.host = Some(host_executor(2048, true, 4).unwrap());
sync_backend(&mut h.app);
h.app.render_workspace.view.name = "Old draft".into();
enter(&mut h.app, true).unwrap();
let mut incoming = Session::default();
incoming.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
crate::documents::adopt(&mut h.app, incoming);
assert!(!active(&h.app));
assert_eq!(h.app.render_workspace.view.name, "Studio");
assert_eq!(h.app.session.render_backend().executors.len(), 3);
assert!(h.app.session.render_job_host().is_some());
assert_eq!(h.app.session.model.state().bodies.len(), 1);
}
#[test]
fn clicked_image_controls_complete_cancel_and_export_without_dirtying_the_design() {
for language in ["en", "de", "ko", "ru"] {
let mut h = Harness::new();
h.app.preferences.language = language.into();
h.frames(1);
let check = h.app.tr("render.image_check").to_string();
let start = h.app.tr("render.image_start").to_string();
let export = h.app.tr("render.image_export").to_string();
let cancel = h.app.tr("render.image_cancel").to_string();
h.call("ui.resize", json!({"width":1600,"height":1400}));
enter(&mut h.app, true).unwrap();
h.app.render_workspace.view.draft.width = 16;
h.app.render_workspace.view.draft.height = 16;
h.app.render_workspace.view.dirty = true;
h.frames(3);
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
h.step(&json!({"widget":check})).unwrap();
h.step(&json!({"widget":start})).unwrap();
for _ in 0..50 {
h.frames(1);
if h.app.render_workspace.view.completed.is_some() {
break;
}
}
let good = h.app.render_workspace.view.completed.unwrap();
h.step(&json!({"widget":export})).unwrap();
assert!(h.dir.join("render.png").is_file());
h.app.render_workspace.view.draft.width = 1024;
h.app.render_workspace.view.draft.height = 768;
h.step(&json!({"widget":start})).unwrap();
h.step(&json!({"widget":cancel})).unwrap();
h.frames(5);
assert_eq!(h.app.render_workspace.view.completed, Some(good));
assert_eq!(
h.app.session.execute("render.status", &json!({"job":h.app.render_workspace.view.job})).unwrap()["progress"]["phase"],
"cancelled"
);
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert!(!h.app.session.is_dirty());
}
}
#[test]
fn saved_camera_survives_frames_and_product_fit_ignores_selection() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5,"body_name":"Product"})).unwrap();
h.app.run("solid.box", json!({"corner":[100,0,0],"length":10,"width":20,"height":5,"body_name":"Other"})).unwrap();
let camera = Camera { up: solidcraft_engine::render::UpAxis::Y, roll: 0.2, ..Default::default() };
h.app.run("render.settings.set", json!({"name":"Studio","set":{"camera":camera,"background":[21,43,65]}})).unwrap();
h.frames(3);
let design = h.app.cam;
enter(&mut h.app, true).unwrap();
h.frames(3);
h.step(&json!({"widget":"Restore Saved Camera"})).unwrap();
h.frames(3);
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.render_workspace.view.draft.background, [21, 43, 65]);
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Product".into() }];
let bounds = solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds;
let mut expected = camera;
expected.fit(&bounds);
h.app.animate_view("fit");
h.frames(3);
assert_eq!(h.app.cam, expected);
enter(&mut h.app, false).unwrap();
assert_eq!(h.app.cam, design);
}
#[test]
fn clicked_render_workspace_saves_one_setup_and_restores_design_view() {
for language in ["en", "de", "ko", "ru"] {
let mut h = Harness::new();
h.app.preferences.language = language.into();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
h.app.session.mark_saved();
h.frames(3);
let camera = h.app.cam;
let flags = (h.app.ui.show_grid, h.app.ui.show_origin, h.app.ui.visual_style, h.app.ui.perspective);
let undo = h.app.session.undo.len();
let at = crate::scenario::rect_of("workspace_switcher").unwrap().center();
h.step(&json!({"click":[at.x,at.y]})).unwrap();
h.step(&json!({"click":{"handle":"menu:ui.workspace.render"}})).unwrap();
assert!(active(&h.app));
let heading = h.app.tr("cmd.render_settings_get").to_string();
let perspective = h.app.tr("render.perspective").to_string();
let save = h.app.tr("render.save_setup").to_string();
let back = h.app.tr("render.return_design").to_string();
assert!(h.widget(&heading).is_some(), "{language}");
h.step(&json!({"widget":perspective})).unwrap();
assert!(!h.app.session.is_dirty(), "preview edits are staged");
h.step(&json!({"widget":save})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.render.setups["Studio"].camera, h.app.cam);
h.step(&json!({"widget":back})).unwrap();
assert!(!active(&h.app));
assert_eq!(h.app.cam, camera);
assert_eq!(flags, (h.app.ui.show_grid, h.app.ui.show_origin, h.app.ui.visual_style, h.app.ui.perspective));
h.app.run("edit.undo", json!({})).unwrap();
assert!(!h.app.session.is_dirty());
}
}
#[test]
fn render_drafts_and_capabilities_are_isolated_across_document_switches() {
let mut h = Harness::new();
h.app.render_workspace.host = Some(host_executor(2048, true, 4).unwrap());
sync_backend(&mut h.app);
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.render_workspace.view.name = "First draft".into();
h.app.render_workspace.view.draft.background = [20, 40, 60];
h.app.render_workspace.view.dirty = true;
enter(&mut h.app, true).unwrap();
crate::documents::open_tab(&mut h.app, Session::default()).unwrap();
assert!(!active(&h.app));
assert_eq!(h.app.render_workspace.view.name, "Studio");
assert_eq!(h.app.session.render_backend().executors.len(), 3);
crate::documents::switch(&mut h.app, 0);
assert_eq!(h.app.render_workspace.view.name, "First draft");
assert_eq!(h.app.render_workspace.view.draft.background, [20, 40, 60]);
assert!(h.app.render_workspace.view.dirty);
assert_eq!(h.app.session.render_backend().executors.len(), 3);
h.app.run("file.new", json!({})).unwrap();
assert_eq!(h.app.render_workspace.view.name, "Studio");
assert_eq!(h.app.session.render_backend().executors.len(), 3);
}
#[test]
fn product_gpu_buffers_preserve_opacity_and_never_contain_overlays() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Glass"})).unwrap();
s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#336699","opacity":0.4})).unwrap();
let scene = solidcraft_engine::product_scene::extract(&s).unwrap();
let (key, gpu) = gpu_scene(&scene).unwrap();
assert!(gpu.tris.is_empty());
assert!(!gpu.glass.is_empty());
assert!(gpu.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>().0.iter().all(|v| v[24..] == [51, 102, 153, 102]));
assert!(gpu.lines.is_empty() && gpu.overlay.is_empty() && gpu.under.is_empty() && gpu.hidden.is_empty() && gpu.ghost.is_empty());
s.selection = vec![solidcraft_engine::Sel::Body { name: "Glass".into() }];
s.section = Some((solidcraft_engine::geom::Vec3::ZERO, solidcraft_engine::geom::Vec3::X));
let after = solidcraft_engine::product_scene::extract(&s).unwrap();
assert_eq!(gpu_scene(&after).unwrap().0, key);
s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#336699","opacity":0.0})).unwrap();
let invisible = solidcraft_engine::product_scene::extract(&s).unwrap();
assert_eq!(invisible.meshes[0].opacity, 0.0);
let (_, empty) = gpu_scene(&invisible).unwrap();
assert!(empty.tris.is_empty() && empty.glass.is_empty());
}
#[test]
fn host_profiles_advertise_only_installed_opaque_pbr_not_hdri_refraction_or_final() {
use solidcraft_engine::render::*;
let host = host_executor(512, true, 4).unwrap();
assert_eq!(host.limits.max_dimension, 512);
assert_eq!(host.profiles.len(), 1);
assert_eq!(host.profiles[0].features, vec![RenderFeature::Pbr]);
assert!(host.profiles[0].formats.is_empty());
let mut s = Session::default();
s.set_render_viewport(Some(host)).unwrap();
let mut request = RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Viewport,
width: 512,
height: 512,
format: None,
features: Vec::new(),
policy: Default::default(),
};
assert_eq!(resolve_request(&[s.render_backend()], &request).unwrap().executor.as_str(), "gpu-viewport");
request.features = vec![RenderFeature::Pbr];
assert!(resolve_request(&[s.render_backend()], &request).is_ok());
for unsupported in [RenderFeature::Hdri, RenderFeature::Refraction, RenderFeature::Progressive] {
request.features = vec![unsupported];
assert!(resolve_request(&[s.render_backend()], &request).is_err());
}
request.features.clear();
request.width = 513;
assert!(resolve_request(&[s.render_backend()], &request).is_err());
request.width = 512;
request.mode = RenderMode::Final;
assert!(resolve_request(&[s.render_backend()], &request).is_err());
assert!(host_executor(1024, false, 1).unwrap().availability != Availability::Available);
assert!(host_executor(1024, true, 0).is_err());
}
fn completed_preview(h: &mut Harness, background: [u8; 3]) -> solidcraft_engine::render::job::JobId {
let reply = h.app.session.execute("render.start", &json!({"settings":{"width":16,"height":16,"background":background}})).unwrap();
let job = serde_json::from_value(reply["job"].clone()).unwrap();
let host = h.app.session.render_job_host().unwrap().clone();
for _ in 0..100 {
host.pump(16384).unwrap();
if h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"] == "completed" {
return job;
}
}
panic!("small cooperative image did not complete within the work budget");
}
fn async_export_fixture() -> (Harness, solidcraft_engine::render::job::JobId) {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
h.app.session.mark_saved();
let job = completed_preview(&mut h, [200, 210, 220]);
(h, job)
}
#[test]
fn delayed_image_export_holds_its_document_without_changing_history() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("delayed.png");
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(h.app.file_picker_busy() && !path.exists());
assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_dialog_open"], true);
crate::documents::new_design(&mut h.app);
let other = h.app.session.doc.clone();
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert_eq!(h.app.docs.active, 0, "upstream holds tab changes while a picker is pending");
assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_dialog_open"], false);
assert!(Arc::ptr_eq(&other, &h.app.session.doc));
assert_eq!(&std::fs::read(&path).unwrap()[..8], b"\x89PNG\r\n\x1a\n");
crate::documents::switch(&mut h.app, 0);
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
assert!(!h.app.session.is_dirty());
}
#[test]
fn delayed_image_export_cancel_and_overwrite_choice_preserve_existing_files() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("existing.png");
std::fs::write(&path, b"existing contents").unwrap();
let doc = h.app.session.doc.clone();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
*slot.borrow_mut() = Some(None);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!h.app.file_picker_busy());
assert_eq!(std::fs::read(&path).unwrap(), b"existing contents");
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
h.app.render_workspace.view.overwrite = true;
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert_eq!(std::fs::read(&path).unwrap(), b"existing contents", "a later checkbox change is not overwrite consent for the pending export");
assert!(matches!(&h.app.status, Some((_, _, true))));
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.execute("render.status", &json!({"job":job})).unwrap()["progress"]["phase"], "completed");
}
#[test]
fn delayed_image_export_never_substitutes_a_newer_retained_image() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("old-image.png");
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
let newer = completed_preview(&mut h, [20, 30, 40]);
h.app.render_workspace.view.completed = Some(newer);
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!path.exists(), "the evicted requested image must fail instead of exporting the newer image");
assert!(matches!(&h.app.status, Some((_, _, true))));
assert_eq!(h.app.render_workspace.view.completed, Some(newer));
let newer_path = h.dir.join("new-image.png");
h.app.run("render.export", json!({"job":newer,"path":newer_path})).unwrap();
assert!(newer_path.is_file());
}
#[test]
fn delayed_image_export_holds_close_and_new_document_until_cancel() {
use crate::picker_test_support::{Slot, save};
let (mut h, job) = async_export_fixture();
let path = h.dir.join("closed.png");
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, false);
crate::file_picker::tick(&mut h.app, &ctx);
crate::documents::new_design(&mut h.app);
crate::documents::close(&mut h.app, 0, true);
let other = h.app.session.doc.clone();
assert_eq!(h.app.docs.count(), 1);
assert!(h.app.file_picker_busy());
*slot.borrow_mut() = Some(None);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!path.exists() && !h.app.file_picker_busy());
assert!(Arc::ptr_eq(&other, &h.app.session.doc));
}
#[test]
fn delayed_image_export_refuses_an_internally_replaced_document_even_with_overwrite() {
use crate::picker_test_support::{Slot, save};
for overwrite in [false, true] {
let (mut h, job) = async_export_fixture();
let path = h.dir.join("keep.png");
std::fs::write(&path, b"keep this file").unwrap();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
export_image(&mut h.app, job, overwrite);
assert!(h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[1,2,3]})).is_err());
// Internal host/session changes are outside the input hold; never export them by accident.
h.app.session.execute("appearance.assign", &json!({"bodies":["Body1"],"color":[1,2,3]})).unwrap();
let changed = h.app.session.doc.clone();
let history = (h.app.session.undo.len(), h.app.session.revision);
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &egui::Context::default());
assert_eq!(std::fs::read(path).unwrap(), b"keep this file");
assert!(Arc::ptr_eq(&changed, &h.app.session.doc));
assert_eq!((h.app.session.undo.len(), h.app.session.revision), history);
assert!(matches!(&h.app.status, Some((_, _, true))));
}
}