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 <35337584+0xlillith@users.noreply.github.com>
This commit is contained in:
1 parent
e4ef6cfe29
commit
a261aaa841
70 files changed
+5860
-101
No files matched your search
@@ -0,0 +1,219 @@
|
||||
use super::*;
|
||||
use solidcraft_render::job::{CpuHost, JobId};
|
||||
|
||||
fn session() -> (Session, CpuHost) {
|
||||
let host = CpuHost::cooperative();
|
||||
let mut s = Session::default();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
(s, host)
|
||||
}
|
||||
|
||||
fn request() -> Value {
|
||||
json!({"settings":{"width":16,"height":16}})
|
||||
}
|
||||
fn start(s: &mut Session) -> JobId {
|
||||
serde_json::from_value(s.execute("render.start", &request()).unwrap()["job"].clone()).unwrap()
|
||||
}
|
||||
fn finish(host: &CpuHost) {
|
||||
for _ in 0..1000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("cooperative job did not finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_commands_preserve_document_history_and_evaluation() {
|
||||
let (mut s, host) = session();
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len());
|
||||
s.execute("render.preflight", &request()).unwrap();
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
let id = start(&mut s);
|
||||
assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "queued");
|
||||
finish(&host);
|
||||
let status = s.execute("render.status", &json!({"job":id})).unwrap();
|
||||
assert_eq!(status["progress"]["phase"], "completed");
|
||||
assert_eq!(status["frame"]["job"], json!(id));
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()));
|
||||
assert_eq!(s.execute("render.cancel", &json!({"job":id})).unwrap()["progress"]["phase"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_are_isolated_across_documents_scratch_and_new() {
|
||||
let (mut a, host) = session();
|
||||
let mut b = Session::default();
|
||||
b.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let a_id = start(&mut a);
|
||||
let b_id = start(&mut b);
|
||||
assert_eq!(a_id.serial, b_id.serial);
|
||||
assert_ne!(a_id.session, b_id.session);
|
||||
assert!(b.execute("render.cancel", &json!({"job":a_id})).is_err());
|
||||
assert!(a.scratch().execute("render.status", &json!({"job":a_id})).is_err());
|
||||
a.execute("file.new", &json!({})).unwrap();
|
||||
assert!(a.render_job_host().unwrap().same_host(&host));
|
||||
assert!(a.execute("render.status", &json!({"job":a_id})).is_err());
|
||||
finish(&host);
|
||||
assert_eq!(b.execute("render.status", &json!({"job":b_id})).unwrap()["progress"]["phase"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_cancellation_and_replacement_keep_the_last_successful_image() {
|
||||
let (mut s, host) = session();
|
||||
let good = start(&mut s);
|
||||
finish(&host);
|
||||
s.execute("render.status", &json!({"job":good})).unwrap();
|
||||
let a = start(&mut s);
|
||||
host.pump(1).unwrap();
|
||||
start(&mut s);
|
||||
assert!(s.execute("render.start", &request()).is_err());
|
||||
s.execute("render.cancel", &json!({"job":a})).unwrap();
|
||||
let replacement = s.execute("render.start", &json!({"settings":{"width":16,"height":16},"replace":true})).unwrap();
|
||||
s.execute("render.cancel", &json!({"job":replacement["job"]})).unwrap();
|
||||
finish(&host);
|
||||
assert!(s.render_jobs.frame(good).is_ok());
|
||||
assert_eq!(s.execute("render.status", &json!({"job":a})).unwrap()["progress"]["phase"], "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_intent_is_rejected_and_export_preserves_existing_files() {
|
||||
let (mut s, host) = session();
|
||||
for params in [
|
||||
json!({"settings":{"mode":"final"}}),
|
||||
json!({"settings":{"format":"exr"}}),
|
||||
json!({"settings":{"backend":"missing"}}),
|
||||
json!({"settings":{"exposure_ev":1}}),
|
||||
json!({"unknown":true}),
|
||||
] {
|
||||
assert!(s.execute("render.preflight", ¶ms).is_err());
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
let id = start(&mut s);
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-job-{}-{}.png", std::process::id(), id.session));
|
||||
assert!(s.execute("render.export", &json!({"job":id,"path":path})).is_err());
|
||||
finish(&host);
|
||||
let missing = path.with_file_name("solidcraft-render-missing-directory").join("image.png");
|
||||
assert!(s.execute("render.export", &json!({"job":id,"path":missing})).is_err());
|
||||
assert!(s.render_jobs.frame(id).is_ok());
|
||||
s.execute("render.export", &json!({"job":id,"path":path})).unwrap();
|
||||
let png = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
|
||||
assert!(s.execute("render.export", &json!({"job":id,"path":path})).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), png);
|
||||
s.execute("render.export", &json!({"job":id,"path":path,"overwrite":true})).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), png);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_close_and_host_replacement_cancel_work_and_reject_old_ids() {
|
||||
let (mut s, host) = session();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-job-open-{}.solidcraft", std::process::id()));
|
||||
s.execute("file.save", &json!({"path":path})).unwrap();
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
s.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
assert!(s.execute("render.status", &json!({"job":id})).is_err());
|
||||
assert!(s.render_job_host().unwrap().same_host(&host));
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
s.replace_design(Session::default());
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
assert!(s.execute("render.status", &json!({"job":id})).is_err());
|
||||
assert!(s.render_job_host().unwrap().same_host(&host));
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
let replacement = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(replacement.clone())).unwrap();
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
assert!(s.execute("render.status", &json!({"job":id})).is_err());
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
drop(s);
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
finish(&host);
|
||||
finish(&replacement);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_image_is_immutable_across_edit_and_undo_and_stale_preflight_fails() {
|
||||
let (mut s, host) = session();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let revision = s.revision;
|
||||
let id = start(&mut s);
|
||||
s.execute("solid.box", &json!({"length":3,"width":4,"height":5})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
finish(&host);
|
||||
let status = s.execute("render.status", &json!({"job":id})).unwrap();
|
||||
assert_eq!(status["scene_generation"], revision);
|
||||
assert_eq!(status["progress"]["phase"], "completed");
|
||||
assert_ne!(s.revision, revision);
|
||||
// Reuse the real live-gesture regression rather than manufacturing model state.
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance":5})),
|
||||
("sketch.edit", json!({"sketch":"Sketch1"})),
|
||||
("sketch.drag", json!({"entity":"l1.start","from":[5,5],"to":[4,5],"live":true})),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
}
|
||||
assert!(s.model.is_stale());
|
||||
let evaluations = s.evaluations;
|
||||
assert!(s.execute("render.preflight", &request()).is_err());
|
||||
assert!(s.execute("render.start", &request()).is_err());
|
||||
assert!(s.model.is_stale());
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_start_and_cancel_measure_cached_capture_without_timing_assertions() {
|
||||
use std::time::{Duration, Instant};
|
||||
let mut s = Session::default();
|
||||
s.set_render_job_host(Some(CpuHost::for_platform().unwrap())).unwrap();
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
s.execute("render.scene", &json!({})).unwrap();
|
||||
let before = Instant::now();
|
||||
let reply = s.execute("render.start", &json!({})).unwrap();
|
||||
let start_time = before.elapsed();
|
||||
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let before = Instant::now();
|
||||
s.execute("render.cancel", &json!({"job":id})).unwrap();
|
||||
let cancel_request_time = before.elapsed();
|
||||
let status = s.render_job_handle(id).unwrap().wait_timeout(Duration::from_secs(60)).unwrap();
|
||||
let cancellation_time = before.elapsed();
|
||||
assert!(matches!(status.progress.phase, solidcraft_render::job::Phase::Cancelled | solidcraft_render::job::Phase::Completed));
|
||||
if status.progress.phase == solidcraft_render::job::Phase::Cancelled {
|
||||
assert!(status.frame.is_none());
|
||||
}
|
||||
eprintln!(
|
||||
"cached one-box 1024x768: start={start_time:?}; cancel request={cancel_request_time:?}; cancellation acknowledged={cancellation_time:?}"
|
||||
);
|
||||
let reply = s.execute("render.start", &json!({})).unwrap();
|
||||
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
let watch = Instant::now();
|
||||
let observed = loop {
|
||||
let phase = handle.status().unwrap().progress.phase;
|
||||
if phase == solidcraft_render::job::Phase::Running || phase.terminal() {
|
||||
break phase;
|
||||
}
|
||||
assert!(watch.elapsed() < Duration::from_secs(60), "native worker did not advance");
|
||||
std::thread::yield_now();
|
||||
};
|
||||
let before = Instant::now();
|
||||
s.execute("render.cancel", &json!({"job":id})).unwrap();
|
||||
let request_time = before.elapsed();
|
||||
let status = handle.wait_timeout(Duration::from_secs(60)).unwrap();
|
||||
eprintln!(
|
||||
"native cancellation observed phase={observed:?}: request={request_time:?}; acknowledged={:?}; terminal={:?}",
|
||||
before.elapsed(),
|
||||
status.progress.phase
|
||||
);
|
||||
assert!(status.progress.phase.terminal());
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::{Result, Session, params::bad, render_jobs::job_error};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::render_settings::RenderSetup;
|
||||
use solidcraft_render::{
|
||||
RenderOperation, RenderRequest,
|
||||
job::{ImageRequest, JobId},
|
||||
};
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct Start {
|
||||
setup: Option<String>,
|
||||
settings: Option<RenderSetup>,
|
||||
transparent_background: bool,
|
||||
replace: bool,
|
||||
}
|
||||
|
||||
fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcraft_render::ProductScene, ImageRequest, Value)> {
|
||||
let input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.setup.is_some() && input.settings.is_some() {
|
||||
return Err(bad(command, "choose either setup or settings"));
|
||||
}
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let settings = if let Some(name) = &input.setup {
|
||||
s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))?
|
||||
} else if let Some(settings) = &input.settings {
|
||||
settings.clone()
|
||||
} else {
|
||||
let mut settings = RenderSetup::default();
|
||||
if let Some(camera) = s.camera {
|
||||
settings.camera = camera;
|
||||
} else {
|
||||
settings.camera.fit(&scene.bounds);
|
||||
}
|
||||
settings
|
||||
};
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
|
||||
return Err(bad(command, "exposure and environment controls are not supported by the R4a CPU image executor"));
|
||||
}
|
||||
let host = s.render_job_host().ok_or_else(|| bad(command, "no image host is installed"))?;
|
||||
if !host.is_available() {
|
||||
return Err(bad(command, "image host is unavailable"));
|
||||
}
|
||||
let resolved = solidcraft_render::resolve_request(
|
||||
&[s.render_backend()],
|
||||
&RenderRequest {
|
||||
backend: Some(settings.backend.clone()),
|
||||
mode: settings.mode,
|
||||
operation: RenderOperation::Image,
|
||||
width: u64::from(settings.width),
|
||||
height: u64::from(settings.height),
|
||||
format: Some(settings.format),
|
||||
features: scene.required_features(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
if resolved.executor.as_str() != "cpu-product" {
|
||||
return Err(bad(command, "resolved executor is not installed as an image job host"));
|
||||
}
|
||||
let request = ImageRequest {
|
||||
camera: settings.camera,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
};
|
||||
let admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
|
||||
let reply = json!({"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":"sorted_alpha_approximation","refraction":false});
|
||||
Ok((input, scene, request, reply))
|
||||
}
|
||||
|
||||
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
prepare(s, params, "render.preflight").map(|(_, _, _, reply)| reply)
|
||||
}
|
||||
|
||||
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let (input, scene, request, _) = prepare(s, params, "render.start")?;
|
||||
let status = s.render_jobs.start(s.revision, scene, request, input.replace).map_err(job_error)?;
|
||||
Ok(json!(status))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Job {
|
||||
job: JobId,
|
||||
}
|
||||
|
||||
pub(super) fn status(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Job = serde_json::from_value(params.clone()).map_err(|e| bad("render.status", e.to_string()))?;
|
||||
Ok(json!(s.render_jobs.status(input.job).map_err(job_error)?))
|
||||
}
|
||||
|
||||
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Job = serde_json::from_value(params.clone()).map_err(|e| bad("render.cancel", e.to_string()))?;
|
||||
let status = s.render_jobs.status(input.job).map_err(job_error)?;
|
||||
if status.progress.phase.terminal() {
|
||||
return Ok(json!(status));
|
||||
}
|
||||
Ok(json!(s.render_jobs.handle(input.job).map_err(job_error)?.cancel().map_err(job_error)?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Export {
|
||||
job: JobId,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Export = serde_json::from_value(params.clone()).map_err(|e| bad("render.export", e.to_string()))?;
|
||||
let path = std::path::Path::new(&input.path);
|
||||
if input.path.len() > 4096 || input.path.chars().any(char::is_control) || !path.extension().is_some_and(|x| x.eq_ignore_ascii_case("png")) {
|
||||
return Err(bad("render.export", "a bounded PNG destination path is required"));
|
||||
}
|
||||
// Atomic native writers are also used for the app's own files and do not
|
||||
// apply a request's path scope. Check the caller's destination explicitly.
|
||||
solidcraft_io::vfs::check(&input.path).map_err(|e| bad("render.export", e.to_string()))?;
|
||||
let frame = s.render_jobs.frame(input.job).map_err(job_error)?;
|
||||
// Replace atomically without reading an arbitrarily large previous target into memory.
|
||||
// Design saves retain their independent backup policy.
|
||||
let result = if input.overwrite {
|
||||
solidcraft_io::write_atomic(path, frame.png(), false)
|
||||
} else {
|
||||
solidcraft_io::safe_write::create_atomic(path, frame.png())
|
||||
};
|
||||
result.map_err(|e| bad("render.export", format!("could not export PNG: {e}")))?;
|
||||
Ok(json!({"job":input.job,"path":input.path,"bytes":frame.png().len(),"frame":frame.info}))
|
||||
}
|
||||
Reference in new issue
Block a user