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]>
This commit is contained in:
1 parent
e4ef6cfe29
commit
a261aaa841
70 files changed
+5860
-101
No files matched your search
@@ -159,7 +159,10 @@ fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
s.doc_mut().appearances.components.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
|
||||
s.doc_mut()
|
||||
.appearances
|
||||
.components
|
||||
.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7, pbr: None });
|
||||
s.refresh();
|
||||
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
|
||||
@@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
),
|
||||
CommandSpec::new("appearance.library", "Appearance Library", appearance_library)
|
||||
.noundo()
|
||||
@@ -341,7 +341,7 @@ pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_doc::appearance::{FaceLook, Look, library};
|
||||
let cmd = "appearance.assign";
|
||||
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
|
||||
let mut look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
|
||||
// A colour with a name: that colour, called that (a name not in the library is fine).
|
||||
(Some(n), Some(v)) if !v.is_null() => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
@@ -367,6 +367,11 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Look::custom(c, o))
|
||||
}
|
||||
};
|
||||
if let Some(l) = &mut look {
|
||||
attach_pbr(cmd, p, l)?;
|
||||
} else if p.get("pbr").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`pbr` requires a color or appearance"));
|
||||
}
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
@@ -466,7 +471,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"bodies": bodies,
|
||||
"faces": faces.iter().map(|(b, p)| json!({"body": b, "point": p})).collect::<Vec<_>>(),
|
||||
"components": comps,
|
||||
"appearance": look.as_ref().map(|l| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})),
|
||||
"appearance": look.as_ref().map(look_json),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -477,7 +482,40 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
|
||||
}
|
||||
|
||||
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
|
||||
let mut value = json!({"name": l.name, "color": l.hex(), "opacity": l.opacity});
|
||||
if let Some(pbr) = l.pbr {
|
||||
value["pbr"] = json!(pbr);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pbr_appearance_tests.rs"]
|
||||
mod pbr_tests;
|
||||
|
||||
pub(super) fn attach_pbr(cmd: &str, params: &Value, look: &mut solidcraft_doc::appearance::Look) -> Result<()> {
|
||||
let Some(value) = params.get("pbr") else { return Ok(()) };
|
||||
if value.is_null() {
|
||||
look.pbr = None;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(opacity) = params.get("opacity")
|
||||
&& opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "opaque PBR requires numeric opacity 1"));
|
||||
}
|
||||
let object = value
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
|
||||
.ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?;
|
||||
let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number")));
|
||||
let response = solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?)
|
||||
.map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if look.opacity != 1.0 {
|
||||
return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported"));
|
||||
}
|
||||
look.pbr = Some(response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -524,7 +562,13 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let in_design: Vec<Value> = used
|
||||
.iter()
|
||||
.map(|(l, b, f, c)| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity, "bodies": b, "faces": f, "components": c}))
|
||||
.map(|(l, b, f, c)| {
|
||||
let mut value = look_json(l);
|
||||
value["bodies"] = json!(b);
|
||||
value["faces"] = json!(f);
|
||||
value["components"] = json!(c);
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"in_design": in_design,
|
||||
|
||||
@@ -218,3 +218,35 @@ fn catalog_inspection_export_and_no_clobber_do_not_touch_design_or_history() {
|
||||
assert!(s.execute(command, &json!({"path":file.0,"unknown":true})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_scalar_export_never_creates_or_replaces_a_file_or_changes_history() {
|
||||
let mut s = session();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Body1"],"color":[180,120,70],
|
||||
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.mark_saved();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for existing in [false, true] {
|
||||
let file = File::new();
|
||||
if existing {
|
||||
std::fs::write(&file.0, b"preserve existing library").unwrap();
|
||||
}
|
||||
for overwrite in [false, true] {
|
||||
let error = s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite})).unwrap_err();
|
||||
assert!(error.to_string().contains("cannot preserve PBR"));
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&file.0).unwrap(), b"preserve existing library");
|
||||
} else {
|
||||
assert!(!std::path::Path::new(&file.0).exists());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,10 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. Assign it with material.assign",
|
||||
),
|
||||
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look",
|
||||
),
|
||||
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
|
||||
@@ -60,6 +60,9 @@ fn density(cmd: &str, p: &Value) -> Result<f64> {
|
||||
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
|
||||
/// to clear it (`color: null`).
|
||||
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
if p.get("pbr").is_some() && str_(p, "appearance").is_none() && p.get("color").is_none_or(Value::is_null) {
|
||||
return Err(bad(cmd, "`pbr` requires an appearance or color"));
|
||||
}
|
||||
let opacity = match p.get("opacity") {
|
||||
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
|
||||
None => None,
|
||||
@@ -79,6 +82,7 @@ fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
if let Some(o) = opacity {
|
||||
l.opacity = o;
|
||||
}
|
||||
super::inspect::attach_pbr(cmd, p, &mut l)?;
|
||||
Ok(Some(Some(l)))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Session, product_scene};
|
||||
|
||||
fn fixture() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
|
||||
let mut s = fixture();
|
||||
s.execute("appearance.assign", &json!({"bodies":["Specimen"],"appearance":"Original legacy look","color":"#b47846","opacity":0.4})).unwrap();
|
||||
let before = serde_json::to_value(&s.doc.appearances).unwrap();
|
||||
let mut legacy = serde_json::to_value(&*s.doc).unwrap();
|
||||
legacy["format"] = json!("solidcraft/20");
|
||||
legacy.as_object_mut().unwrap().remove("render");
|
||||
let upgraded = crate::doc::Document::from_json(&legacy.to_string()).unwrap();
|
||||
assert_eq!(upgraded.format, "solidcraft/21");
|
||||
assert_eq!(serde_json::to_value(&upgraded.appearances).unwrap(), before);
|
||||
assert!(upgraded.render.setups.is_empty());
|
||||
s.execute("render.settings.set", &json!({"name":"Studio","set":{"width":320,"height":240}})).unwrap();
|
||||
let mut saved: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solidcraft/21");
|
||||
let d = crate::doc::Document::from_json(&saved.to_string()).unwrap();
|
||||
assert_eq!(d.format, crate::doc::format::format_string(crate::doc::format::FORMAT));
|
||||
assert_eq!(serde_json::to_value(&d.appearances).unwrap(), before);
|
||||
assert!(d.appearances.bodies["Specimen"].pbr.is_none());
|
||||
assert_eq!(d.render.setups["Studio"].width, 320);
|
||||
for version in [22, 23] {
|
||||
saved["format"] = json!(format!("solidcraft/{version}"));
|
||||
assert!(crate::doc::Document::from_json(&saved.to_string()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_response_preserves_geometry_mass_undo_and_cold_reopening() {
|
||||
let mut s = fixture();
|
||||
let before = product_scene::extract(&s).unwrap();
|
||||
let mass = s.execute("component.properties", &json!({})).unwrap();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Specimen"],"color":"#b47846","appearance":"Original opaque test",
|
||||
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
let after = product_scene::extract(&s).unwrap();
|
||||
assert!(Arc::ptr_eq(&before.meshes[0].mesh, &after.meshes[0].mesh));
|
||||
assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
assert!(after.meshes[0].surface.is_some());
|
||||
assert_eq!(after.required_features(), vec![crate::render::RenderFeature::Pbr]);
|
||||
let saved = s.doc.to_json();
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&saved).unwrap()["format"], "solidcraft/21");
|
||||
let look = &s.doc.appearances.bodies["Specimen"];
|
||||
assert_eq!(crate::doc::config::appearance_cell(&serde_json::to_string(look).unwrap()).unwrap().as_ref(), Some(look));
|
||||
let list = s.execute("appearance.list", &json!({})).unwrap();
|
||||
assert_eq!(list["in_design"][0]["pbr"]["metallic"], 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(product_scene::extract(&s).unwrap().meshes[0].surface.is_none());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(product_scene::extract(&s).unwrap().meshes[0].surface.is_some());
|
||||
let mut reopened = Session::new(crate::doc::Document::from_json(&saved).unwrap());
|
||||
assert_eq!(product_scene::extract(&reopened).unwrap().meshes[0].surface, after.meshes[0].surface);
|
||||
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_optical_requests_refuse_atomically_and_scalar_export_cannot_lose_them() {
|
||||
let mut s = fixture();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for params in [
|
||||
json!({"bodies":["Specimen"],"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"color":"#ffffff","pbr":{"metallic":0,"roughness":0,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"color":"#ffffff","opacity":0.5,"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"appearance":"Named","color":"#ffffff","opacity":"invalid","pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"appearance":"Named","color":"#ffffff","opacity":2,"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"color":"#ffffff","pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5,"transmission":1}}),
|
||||
] {
|
||||
assert!(s.execute("appearance.assign", ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
}
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Specimen"],"color":"#ffffff",
|
||||
"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
|
||||
let mut invalid: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(0.5);
|
||||
assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err());
|
||||
invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(1.0);
|
||||
invalid["appearances"]["bodies"]["Specimen"]["pbr"]["roughness"] = json!(0.0);
|
||||
assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_default_look_and_face_override_keep_independent_optical_meaning() {
|
||||
let mut s = fixture();
|
||||
s.execute(
|
||||
"material.create",
|
||||
&json!({"name":"Original density fixture","density_g_cm3":0.7,"color":"#b47846",
|
||||
"pbr":{"metallic":0,"roughness":0.4,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Specimen"],"material":"Original density fixture"})).unwrap();
|
||||
let scene = product_scene::extract(&s).unwrap();
|
||||
assert!(scene.meshes[0].surface.is_some());
|
||||
s.execute("appearance.assign", &json!({"faces":[[5,10,5]],"body":"Specimen","color":"#0080a0"})).unwrap();
|
||||
let scene = product_scene::extract(&s).unwrap();
|
||||
assert!(scene.meshes[0].surface.is_some());
|
||||
assert!(scene.meshes[0].faces.iter().any(|f| f.color == [0, 128, 160] && f.surface.is_none()));
|
||||
assert_eq!(s.doc.user_materials[0].density, 0.7);
|
||||
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_component_copy_keeps_its_opaque_response_after_source_edits_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
let part = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
|
||||
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"components":["Part"],"color":"#b47846",
|
||||
"pbr":{"metallic":1,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
let copied = s.execute("component.paste_new", &json!({"component":part,"translate":[30,0,0]})).unwrap()["component"].as_u64().unwrap();
|
||||
let original = s.doc.appearances.components[&part].clone();
|
||||
assert_eq!(s.doc.appearances.components[&copied], original);
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"components":["Part"],"color":"#b47846",
|
||||
"pbr":{"metallic":1,"roughness":0.8,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(s.doc.appearances.components[&part], original);
|
||||
assert_eq!(s.doc.appearances.components[&copied], original);
|
||||
let reopened = crate::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.appearances.components[&copied], original);
|
||||
}
|
||||
@@ -10,7 +10,19 @@ use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
mod jobs;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).noundo().doc_only()
|
||||
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("box")
|
||||
.params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.status", "Render Progress", jobs::status).noundo().doc_only()
|
||||
.params("job: {session, serial, generation}. Non-blocking progress, error and completed frame metadata; no image bytes and no rendering in this call."),
|
||||
CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).noundo().doc_only()
|
||||
.params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."),
|
||||
CommandSpec::new("render.export", "Export Render Image", jobs::export).noundo().doc_only()
|
||||
.params("job: {session, serial, generation}, path: PNG destination, overwrite?: false. Exports a retained completed image atomically; an existing destination is preserved on failure. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.backends", "Render Backends", backends)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
@@ -19,8 +31,37 @@ CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo(
|
||||
.params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"),
|
||||
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).doc_only()
|
||||
.params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background). Creates from defaults, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"),
|
||||
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("box")
|
||||
.params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"),
|
||||
CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).noundo().doc_only()
|
||||
.params("name: saved setup. Applies its existing Camera snapshot including roll/up as view state, without modifying the document or starting a render."),
|
||||
];
|
||||
|
||||
fn scene(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !params.as_object().is_some_and(|p| p.is_empty()) {
|
||||
return Err(bad("render.scene", "expected an empty parameter object"));
|
||||
}
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let bodies: Vec<_> = scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|m| json!({"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()}))
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"bodies":bodies,"bounds":if scene.bounds.is_empty() { Value::Null } else { json!({"min":scene.bounds.min,"max":scene.bounds.max}) },"overlays":false}),
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad("render.settings.apply", e.to_string()))?;
|
||||
let name = request.name.ok_or_else(|| bad("render.settings.apply", "`name` is required"))?;
|
||||
RenderSettings::validate_name(&name).map_err(|e| bad("render.settings.apply", e.to_string()))?;
|
||||
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad("render.settings.apply", "no saved render setup with that name"))?;
|
||||
setup.validate().map_err(|e| bad("render.settings.apply", e.to_string()))?;
|
||||
s.camera = Some(setup.camera);
|
||||
Ok(json!({"name":name,"camera":setup.camera}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct GetSettings {
|
||||
@@ -71,11 +112,11 @@ fn set_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn backends(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
fn backends(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !params.as_object().is_some_and(|p| p.is_empty()) {
|
||||
return Err(bad("render.backends", "expected an empty parameter object"));
|
||||
}
|
||||
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [crate::render::builtin_backend()]}))
|
||||
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [s.render_backend()]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -152,3 +193,6 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod settings_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod job_tests;
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -15,7 +15,9 @@ pub mod fuzz;
|
||||
pub mod licences;
|
||||
pub mod occurrences;
|
||||
pub mod params;
|
||||
pub mod product_scene;
|
||||
pub mod recovery;
|
||||
pub mod render_jobs;
|
||||
pub mod sample;
|
||||
pub mod view;
|
||||
|
||||
@@ -228,6 +230,9 @@ pub struct Session {
|
||||
/// moves its viewport there and takes it; headless renders keep using it until a standard
|
||||
/// view is chosen.
|
||||
pub camera: Option<render::Camera>,
|
||||
/// Host-owned viewport metadata, never saved or included in history.
|
||||
render_viewport: Option<Arc<render::ExecutorDescriptor>>,
|
||||
render_jobs: render_jobs::Jobs,
|
||||
/// The visibility the design file holds (what `is_dirty` compares with).
|
||||
saved_visibility: Visibility,
|
||||
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||
@@ -324,6 +329,8 @@ impl Session {
|
||||
saved_visibility: visibility.clone(),
|
||||
visibility,
|
||||
camera: None,
|
||||
render_viewport: None,
|
||||
render_jobs: Default::default(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
crash_journal: false,
|
||||
@@ -334,8 +341,12 @@ impl Session {
|
||||
/// are the user's rather than the design's: `auto_project` is a preference (#680).
|
||||
pub fn replace_design(&mut self, fresh: Session) {
|
||||
let auto_project = self.auto_project;
|
||||
let render_viewport = self.render_viewport.clone();
|
||||
let render_jobs = self.render_jobs.scratch();
|
||||
*self = fresh;
|
||||
self.auto_project = auto_project;
|
||||
self.render_viewport = render_viewport;
|
||||
self.render_jobs = render_jobs;
|
||||
}
|
||||
|
||||
/// The design has changes not in its file: in the document, or in what is hidden.
|
||||
@@ -347,6 +358,56 @@ impl Session {
|
||||
self.saved_visibility = self.visibility.clone();
|
||||
}
|
||||
|
||||
pub fn render_backend(&self) -> render::BackendDescriptor {
|
||||
let mut backend = render::builtin_backend();
|
||||
if let Some(viewport) = &self.render_viewport {
|
||||
backend.executors.push((**viewport).clone());
|
||||
}
|
||||
if let Some(executor) = &self.render_jobs.descriptor {
|
||||
let mut executor = executor.clone();
|
||||
if self.render_jobs.host.as_ref().is_none_or(|host| !host.is_available()) {
|
||||
executor.availability = render::Availability::Unavailable { reason: "CPU image host is unavailable".into() };
|
||||
}
|
||||
backend.executors.push(executor);
|
||||
}
|
||||
backend
|
||||
}
|
||||
|
||||
pub fn render_viewport(&self) -> Option<&render::ExecutorDescriptor> {
|
||||
self.render_viewport.as_deref()
|
||||
}
|
||||
|
||||
/// Installed hosts supply pure metadata; document commands cannot invent a device.
|
||||
pub fn set_render_viewport(&mut self, viewport: Option<render::ExecutorDescriptor>) -> Result<()> {
|
||||
if self.render_viewport.as_deref() == viewport.as_ref() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(executor) = &viewport {
|
||||
if executor.profiles.is_empty() || executor.profiles.iter().any(|p| p.operation != render::RenderOperation::Viewport) {
|
||||
return Err(EngineError::Other("host viewport executor must contain only viewport profiles".into()));
|
||||
}
|
||||
let mut backend = render::builtin_backend();
|
||||
if let Some(executor) = &self.render_jobs.descriptor {
|
||||
backend.executors.push(executor.clone());
|
||||
}
|
||||
backend.executors.push(executor.clone());
|
||||
// Resolution validates the whole registry, including duplicate executor IDs.
|
||||
let request = render::RenderRequest {
|
||||
backend: None,
|
||||
mode: render::RenderMode::Preview,
|
||||
operation: render::RenderOperation::Image,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: Some(render::RenderFormat::Png),
|
||||
features: Vec::new(),
|
||||
policy: Default::default(),
|
||||
};
|
||||
render::resolve_request(&[backend], &request).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
self.render_viewport = viewport.map(Arc::new);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The design as a file holds it: the document with what is hidden in it.
|
||||
pub fn design(&self) -> std::borrow::Cow<'_, Document> {
|
||||
if self.visibility.is_empty() {
|
||||
@@ -549,6 +610,8 @@ impl Session {
|
||||
/// the model up to date before anything reads it.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let host_viewport = self.render_viewport.clone();
|
||||
let host_jobs = self.render_jobs.host.clone();
|
||||
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
|
||||
if live.is_none() && spec.reads_model && self.model.is_stale() {
|
||||
self.refresh();
|
||||
@@ -584,6 +647,12 @@ impl Session {
|
||||
Err(EngineError::Internal(spec.id.to_string(), msg))
|
||||
}
|
||||
};
|
||||
// New/open/recover can replace the Session. Device ownership belongs to the host,
|
||||
// so its capability metadata survives those document lifecycle commands.
|
||||
self.render_viewport = host_viewport;
|
||||
if self.render_jobs.host.is_none() && host_jobs.is_some() {
|
||||
self.set_render_job_host(host_jobs)?;
|
||||
}
|
||||
if outermost && crash::enabled() {
|
||||
// A failed command leaves the design as it was (restored below).
|
||||
let failed = r.as_ref().err().map(ToString::to_string);
|
||||
@@ -724,6 +793,8 @@ impl Session {
|
||||
next_checkpoint: 1,
|
||||
visibility: self.visibility.clone(),
|
||||
camera: self.camera,
|
||||
render_viewport: self.render_viewport.clone(),
|
||||
render_jobs: self.render_jobs.scratch(),
|
||||
saved_visibility: self.saved_visibility.clone(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Product extraction shares evaluated placement and appearance resolution, not viewport policy.
|
||||
|
||||
use crate::geom::Aabb3;
|
||||
use crate::render::{FaceAppearance, ProductMesh, ProductScene};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub const MAX_PRODUCT_BODIES: usize = 16_384;
|
||||
|
||||
/// Extract authoritative, visible geometry. Does not settle a live gesture implicitly.
|
||||
/// `render.scene` is the explicit model-reading command for that boundary.
|
||||
pub fn extract(s: &Session) -> Result<ProductScene> {
|
||||
if s.model.is_stale() {
|
||||
return Err(EngineError::Other("render scene is stale; finish the modelling gesture before previewing".into()));
|
||||
}
|
||||
let world = s.world_state();
|
||||
let mut scene = ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY };
|
||||
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
|
||||
if scene.meshes.len() >= MAX_PRODUCT_BODIES {
|
||||
return Err(EngineError::Other("render scene exceeds 16384 visible bodies".into()));
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
scene.bounds = scene.bounds.union(&mesh.bounds());
|
||||
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let color = s.doc.body_color(body).map(|c| c.map(to8)).unwrap_or([176, 186, 198]);
|
||||
let faces = s
|
||||
.doc
|
||||
.face_colors(body)
|
||||
.into_iter()
|
||||
.map(|(face, look)| {
|
||||
Ok(FaceAppearance {
|
||||
face: u32::try_from(face).map_err(|_| EngineError::Other("render face index exceeds the mesh contract".into()))?,
|
||||
color: look.color,
|
||||
opacity: look.opacity.clamp(0.0, 1.0),
|
||||
surface: look.opaque_surface().map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let surface = s.doc.model_body_look(body).map(|l| l.opaque_surface()).transpose().map_err(|e| EngineError::Other(e.to_string()))?.flatten();
|
||||
scene.meshes.push(ProductMesh {
|
||||
name: body.name.clone(),
|
||||
mesh,
|
||||
color,
|
||||
opacity: s.doc.model_body_opacity(body).clamp(0.0, 1.0),
|
||||
surface,
|
||||
faces,
|
||||
});
|
||||
}
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Sel;
|
||||
use crate::geom::Vec3;
|
||||
use crate::render::*;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn product_scene_keeps_placed_appearances_and_meshes_without_design_overlays() {
|
||||
let mut s = Session::default();
|
||||
let component = s.execute("component.create", &json!({"name":"Part"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"bodies":["Block"],"color":"#20a080","opacity":0.4})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"faces":[[5,5,10]],"color":"#ff0000","opacity":0.7})).unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
s.execute("occurrence.copy", &json!({"component":component["component"],"translate":[30,0,0]})).unwrap();
|
||||
s.execute("occurrence.move", &json!({"occurrence":component["occurrence"],"translate":[10,0,0]})).unwrap();
|
||||
let doc = s.doc.clone();
|
||||
s.evaluations = 0;
|
||||
let first = extract(&s).unwrap();
|
||||
assert_eq!(first.meshes.len(), 2);
|
||||
assert_eq!(first.bounds.min, Vec3::new(10.0, 0.0, 0.0));
|
||||
assert_eq!(first.bounds.max, Vec3::new(40.0, 10.0, 10.0));
|
||||
for m in &first.meshes {
|
||||
assert_eq!(m.color, [32, 160, 128]);
|
||||
assert_eq!(m.opacity, 0.4);
|
||||
assert!(m.faces.iter().any(|f| f.color == [255, 0, 0] && f.opacity == 0.7));
|
||||
assert!(Arc::ptr_eq(&m.mesh, &s.world_state().body(&m.name).unwrap().mesh()));
|
||||
}
|
||||
s.selection = vec![Sel::Body { name: first.meshes[0].name.clone() }];
|
||||
s.section = Some((Vec3::ZERO, Vec3::X));
|
||||
s.analysis = Some(crate::SurfaceAnalysis::Zebra { stripes: 10.0 });
|
||||
let after = extract(&s).unwrap();
|
||||
for (a, b) in first.meshes.iter().zip(&after.meshes) {
|
||||
assert!(Arc::ptr_eq(&a.mesh, &b.mesh));
|
||||
assert_eq!((a.color, a.opacity, &a.faces), (b.color, b.opacity, &b.faces));
|
||||
}
|
||||
s.active_component = component["component"].as_u64().unwrap();
|
||||
let active = extract(&s).unwrap();
|
||||
assert_eq!(active.bounds.min, first.bounds.min);
|
||||
assert_eq!(active.meshes[1].opacity, 0.4, "no active-component fading");
|
||||
s.visibility.hidden_bodies.push(first.meshes[1].name.clone());
|
||||
assert_eq!(extract(&s).unwrap().meshes.len(), 1);
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_geometry_requires_an_explicit_model_read_and_camera_apply_preserves_staleness() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})),
|
||||
("sketch.dimension", json!({"entities":["l1"],"value":20})),
|
||||
("sketch.dimension", json!({"entities":["l2"],"value":15})),
|
||||
("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(id, &p).unwrap();
|
||||
}
|
||||
assert!(s.model.is_stale());
|
||||
assert!(extract(&s).is_err());
|
||||
s.evaluations = 0;
|
||||
let camera = Camera { up: UpAxis::Y, roll: 0.25, ..Default::default() };
|
||||
s.execute("render.settings.set", &json!({"name":"Photo","set":{"camera":camera}})).unwrap();
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
s.execute("render.settings.apply", &json!({"name":"Photo"})).unwrap();
|
||||
assert_eq!(s.camera, Some(camera));
|
||||
assert!(s.model.is_stale());
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
let metadata = s.execute("render.scene", &json!({})).unwrap();
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.evaluations > 0);
|
||||
assert_eq!(metadata["overlays"], false);
|
||||
assert_eq!(metadata["bodies"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_capabilities_are_validated_runtime_only_and_survive_new_design() {
|
||||
let mut s = Session::default();
|
||||
let viewport = ExecutorDescriptor {
|
||||
id: ExecutorId::new("gpu-viewport").unwrap(),
|
||||
availability: Availability::Available,
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Viewport,
|
||||
formats: Vec::new(),
|
||||
features: Vec::new(),
|
||||
}],
|
||||
limits: RenderLimits::default(),
|
||||
scratch: ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 0 },
|
||||
};
|
||||
let doc = s.doc.clone();
|
||||
s.set_render_viewport(Some(viewport.clone())).unwrap();
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert!(!s.is_dirty());
|
||||
let backend = s.render_backend();
|
||||
s.replace_design(Session::default());
|
||||
assert_eq!(s.render_viewport(), Some(&viewport));
|
||||
assert_eq!(s.render_backend(), backend);
|
||||
assert_eq!(backend.executors.len(), 2);
|
||||
assert_eq!(s.scratch().render_backend(), backend);
|
||||
let mut invalid = viewport;
|
||||
invalid.id = ExecutorId::new("cpu-raster").unwrap();
|
||||
assert!(s.set_render_viewport(Some(invalid)).is_err());
|
||||
assert_eq!(s.render_backend(), backend);
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.render_backend(), backend);
|
||||
assert!(!s.doc.to_json().contains("gpu-viewport"));
|
||||
s.set_render_viewport(None).unwrap();
|
||||
assert_eq!(s.render_backend(), builtin_backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_looks_survive_product_extraction_and_design_overrides() {
|
||||
let body = crate::kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0))
|
||||
.unwrap()
|
||||
.with_color(Some([0.2, 0.6, 0.8]))
|
||||
.with_paint(Some(crate::kernel::Paint { opacity: 0.4, faces: Vec::new() }));
|
||||
let step = crate::kernel::step_export_bodies(
|
||||
&[crate::kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }],
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut document = crate::doc::Document::default();
|
||||
document
|
||||
.add_feature(crate::doc::FeatureKind::Import { file: "original-generated.step".into(), step: step.into(), components: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let mut s = Session::new(document);
|
||||
let first = extract(&s).unwrap();
|
||||
assert_eq!(first.meshes[0].color, [51, 153, 204]);
|
||||
assert!((first.meshes[0].opacity - 0.4).abs() < 1e-6);
|
||||
let name = first.meshes[0].name.clone();
|
||||
s.execute("appearance.assign", &json!({"bodies":[name],"color":"#336699","opacity":0.7})).unwrap();
|
||||
let changed = extract(&s).unwrap();
|
||||
assert_eq!(changed.meshes[0].color, [51, 102, 153]);
|
||||
assert_eq!(changed.meshes[0].opacity, 0.7);
|
||||
assert!(Arc::ptr_eq(&first.meshes[0].mesh, &changed.meshes[0].mesh));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Per-document, transient render ownership. Captured scenes remain immutable when CAD edits
|
||||
//! continue; callers get the captured revision and must never present it as a newer scene.
|
||||
|
||||
use solidcraft_render::{
|
||||
ExecutorDescriptor,
|
||||
job::{CpuHost, ErrorCode, JobError, JobHandle, JobId, JobStatus, Phase, RenderFrame},
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_OWNER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
fn owner() -> u64 {
|
||||
let mut n = NEXT_OWNER.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let Some(next) = n.checked_add(1) else { return 0 };
|
||||
match NEXT_OWNER.compare_exchange_weak(n, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return n,
|
||||
Err(current) => n = current,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Jobs {
|
||||
owner: u64,
|
||||
serial: u64,
|
||||
generation: u64,
|
||||
pub(crate) host: Option<CpuHost>,
|
||||
pub(crate) descriptor: Option<ExecutorDescriptor>,
|
||||
live: Vec<JobHandle>,
|
||||
history: VecDeque<JobStatus>,
|
||||
last_good: Option<JobHandle>,
|
||||
}
|
||||
|
||||
impl Default for Jobs {
|
||||
fn default() -> Self {
|
||||
Self { owner: owner(), serial: 0, generation: 0, host: None, descriptor: None, live: Vec::new(), history: VecDeque::new(), last_good: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Jobs {
|
||||
fn drop(&mut self) {
|
||||
for job in &self.live {
|
||||
let _ = job.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub(crate) fn scratch(&self) -> Self {
|
||||
let mut jobs = Self::default();
|
||||
jobs.host = self.host.clone();
|
||||
jobs.descriptor = self.descriptor.clone();
|
||||
jobs
|
||||
}
|
||||
|
||||
pub(crate) fn collect(&mut self) -> Result<(), JobError> {
|
||||
let mut i = 0;
|
||||
while i < self.live.len() {
|
||||
let status = self.live.get(i).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing live job"))?.status()?;
|
||||
if !status.progress.phase.terminal() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let handle = self.live.remove(i);
|
||||
if status.progress.phase == Phase::Completed && self.last_good.as_ref().is_none_or(|old| old.id().serial < handle.id().serial) {
|
||||
self.last_good = Some(handle);
|
||||
}
|
||||
self.history.push_back(status);
|
||||
while self.history.len() > 4 {
|
||||
self.history.pop_front();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: solidcraft_render::job::ImageRequest,
|
||||
replace: bool,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
self.collect()?;
|
||||
let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is installed"))?.clone();
|
||||
if replace {
|
||||
for job in self.live.drain(..) {
|
||||
let mut status = job.cancel()?;
|
||||
// Replaced handles are deliberately abandoned: keep a cancellation tombstone,
|
||||
// not a permanently "cancelling" snapshot. The token already prevents any
|
||||
// publication; the host can finish releasing its buffers asynchronously.
|
||||
if !status.progress.phase.terminal() {
|
||||
status.progress.phase = Phase::Cancelled;
|
||||
}
|
||||
self.history.push_back(status);
|
||||
}
|
||||
while self.history.len() > 4 {
|
||||
self.history.pop_front();
|
||||
}
|
||||
}
|
||||
if self.live.len() >= 2 {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "this document already has an active and pending render"));
|
||||
}
|
||||
if self.owner == 0 {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
|
||||
}
|
||||
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
|
||||
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
|
||||
let handle = host.submit(JobId { session: self.owner, serial, generation }, revision, scene, request)?;
|
||||
let status = handle.status()?;
|
||||
self.serial = serial;
|
||||
self.generation = generation;
|
||||
self.live.push(handle);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn check_owner(&self, id: JobId) -> Result<(), JobError> {
|
||||
if id.session != self.owner {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "render job belongs to another document session"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle(&self, id: JobId) -> Result<JobHandle, JobError> {
|
||||
self.check_owner(id)?;
|
||||
self.live
|
||||
.iter()
|
||||
.chain(self.last_good.iter())
|
||||
.find(|j| j.id() == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
|
||||
pub(crate) fn status(&mut self, id: JobId) -> Result<JobStatus, JobError> {
|
||||
self.check_owner(id)?;
|
||||
self.collect()?;
|
||||
if let Ok(handle) = self.handle(id) {
|
||||
return handle.status();
|
||||
}
|
||||
self.history.iter().find(|s| s.job == id).cloned().ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
|
||||
pub(crate) fn frame(&mut self, id: JobId) -> Result<Arc<RenderFrame>, JobError> {
|
||||
self.collect()?;
|
||||
self.handle(id)?.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "render has no completed image"))
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// A host is installed by the application, never by document data or a command.
|
||||
pub fn set_render_job_host(&mut self, host: Option<CpuHost>) -> crate::Result<()> {
|
||||
if self.render_jobs.host.as_ref().zip(host.as_ref()).is_some_and(|(a, b)| a.same_host(b)) {
|
||||
return Ok(());
|
||||
}
|
||||
let descriptor = host.as_ref().map(CpuHost::descriptor).transpose().map_err(job_error)?;
|
||||
let mut backend = self.render_backend();
|
||||
backend.executors.retain(|e| self.render_jobs.descriptor.as_ref().is_none_or(|old| old.id != e.id));
|
||||
if let Some(d) = &descriptor {
|
||||
backend.executors.push(d.clone());
|
||||
}
|
||||
solidcraft_render::resolve_request(
|
||||
&[backend],
|
||||
&solidcraft_render::RenderRequest {
|
||||
backend: None,
|
||||
mode: solidcraft_render::RenderMode::Preview,
|
||||
operation: solidcraft_render::RenderOperation::Image,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: Some(solidcraft_render::RenderFormat::Png),
|
||||
features: Vec::new(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
let mut jobs = Jobs::default();
|
||||
jobs.host = host;
|
||||
jobs.descriptor = descriptor;
|
||||
self.render_jobs = jobs;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn render_job_host(&self) -> Option<&CpuHost> {
|
||||
self.render_jobs.host.as_ref()
|
||||
}
|
||||
|
||||
/// Explicit native CLI waiting is separate from non-blocking status commands.
|
||||
pub fn render_job_handle(&self, id: JobId) -> crate::Result<JobHandle> {
|
||||
self.render_jobs.handle(id).map_err(job_error)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn job_error(error: JobError) -> crate::EngineError {
|
||||
crate::EngineError::Other(error.to_string())
|
||||
}
|
||||
Reference in new issue
Block a user