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:
selune authored and GitHub committed 2026-10-14 19:24:36 -04:00
1 parent e4ef6cfe29
commit a261aaa841
70 files changed
+5860 -101

No files matched your search

+37 -3
View File
@@ -12,11 +12,34 @@ use crate::Document;
/// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "LookWire")]
pub struct Look {
pub name: String,
pub color: [u8; 3],
#[serde(default = "opaque")]
pub opacity: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pbr: Option<crate::pbr_appearance::PbrParameters>,
}
#[derive(Deserialize)]
struct LookWire {
name: String,
color: [u8; 3],
#[serde(default = "opaque")]
opacity: f64,
#[serde(default)]
pbr: Option<crate::pbr_appearance::PbrParameters>,
}
impl TryFrom<LookWire> for Look {
type Error = String;
fn try_from(value: LookWire) -> std::result::Result<Self, Self::Error> {
if value.pbr.is_some() && value.opacity != 1.0 {
return Err("opaque PBR requires opacity 1; refraction/transmission is not supported".into());
}
Ok(Self { name: value.name, color: value.color, opacity: value.opacity, pbr: value.pbr })
}
}
fn opaque() -> f64 {
@@ -25,7 +48,13 @@ fn opaque() -> f64 {
impl Look {
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) }
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
}
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_render::surface::OpaqueSurface>> {
if self.pbr.is_some() && self.opacity != 1.0 {
return Err(crate::DocError::Invalid("opaque PBR requires opacity 1".into()));
}
self.pbr.map(|p| p.surface(self.color).map_err(|e| crate::DocError::Invalid(e.to_string()))).transpose()
}
/// Colour as 0–1 fractions.
pub fn rgb(&self) -> [f32; 3] {
@@ -109,14 +138,19 @@ pub use defaults::LIBRARY;
/// A library appearance by name (case-insensitive).
pub fn library(name: &str) -> Option<Look> {
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o })
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look {
name: n.to_string(),
color: *c,
opacity: *o,
pbr: None,
})
}
/// The look a physical material gives a body that has no appearance of its own.
pub fn material_look(material: &str) -> Option<Look> {
let color = crate::material_color(material)?;
let opacity = if material == "Glass" { 0.25 } else { 1.0 };
Some(Look { name: material.to_string(), color, opacity })
Some(Look { name: material.to_string(), color, opacity, pbr: None })
}
impl Document {
+3
View File
@@ -164,6 +164,9 @@
//! (`com.example.tool`). Every key is kept as it is, and nothing in it is checked when the
//! design opens. A design without it saves as before. 0.11.x refuses format 21, so no
//! released build drops the properties when it saves.
//! Under the same version, looks can explicitly select opaque PBR parameters (`pbr`: metal
//! fraction, roughness, dielectric IOR). Absent fields keep the scalar shading a look always
//! had; 0.11.0 asks for an update instead of silently dropping the response.
use serde_json::Value;
+1
View File
@@ -9,6 +9,7 @@ pub mod appearance;
mod assembly;
mod body_transfer;
mod body_transfer_sheet;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
mod clipboard;
+87
View File
@@ -0,0 +1,87 @@
//! Persistable opaque optical parameters, independent of engineering material data.
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_render::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear};
/// Explicitly selected opaque response. Existing scalar looks have no such record.
/// Construction and deserialization share the renderer's finite admission bounds.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "Parameters", into = "Parameters")]
pub struct PbrParameters {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
}
impl Default for PbrParameters {
fn default() -> Self {
Self { metallic: 0.0, roughness: 0.5, dielectric_ior: 1.5 }
}
}
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Parameters {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
}
impl PbrParameters {
pub fn new(metallic: f64, roughness: f64, dielectric_ior: f64) -> Result<Self, SurfaceError> {
OpaqueSurface::new(Vec3::ZERO, metallic, roughness, dielectric_ior)?;
Ok(Self { metallic, roughness, dielectric_ior })
}
/// Decode the containing look's sRGB8 base color once, for the actual consumer.
pub fn surface(self, color: [u8; 3]) -> Result<OpaqueSurface, SurfaceError> {
let [r, g, b] = color.map(srgb8_to_linear);
OpaqueSurface::new(Vec3::new(r, g, b), self.metallic, self.roughness, self.dielectric_ior)
}
pub fn metallic(self) -> f64 {
self.metallic
}
pub fn roughness(self) -> f64 {
self.roughness
}
pub fn dielectric_ior(self) -> f64 {
self.dielectric_ior
}
}
impl TryFrom<Parameters> for PbrParameters {
type Error = SurfaceError;
fn try_from(p: Parameters) -> Result<Self, Self::Error> {
Self::new(p.metallic, p.roughness, p.dielectric_ior)
}
}
impl From<PbrParameters> for Parameters {
fn from(p: PbrParameters) -> Self {
Self { metallic: p.metallic, roughness: p.roughness, dielectric_ior: p.dielectric_ior }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn optical_parameters_share_strict_admission_and_round_trip() {
let p = PbrParameters::new(1.0, 0.3, 1.5).unwrap();
let saved = serde_json::to_value(p).unwrap();
assert_eq!(serde_json::from_value::<PbrParameters>(saved.clone()).unwrap(), p);
for (key, value) in [("metallic", json!(1.1)), ("roughness", json!(0)), ("dielectric_ior", json!(0.9)), ("extra", json!(true))] {
let mut bad = saved.clone();
bad[key] = value;
assert!(serde_json::from_value::<PbrParameters>(bad).is_err());
}
assert!(PbrParameters::new(f64::NAN, 0.3, 1.5).is_err());
assert!(PbrParameters::new(0.0, f64::INFINITY, 1.5).is_err());
let surface = p.surface([128, 0, 255]).unwrap();
assert!((surface.base_color().x - 0.21586050011389926).abs() < 1e-14);
assert_eq!(surface.base_color().z, 1.0);
}
}
@@ -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"]}));
+49 -5
View File
@@ -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,
+32
View File
@@ -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());
}
}
}
+6 -2
View File
@@ -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", &params).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);
}
+46 -2
View File
@@ -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;
+219
View File
@@ -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", &params).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, &params).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());
}
+133
View File
@@ -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}))
}
+71
View File
@@ -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,
+198
View File
@@ -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));
}
}
+197
View File
@@ -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())
}
+15 -8
View File
@@ -57,11 +57,14 @@ pub struct ScalarLook {
pub opacity: f64,
}
impl ScalarLook {
pub fn from_look(look: &Look) -> Self {
Self { name: look.name.clone(), color: look.color, opacity: look.opacity }
pub fn from_look(look: &Look) -> Result<Self> {
if look.pbr.is_some() {
return Err(invalid("scalar catalog v1 cannot preserve PBR parameters; export refused"));
}
Ok(Self { name: look.name.clone(), color: look.color, opacity: look.opacity })
}
pub fn to_look(&self) -> Look {
Look { name: self.name.clone(), color: self.color, opacity: self.opacity }
Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: None }
}
fn validate(&self) -> Result<()> {
if !text(&self.name, 128) || !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) {
@@ -80,8 +83,12 @@ pub struct ScalarMaterial {
pub appearance: Option<ScalarLook>,
}
impl ScalarMaterial {
pub fn from_material(material: &UserMaterial) -> Self {
Self { name: material.name.clone(), density_g_cm3: material.density, appearance: material.appearance.as_ref().map(ScalarLook::from_look) }
pub fn from_material(material: &UserMaterial) -> Result<Self> {
Ok(Self {
name: material.name.clone(),
density_g_cm3: material.density,
appearance: material.appearance.as_ref().map(ScalarLook::from_look).transpose()?,
})
}
pub fn to_material(&self, name: String) -> UserMaterial {
UserMaterial { name, density: self.density_g_cm3, appearance: self.appearance.as_ref().map(ScalarLook::to_look) }
@@ -266,12 +273,12 @@ impl Catalog {
Ok(())
};
for look in doc.appearances.bodies.values().chain(doc.appearances.components.values()).chain(doc.appearances.faces.iter().map(|f| &f.look)) {
insert(Definition::Appearance(ScalarLook::from_look(look)))?;
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
}
for material in &doc.user_materials {
insert(Definition::Material(ScalarMaterial::from_material(material)))?;
insert(Definition::Material(ScalarMaterial::from_material(material)?))?;
if let Some(look) = &material.appearance {
insert(Definition::Appearance(ScalarLook::from_look(look)))?;
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
}
}
let out = Self { format: FORMAT.into(), id: id.into(), name: name.into(), entries: entries.into_values().collect() };
+31
View File
@@ -123,3 +123,34 @@ fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct
assert_eq!(library, Catalog::decode(&library.encode().unwrap()).unwrap());
assert!(Catalog::from_document(&Document::default(), "user.test", "Test", Provenance { license: String::new(), ..Default::default() }).is_err());
}
#[test]
fn scalar_export_refuses_pbr_in_every_design_owned_appearance_location() {
use solidcraft_doc::{appearance::FaceLook, pbr_appearance::PbrParameters};
let mut look = ScalarLook { name: "Original PBR specimen".into(), color: [180, 120, 70], opacity: 1.0 }.to_look();
look.pbr = Some(PbrParameters::new(1.0, 0.3, 1.5).unwrap());
for location in 0..4 {
let mut doc = Document::default();
match location {
0 => {
doc.appearances.bodies.insert("Specimen".into(), look.clone());
}
1 => {
doc.appearances.components.insert(1, look.clone());
}
2 => doc.appearances.faces.push(FaceLook {
body: "Specimen".into(),
point: Default::default(),
look: look.clone(),
name: None,
strict_name: false,
certificate: None,
}),
_ => doc.user_materials.push(UserMaterial { name: "Original material".into(), density: 1.2, appearance: Some(look.clone()) }),
}
let before = doc.clone();
let error = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap_err();
assert!(error.to_string().contains("cannot preserve PBR"));
assert_eq!(doc, before);
}
}
+9 -1
View File
@@ -172,7 +172,15 @@ impl Default for Headless {
}
impl Headless {
pub fn new(session: Session) -> Self {
pub fn new(mut session: Session) -> Self {
match solidcraft_engine::render::job::CpuHost::for_platform() {
Ok(host) => {
if let Err(e) = session.set_render_job_host(Some(host)) {
session.log.push(format!("image host unavailable: {e}"));
}
}
Err(e) => session.log.push(format!("image host unavailable: {e}")),
}
Headless { session, view: StandardView::Iso }
}
}
+1
View File
@@ -12,6 +12,7 @@ workspace = true
solidcraft-geom = { workspace = true }
serde = { workspace = true }
image = { workspace = true, features = ["jpeg"] }
png = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+516
View File
@@ -0,0 +1,516 @@
//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping
//! uses the same incremental task without threads. Shared ownership never includes CAD state.
use std::collections::VecDeque;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Condvar;
use std::sync::{
Arc, Mutex, Weak,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use serde::Serialize;
use super::{
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result,
TaskPoll, admit,
};
use crate::{
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout,
};
const MEMORY_LIMIT: u64 = 512 * 1024 * 1024;
const QUEUE_LIMIT: usize = 2;
#[cfg(test)]
#[path = "host_tests.rs"]
mod tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HostKind {
NativeWorker,
Cooperative,
}
struct Budget {
used: AtomicU64,
}
struct Reservation {
budget: Arc<Budget>,
bytes: u64,
}
impl Budget {
fn reserve(self: &Arc<Self>, bytes: u64) -> Result<Reservation> {
let mut used = self.used.load(Ordering::Acquire);
loop {
let next = used
.checked_add(bytes)
.filter(|n| *n <= MEMORY_LIMIT)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?;
match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => break,
Err(current) => used = current,
}
}
Ok(Reservation { budget: self.clone(), bytes })
}
}
impl Reservation {
fn shrink(&mut self, bytes: u64) -> Result<()> {
if bytes > self.bytes {
return Err(JobError::new(ErrorCode::LimitExceeded, "completed PNG exceeds its reservation"));
}
self.budget.used.fetch_sub(self.bytes - bytes, Ordering::AcqRel);
self.bytes = bytes;
Ok(())
}
}
impl Drop for Reservation {
fn drop(&mut self) {
self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel);
}
}
/// Borrow encoded bytes through the frame, so retaining the data also retains its admission lease.
pub struct RenderFrame {
pub info: FrameInfo,
png: Vec<u8>,
_reservation: Reservation,
}
impl RenderFrame {
pub fn png(&self) -> &[u8] {
&self.png
}
}
#[derive(Clone, Debug, Serialize)]
pub struct JobStatus {
pub job: JobId,
pub scene_generation: u64,
pub request: ImageRequest,
pub resolved: crate::ResolvedRequest,
pub progress: Progress,
pub error: Option<JobError>,
pub frame: Option<FrameInfo>,
}
struct Data {
progress: Progress,
error: Option<JobError>,
frame: Option<Arc<RenderFrame>>,
}
struct Record {
id: JobId,
scene_generation: u64,
request: ImageRequest,
resolved: crate::ResolvedRequest,
cancel: CancelToken,
data: Mutex<Data>,
#[cfg(not(target_arch = "wasm32"))]
done: Condvar,
}
impl Record {
fn notify(&self) {
#[cfg(not(target_arch = "wasm32"))]
self.done.notify_all();
}
}
#[derive(Clone)]
pub struct JobHandle(Arc<Record>);
impl JobHandle {
pub fn id(&self) -> JobId {
self.0.id
}
pub fn status(&self) -> Result<JobStatus> {
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
Ok(JobStatus {
job: self.0.id,
scene_generation: self.0.scene_generation,
request: self.0.request.clone(),
resolved: self.0.resolved.clone(),
progress: data.progress,
error: data.error.clone(),
frame: data.frame.as_ref().map(|f| f.info.clone()),
})
}
pub fn cancel(&self) -> Result<JobStatus> {
{
let mut data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
if !data.progress.phase.terminal() {
self.0.cancel.cancel();
data.progress.phase = if data.progress.phase == Phase::Queued { Phase::Cancelled } else { Phase::Cancelling };
}
}
self.0.notify();
self.status()
}
/// Native application helper; engine status commands remain non-blocking.
#[cfg(not(target_arch = "wasm32"))]
pub fn wait_timeout(&self, timeout: std::time::Duration) -> Result<JobStatus> {
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
let (data, timed) = self
.0
.done
.wait_timeout_while(data, timeout, |d| !d.progress.phase.terminal())
.map_err(|_| JobError::new(ErrorCode::Internal, "render completion lock poisoned"))?;
if timed.timed_out() && !data.progress.phase.terminal() {
return Err(JobError::new(ErrorCode::TimedOut, "render wait timed out"));
}
drop(data);
self.status()
}
pub fn frame(&self) -> Result<Option<Arc<RenderFrame>>> {
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render result lock poisoned"))?;
let frame = data.frame.clone();
if frame.as_ref().is_some_and(|f| f.info.job != self.0.id || f.info.scene_generation != self.0.scene_generation) {
return Err(JobError::new(ErrorCode::WrongOwner, "render frame identity does not match its job"));
}
Ok(frame)
}
}
struct Work {
scene: ProductScene,
handle: JobHandle,
reservation: Reservation,
}
struct Running {
task: ImageTask,
handle: JobHandle,
reservation: Option<Reservation>,
}
fn terminal(handle: &JobHandle, error: JobError) {
if let Ok(mut data) = handle.0.data.lock() {
if data.progress.phase == Phase::Completed {
return;
}
if handle.0.cancel.is_cancelled() || error.code == ErrorCode::Cancelled {
data.progress.phase = Phase::Cancelled;
data.error = None;
} else {
data.progress.phase = Phase::Failed;
data.error = Some(error);
}
data.frame = None;
}
handle.0.notify();
}
impl Running {
fn begin(work: Work) -> Option<Self> {
if work.handle.0.cancel.is_cancelled() {
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render cancelled"));
return None;
}
if let Ok(mut data) = work.handle.0.data.lock()
&& !work.handle.0.cancel.is_cancelled()
{
data.progress.phase = Phase::Preparing;
}
let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone());
match task {
Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }),
Err(e) => {
terminal(&work.handle, e);
None
}
}
}
fn advance(&mut self, units: usize) -> Result<bool> {
let result = self.task.step(units)?;
let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
// The final publication check is under the same lock as cancellation.
if self.handle.0.cancel.is_cancelled() {
data.progress.phase = Phase::Cancelled;
data.frame = None;
self.handle.0.notify();
return Ok(false);
}
match result {
TaskPoll::Pending(progress) => {
data.progress = progress;
Ok(true)
}
TaskPoll::Complete(png) => {
let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?;
reservation.shrink(png.capacity() as u64 + 1024)?;
let info = FrameInfo {
job: self.handle.0.id,
scene_generation: self.handle.0.scene_generation,
backend: self.handle.0.resolved.backend.clone(),
executor: self.handle.0.resolved.executor.clone(),
width: self.handle.0.request.width,
height: self.handle.0.request.height,
encoding: ColourEncoding::Srgb,
alpha: AlphaConvention::Straight,
};
data.frame = Some(Arc::new(RenderFrame { info, png, _reservation: reservation }));
data.progress = Progress { phase: Phase::Completed, done: 1, total: 1 };
self.handle.0.notify();
Ok(false)
}
}
}
fn guarded_step(&mut self, units: usize) -> bool {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.advance(units)));
match result {
Ok(Ok(more)) => more,
Ok(Err(e)) => {
terminal(&self.handle, e);
false
}
Err(_) => {
terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error"));
false
}
}
}
}
struct Shared {
queue: Mutex<VecDeque<Work>>,
active: Mutex<Option<Weak<Record>>>,
stop: AtomicBool,
budget: Arc<Budget>,
#[cfg(not(target_arch = "wasm32"))]
wake: Condvar,
}
struct Owner {
shared: Arc<Shared>,
kind: HostKind,
running: Mutex<Option<Running>>,
#[cfg(not(target_arch = "wasm32"))]
_worker: Option<std::thread::JoinHandle<()>>,
}
impl Drop for Owner {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::Release);
if let Ok(active) = self.shared.active.lock()
&& let Some(record) = active.as_ref().and_then(Weak::upgrade)
{
let _ = JobHandle(record).cancel();
}
if let Ok(mut queue) = self.shared.queue.lock() {
for work in queue.iter() {
let _ = work.handle.cancel();
}
queue.clear();
}
if let Ok(mut running) = self.running.lock()
&& let Some(work) = running.take()
{
let _ = work.handle.cancel();
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed"));
}
#[cfg(not(target_arch = "wasm32"))]
self.shared.wake.notify_all();
// Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit.
}
}
#[derive(Clone)]
pub struct CpuHost(Arc<Owner>);
impl CpuHost {
fn shared() -> Arc<Shared> {
Arc::new(Shared {
queue: Mutex::new(VecDeque::new()),
active: Mutex::new(None),
stop: AtomicBool::new(false),
budget: Arc::new(Budget { used: AtomicU64::new(0) }),
#[cfg(not(target_arch = "wasm32"))]
wake: Condvar::new(),
})
}
pub fn cooperative() -> Self {
Self(Arc::new(Owner {
shared: Self::shared(),
kind: HostKind::Cooperative,
running: Mutex::new(None),
#[cfg(not(target_arch = "wasm32"))]
_worker: None,
}))
}
pub fn for_platform() -> Result<Self> {
#[cfg(target_arch = "wasm32")]
{
Ok(Self::cooperative())
}
#[cfg(not(target_arch = "wasm32"))]
{
let shared = Self::shared();
let worker_shared = shared.clone();
let worker = std::thread::Builder::new()
.name("solidcraft-render".into())
.spawn(move || native_worker(worker_shared))
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?;
Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
}
}
pub fn kind(&self) -> HostKind {
self.0.kind
}
pub fn same_host(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
pub fn is_available(&self) -> bool {
!self.0.shared.stop.load(Ordering::Acquire)
}
pub fn admitted_bytes(&self) -> u64 {
self.0.shared.budget.used.load(Ordering::Acquire)
}
pub fn descriptor(&self) -> Result<ExecutorDescriptor> {
Ok(ExecutorDescriptor {
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
availability: if self.0.shared.stop.load(Ordering::Acquire) {
Availability::Unavailable { reason: "CPU image host has stopped".into() }
} else {
Availability::Available
},
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Image,
formats: vec![RenderFormat::Png],
features: vec![crate::RenderFeature::Pbr],
}],
limits: RenderLimits::default(),
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
})
}
pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result<JobHandle> {
if id.session == 0 || id.serial == 0 || id.generation == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity"));
}
let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?;
let mut backend = crate::builtin_backend();
backend.executors.push(self.descriptor()?);
let resolved = crate::resolve_request(
&[backend],
&crate::RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Image,
width: u64::from(request.width),
height: u64::from(request.height),
format: Some(RenderFormat::Png),
features: scene.required_features(),
policy: Default::default(),
},
)
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?;
if resolved.executor.as_str() != "cpu-product" {
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable"));
}
let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?;
if self.0.shared.stop.load(Ordering::Acquire) {
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped"));
}
queue.retain(|work| !work.handle.0.cancel.is_cancelled());
if queue.len() >= QUEUE_LIMIT {
return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full"));
}
let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?;
let handle = JobHandle(Arc::new(Record {
id,
scene_generation,
request,
resolved,
cancel: CancelToken::default(),
data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }),
#[cfg(not(target_arch = "wasm32"))]
done: Condvar::new(),
}));
queue.push_back(Work { scene, handle: handle.clone(), reservation });
drop(queue);
#[cfg(not(target_arch = "wasm32"))]
self.0.shared.wake.notify_one();
Ok(handle)
}
/// Cooperative callers pump a small quantum independently of polling a job's status.
/// Native jobs run even when the UI is idle or a headless caller is not polling.
pub fn pump(&self, units: usize) -> Result<bool> {
if self.0.kind != HostKind::Cooperative {
return Ok(false);
}
let mut running = self.0.running.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "cooperative host lock poisoned"))?;
if running.is_none() {
let work = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.pop_front();
if let Some(work) = work {
*running = Running::begin(work);
}
}
if let Some(work) = running.as_mut() {
if let Ok(mut active) = self.0.shared.active.lock() {
*active = Some(Arc::downgrade(&work.handle.0));
}
if !work.guarded_step(units) {
*running = None;
if let Ok(mut active) = self.0.shared.active.lock() {
*active = None;
}
}
}
Ok(running.is_some()
|| !self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.is_empty())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_worker(shared: Arc<Shared>) {
loop {
let mut queue = match shared.queue.lock() {
Ok(q) => q,
Err(_) => {
shared.stop.store(true, Ordering::Release);
return;
}
};
while queue.is_empty() && !shared.stop.load(Ordering::Acquire) {
queue = match shared.wake.wait(queue) {
Ok(q) => q,
Err(_) => {
shared.stop.store(true, Ordering::Release);
return;
}
};
}
if shared.stop.load(Ordering::Acquire) {
return;
}
let work = queue.pop_front();
drop(queue);
let Some(mut running) = work.and_then(Running::begin) else { continue };
if let Ok(mut active) = shared.active.lock() {
*active = Some(Arc::downgrade(&running.handle.0));
}
loop {
if shared.stop.load(Ordering::Acquire) {
let _ = running.handle.cancel();
}
if !running.guarded_step(4096) {
break;
}
}
if let Ok(mut active) = shared.active.lock() {
*active = None;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
use super::*;
use solidcraft_geom::{Aabb3, Mesh};
fn request() -> ImageRequest {
ImageRequest { camera: Default::default(), width: 16, height: 16, background: [40, 80, 120], transparent_background: false }
}
fn empty() -> ProductScene {
ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }
}
fn id(owner: u64, serial: u64) -> JobId {
JobId { session: owner, serial, generation: serial }
}
fn drain(host: &CpuHost) {
for _ in 0..10_000 {
if !host.pump(4096).unwrap() {
return;
}
}
panic!("small cooperative jobs did not drain");
}
#[test]
fn queue_is_bounded_and_cancelled_entries_release_their_reservations() {
let host = CpuHost::cooperative();
let a = host.submit(id(10, 1), 100, empty(), request()).unwrap();
let b = host.submit(id(20, 1), 200, empty(), request()).unwrap();
let before = host.admitted_bytes();
assert_eq!(host.submit(id(30, 1), 300, empty(), request()).err().unwrap().code, ErrorCode::QueueFull);
assert_eq!(host.admitted_bytes(), before);
assert_eq!(a.cancel().unwrap().progress.phase, Phase::Cancelled);
let c = host.submit(id(30, 1), 300, empty(), request()).unwrap();
drain(&host);
assert_eq!(a.status().unwrap().progress.phase, Phase::Cancelled);
for (handle, owner, generation) in [(&b, 20, 200), (&c, 30, 300)] {
let frame = handle.frame().unwrap().unwrap();
assert_eq!(frame.info.job, id(owner, 1));
assert_eq!(frame.info.scene_generation, generation);
assert_eq!(frame.info.executor.as_str(), "cpu-product");
assert_eq!(image::load_from_memory(frame.png()).unwrap().width(), 16);
}
assert!(host.admitted_bytes() > 0 && host.admitted_bytes() < before);
let frame = b.frame().unwrap().unwrap();
drop(b);
drop(c);
assert!(host.admitted_bytes() > 0, "an externally retained frame retains its lease");
drop(frame);
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn cancellation_while_encoding_cannot_publish_a_late_frame() {
let host = CpuHost::cooperative();
let job = host.submit(id(1, 1), 8, empty(), request()).unwrap();
for _ in 0..10_000 {
host.pump(64).unwrap();
let status = job.status().unwrap();
if status.progress.phase == Phase::Encoding && status.progress.done > 0 {
break;
}
}
assert_eq!(job.status().unwrap().progress.phase, Phase::Encoding);
assert_eq!(job.cancel().unwrap().progress.phase, Phase::Cancelling);
drain(&host);
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert!(job.frame().unwrap().is_none());
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn malformed_preparation_fails_the_job_without_stopping_the_host() {
let host = CpuHost::cooperative();
let scene = ProductScene {
bounds: Aabb3::EMPTY,
meshes: vec![crate::ProductMesh {
name: "Broken".into(),
mesh: Arc::new(Mesh { triangles: vec![[99, 99, 99]], ..Default::default() }),
color: [0; 3],
opacity: 1.0,
surface: None,
faces: Vec::new(),
}],
};
let broken = host.submit(id(1, 1), 1, scene, request()).unwrap();
drain(&host);
let status = broken.status().unwrap();
assert_eq!(status.progress.phase, Phase::Failed);
assert_eq!(status.error.unwrap().code, ErrorCode::InvalidScene);
assert!(broken.frame().unwrap().is_none());
assert!(host.is_available());
let healthy = host.submit(id(1, 2), 1, empty(), request()).unwrap();
drain(&host);
assert_eq!(healthy.status().unwrap().progress.phase, Phase::Completed);
}
#[test]
fn closing_the_host_cancels_active_and_queued_work() {
let host = CpuHost::cooperative();
let budget = host.0.shared.budget.clone();
let active = host.submit(id(1, 1), 1, empty(), request()).unwrap();
let queued = host.submit(id(2, 1), 1, empty(), request()).unwrap();
host.pump(1).unwrap();
drop(host);
assert_eq!(active.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(queued.status().unwrap().progress.phase, Phase::Cancelled);
assert_eq!(budget.used.load(Ordering::Acquire), 0);
}
#[test]
fn a_foreign_frame_is_rejected_even_if_a_broken_host_puts_it_in_the_result_slot() {
let host = CpuHost::cooperative();
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
drain(&host);
{
let mut data = job.0.data.lock().unwrap();
let frame = Arc::get_mut(data.frame.as_mut().unwrap()).unwrap();
frame.info.job.session = 2;
}
assert_eq!(job.frame().err().unwrap().code, ErrorCode::WrongOwner);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_job_finishes_without_any_poll_or_ui_pump() {
let host = CpuHost::for_platform().unwrap();
assert_eq!(host.kind(), HostKind::NativeWorker);
let job = host.submit(id(1, 1), 12, empty(), request()).unwrap();
// A generous deadlock watchdog, not a performance assertion.
let status = job.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed);
assert_eq!(job.frame().unwrap().unwrap().info.scene_generation, 12);
}
+700
View File
@@ -0,0 +1,700 @@
//! Incremental product rasterization. Every phase yields between bounded work units.
use std::collections::BTreeMap;
use std::io::Write;
use std::sync::{Arc, Mutex};
use serde::Serialize;
use solidcraft_geom::Vec3;
use super::{CancelToken, ErrorCode, ImageRequest, JobError, Phase, Progress, Result};
use crate::{FaceAppearance, Mat4, ProductScene, RenderFormat, RenderMode, RenderOperation, RenderRequest};
#[cfg(test)]
#[path = "image_tests.rs"]
mod tests;
pub const MAX_TRIANGLES: u64 = 262_144;
pub const MAX_GEOMETRY_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_JOB_BYTES: u64 = 384 * 1024 * 1024;
pub const MAX_PIXEL_TESTS: u64 = 128_000_000;
const MAX_STEP: usize = 16_384;
const SS: usize = 2;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct Admission {
pub triangles: u64,
pub face_appearances: u64,
pub geometry_bytes: u64,
pub pixel_scratch_bytes: u64,
pub preparation_bytes: u64,
pub estimated_total_bytes: u64,
pub encoded_limit: u64,
}
fn limit(message: &str) -> JobError {
JobError::new(ErrorCode::LimitExceeded, message)
}
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidScene, message)
}
fn add_bytes(sum: &mut u64, count: usize, size: usize) -> Result<()> {
let bytes = (count as u64).checked_mul(size as u64).ok_or_else(|| limit("geometry size overflow"))?;
*sum = sum.checked_add(bytes).ok_or_else(|| limit("geometry size overflow"))?;
if *sum > MAX_GEOMETRY_BYTES {
return Err(limit("retained product geometry exceeds 128 MiB"));
}
Ok(())
}
/// Conservative admission before allocating pixels or projected-work lists. Source Arcs are
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
if !request.camera.is_valid() {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
}
if scene.meshes.len() > 16_384 {
return Err(limit("too many product bodies"));
}
let resolved = crate::resolve_request(
&[crate::builtin_backend()],
&RenderRequest {
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Image,
width: u64::from(request.width),
height: u64::from(request.height),
format: Some(RenderFormat::Png),
features: Vec::new(),
policy: Default::default(),
},
)
.map_err(|e| JobError::new(ErrorCode::InvalidRequest, e.to_string()))?;
let (mut triangles, mut faces, mut geometry, mut polylines) = (0u64, 0u64, 0u64, 0u64);
add_bytes(&mut geometry, scene.meshes.capacity(), std::mem::size_of::<crate::ProductMesh>())?;
for body in &scene.meshes {
let m = &body.mesh;
triangles = triangles.checked_add(m.triangles.len() as u64).ok_or_else(|| limit("triangle count overflow"))?;
faces = faces.checked_add(body.faces.len() as u64).ok_or_else(|| limit("appearance count overflow"))?;
polylines = polylines.checked_add(m.edges.len() as u64 + m.edge_faces.len() as u64).ok_or_else(|| limit("edge count overflow"))?;
if triangles > MAX_TRIANGLES || faces > MAX_TRIANGLES || polylines > MAX_TRIANGLES {
return Err(limit("product image exceeds the 262144 triangle/appearance/polyline admission cap"));
}
add_bytes(&mut geometry, m.positions.capacity(), std::mem::size_of::<Vec3>())?;
add_bytes(&mut geometry, m.normals.capacity(), std::mem::size_of::<Vec3>())?;
add_bytes(&mut geometry, m.triangles.capacity(), std::mem::size_of::<[u32; 3]>())?;
add_bytes(&mut geometry, m.tri_face.capacity(), std::mem::size_of::<u32>())?;
add_bytes(&mut geometry, body.faces.capacity(), std::mem::size_of::<FaceAppearance>())?;
add_bytes(&mut geometry, body.name.capacity(), 1)?;
add_bytes(&mut geometry, m.edges.capacity(), std::mem::size_of::<Vec<Vec3>>())?;
add_bytes(&mut geometry, m.edge_faces.capacity(), std::mem::size_of::<Vec<u32>>())?;
for edge in &m.edges {
add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::<Vec3>())?;
}
for edge in &m.edge_faces {
add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::<u32>())?;
}
for count in [m.seams.capacity(), m.smooth_edges.capacity(), m.sharp_edges.capacity()] {
add_bytes(&mut geometry, count, 1)?;
}
if body.surface.is_some() && body.opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
if !body.opacity.is_finite() || !(0.0..=1.0).contains(&body.opacity) {
return Err(invalid("invalid body opacity"));
}
}
// Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead.
let preparation = triangles
.checked_mul(48)
.and_then(|n| faces.checked_mul(160).and_then(|f| n.checked_add(f)))
.and_then(|n| n.checked_add(scene.meshes.len() as u64 * 128 + 262_144))
.ok_or_else(|| limit("work list size overflow"))?;
let total = geometry
.checked_add(preparation)
.and_then(|n| n.checked_add(resolved.estimated_scratch_bytes))
.ok_or_else(|| limit("image memory overflow"))?;
if total > MAX_JOB_BYTES {
return Err(limit("product job exceeds its 384 MiB admission estimate"));
}
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
Ok(Admission {
triangles,
face_appearances: faces,
geometry_bytes: geometry,
pixel_scratch_bytes: resolved.estimated_scratch_bytes,
preparation_bytes: preparation,
estimated_total_bytes: total,
encoded_limit,
})
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?;
Ok(v)
}
#[derive(Clone, Copy, Default)]
struct Draw {
body: u32,
triangle: u32,
depth: f64,
}
impl Draw {
fn compare(&self, other: &Self) -> std::cmp::Ordering {
self.depth.total_cmp(&other.depth).then_with(|| (self.body, self.triangle).cmp(&(other.body, other.triangle)))
}
}
struct Triangle {
points: [[f64; 3]; 3],
world: [Vec3; 3],
normals: [Vec3; 3],
inv_w: [f64; 3],
colour: [f64; 3],
opacity: f64,
surface: Option<crate::surface::OpaqueSurface>,
centre_depth: f64,
area: f64,
bounds: [usize; 4],
x: usize,
y: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Stage {
Looks,
Prepare,
Sort,
Clear,
Opaque,
Glass,
Downsample,
Encode,
Done,
}
struct BufferWriter {
bytes: Arc<Mutex<Vec<u8>>>,
limit: usize,
cancel: CancelToken,
}
impl Write for BufferWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.cancel.is_cancelled() {
return Err(std::io::Error::other("render cancelled"));
}
let mut out = self.bytes.lock().map_err(|_| std::io::Error::other("PNG output lock poisoned"))?;
if out.len().checked_add(bytes.len()).is_none_or(|n| n > self.limit) {
return Err(std::io::Error::other("PNG output exceeds its byte budget"));
}
out.try_reserve_exact(bytes.len()).map_err(|_| std::io::Error::other("PNG allocation failed"))?;
out.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.cancel.is_cancelled() { Err(std::io::Error::other("render cancelled")) } else { Ok(()) }
}
}
pub enum TaskPoll {
Pending(Progress),
Complete(Vec<u8>),
}
/// CPU product renderer with cooperative preparation, sorting, raster and PNG encoding.
/// Transparency sorts triangle centres and blends in linear space; it is not refraction.
pub struct ImageTask {
scene: ProductScene,
request: ImageRequest,
admission: Admission,
cancel: CancelToken,
stage: Stage,
vp: Mat4,
back: Vec3,
sw: usize,
sh: usize,
looks: Vec<BTreeMap<u32, FaceAppearance>>,
body: usize,
item: usize,
prepare_done: u64,
opaque: Vec<Draw>,
glass: Vec<Draw>,
scratch: Vec<Draw>,
sort_width: usize,
sort_block: usize,
sort_left: usize,
sort_right: usize,
sort_mid: usize,
sort_end: usize,
sort_dst: usize,
pixels: Vec<[u8; 4]>,
depth: Vec<f32>,
pixel_tests: u64,
pixel_done: u64,
draw: usize,
triangle: Option<Triangle>,
output: Vec<u8>,
encoded: Arc<Mutex<Vec<u8>>>,
encoder: Option<png::StreamWriter<'static, BufferWriter>>,
row: usize,
}
impl ImageTask {
pub fn new(scene: ProductScene, request: ImageRequest, cancel: CancelToken) -> Result<Self> {
cancel.check()?;
let admission = admit(&scene, &request)?;
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
if !radius.is_finite() {
return Err(invalid("invalid product bounds"));
}
let vp = request.camera.view_proj(sw as f64 / sh as f64, radius);
let back = request.camera.back();
let looks = reserve(scene.meshes.len())?;
let (opaque, glass, scratch) =
(reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?);
let (pixels, depth, output) = (reserve(sw * sh)?, reserve(sw * sh)?, reserve(request.width as usize * request.height as usize * 4)?);
Ok(Self {
scene,
request,
admission,
cancel,
stage: Stage::Looks,
vp,
back,
sw,
sh,
looks,
body: 0,
item: 0,
prepare_done: 0,
opaque,
glass,
scratch,
sort_width: 1,
sort_block: 0,
sort_left: 0,
sort_right: 0,
sort_mid: 0,
sort_end: 0,
sort_dst: 0,
pixels,
depth,
pixel_tests: 0,
pixel_done: 0,
draw: 0,
triangle: None,
output,
encoded: Arc::new(Mutex::new(Vec::new())),
encoder: None,
row: 0,
})
}
pub fn progress(&self) -> Progress {
match self.stage {
Stage::Looks | Stage::Prepare | Stage::Sort => Progress {
phase: Phase::Preparing,
done: self.prepare_done,
total: self.admission.face_appearances + self.admission.triangles + self.scene.meshes.len() as u64 + self.glass.len() as u64 * 19,
},
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress {
phase: Phase::Running,
done: self.pixel_done,
total: self.pixel_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
},
Stage::Encode => Progress { phase: Phase::Encoding, done: self.row as u64, total: u64::from(self.request.height) },
Stage::Done => Progress { phase: Phase::Completed, done: 1, total: 1 },
}
}
/// At most 16384 simple units; encoding additionally yields after at most four rows.
pub fn step(&mut self, units: usize) -> Result<TaskPoll> {
let mut rows = 0;
for _ in 0..units.clamp(1, MAX_STEP) {
self.cancel.check()?;
match self.stage {
Stage::Looks => self.look_step()?,
Stage::Prepare => self.prepare_step()?,
Stage::Sort => self.sort_step()?,
Stage::Clear => self.clear_step(),
Stage::Opaque | Stage::Glass => self.draw_step()?,
Stage::Downsample => self.downsample_step()?,
Stage::Encode => {
if let Some(png) = self.encode_step()? {
return Ok(TaskPoll::Complete(png));
}
rows += 1;
if rows >= 4 {
break;
}
}
Stage::Done => return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed")),
}
}
Ok(TaskPoll::Pending(self.progress()))
}
fn look_step(&mut self) -> Result<()> {
let Some(body) = self.scene.meshes.get(self.body) else {
self.body = 0;
self.item = 0;
self.stage = Stage::Prepare;
return Ok(());
};
if self.looks.len() == self.body {
self.looks.push(BTreeMap::new());
}
if let Some(face) = body.faces.get(self.item) {
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
return Err(invalid("invalid face opacity"));
}
let looks = self.looks.get_mut(self.body).ok_or_else(|| invalid("missing body appearances"))?;
// Match the document policy: the last assignment of a face wins.
looks.insert(face.face, face.clone());
self.item += 1;
} else {
self.body += 1;
self.item = 0;
}
self.prepare_done += 1;
Ok(())
}
fn project(&self, draw: Draw) -> Result<Option<Triangle>> {
let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?;
let mesh = &body.mesh;
let indices = mesh.triangles.get(draw.triangle as usize).ok_or_else(|| invalid("missing product triangle"))?;
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
return Err(invalid("invalid triangle index or missing normal"));
};
let world = [a, b, c];
let normals = [na, nb, nc];
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
return Err(invalid("non-finite product triangle"));
}
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface), |look| (look.color, look.opacity, look.surface));
if surface.is_some() && opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
let normals = if surface.is_some() {
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
.map(|n| n.map_err(|e| invalid(&e.to_string())))
} else {
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
};
let [na, nb, nc] = normals;
let normals = [na?, nb?, nc?];
if opacity <= 0.0 {
return Ok(None);
}
let clip = world.map(|p| self.vp.apply(p));
if clip.iter().flatten().any(|v| !v.is_finite()) {
return Err(invalid("non-finite product projection"));
}
if clip.iter().any(|p| p[3] <= 1e-9) {
return Ok(None);
}
let inv_w = clip.map(|p| 1.0 / p[3]);
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
return Err(invalid("product projection exceeds pixel precision"));
}
let [a, b, c] = points;
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
return Ok(None);
}
if !area.is_finite() {
return Err(invalid("invalid projected area"));
}
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.request.camera.target).dot(self.back);
if !centre_depth.is_finite() {
return Err(invalid("invalid triangle depth"));
}
Ok(Some(Triangle {
points,
world,
normals,
inv_w,
colour: colour.map(decode),
opacity,
surface,
centre_depth,
area,
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
x: x0 as usize,
y: y0 as usize,
}))
}
fn prepare_step(&mut self) -> Result<()> {
let Some(body) = self.scene.meshes.get(self.body) else {
self.stage = if self.glass.len() > 1 { Stage::Sort } else { Stage::Clear };
self.sort_block = 0;
self.init_merge();
return Ok(());
};
if self.item >= body.mesh.triangles.len() {
self.body += 1;
self.item = 0;
return Ok(());
}
let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 };
if let Some(triangle) = self.project(draw)? {
let [x0, x1, y0, y1] = triangle.bounds;
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
if self.pixel_tests > MAX_PIXEL_TESTS {
return Err(limit("projected product exceeds 128000000 pixel tests"));
}
draw.depth = triangle.centre_depth;
if triangle.opacity >= 1.0 {
self.opaque.push(draw);
} else {
self.glass.push(draw);
self.scratch.push(draw);
}
}
self.item += 1;
self.prepare_done += 1;
Ok(())
}
fn init_merge(&mut self) {
let n = self.glass.len();
self.sort_mid = (self.sort_block + self.sort_width).min(n);
self.sort_end = (self.sort_block + 2 * self.sort_width).min(n);
self.sort_left = self.sort_block;
self.sort_right = self.sort_mid;
self.sort_dst = self.sort_block;
}
fn sort_step(&mut self) -> Result<()> {
if self.sort_dst >= self.sort_end {
self.sort_block += 2 * self.sort_width;
if self.sort_block >= self.glass.len() {
std::mem::swap(&mut self.glass, &mut self.scratch);
self.sort_width *= 2;
self.sort_block = 0;
if self.sort_width >= self.glass.len() {
self.stage = Stage::Clear;
return Ok(());
}
}
self.init_merge();
}
let left = self.glass.get(self.sort_left).filter(|_| self.sort_left < self.sort_mid);
let right = self.glass.get(self.sort_right).filter(|_| self.sort_right < self.sort_end);
let take_left = match (left, right) {
(Some(a), Some(b)) => a.compare(b).is_le(),
(Some(_), None) => true,
_ => false,
};
let source = if take_left {
let i = self.sort_left;
self.sort_left += 1;
i
} else {
let i = self.sort_right;
self.sort_right += 1;
i
};
let draw = self.glass.get(source).copied().ok_or_else(|| invalid("invalid transparent sort source"))?;
*self.scratch.get_mut(self.sort_dst).ok_or_else(|| invalid("invalid transparent sort destination"))? = draw;
self.sort_dst += 1;
self.prepare_done += 1;
Ok(())
}
fn clear_step(&mut self) {
if self.pixels.len() >= self.sw * self.sh {
self.stage = Stage::Opaque;
self.draw = 0;
return;
}
let [r, g, b] = self.request.background;
self.pixels.push(if self.request.transparent_background { [0; 4] } else { [r, g, b, 255] });
self.depth.push(f32::INFINITY);
self.pixel_done += 1;
}
fn draw_step(&mut self) -> Result<()> {
if self.triangle.is_none() {
let list = if self.stage == Stage::Opaque { &self.opaque } else { &self.glass };
let Some(draw) = list.get(self.draw).copied() else {
self.stage = if self.stage == Stage::Opaque { Stage::Glass } else { Stage::Downsample };
self.draw = 0;
return Ok(());
};
self.triangle = self.project(draw)?;
if self.triangle.is_none() {
self.draw += 1;
}
return Ok(());
}
let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?;
let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye());
raster_pixel(triangle, self.sw, self.back, eye, &mut self.pixels, &mut self.depth)?;
triangle.x += 1;
if triangle.x > triangle.bounds[1] {
triangle.x = triangle.bounds[0];
triangle.y += 1;
}
if triangle.y > triangle.bounds[3] {
self.triangle = None;
self.draw += 1;
}
self.pixel_done += 1;
Ok(())
}
fn downsample_step(&mut self) -> Result<()> {
let width = self.request.width as usize;
let index = self.output.len() / 4;
if index >= width * self.request.height as usize {
let writer = BufferWriter { bytes: self.encoded.clone(), limit: self.admission.encoded_limit as usize, cancel: self.cancel.clone() };
let mut encoder = png::Encoder::new(writer, self.request.width, self.request.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
self.encoder = Some(
encoder.write_header().and_then(|w| w.into_stream_writer()).map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?,
);
self.stage = Stage::Encode;
// Pixels and prepared geometry are no longer needed during PNG compression.
self.pixels = Vec::new();
self.depth = Vec::new();
self.opaque = Vec::new();
self.glass = Vec::new();
self.scratch = Vec::new();
self.looks = Vec::new();
self.scene.meshes.clear();
return Ok(());
}
let (x, y) = (index % width, index / width);
let (mut colour, mut alpha) = ([0.0; 3], 0.0);
for dy in 0..SS {
for dx in 0..SS {
let p = self.pixels.get((y * SS + dy) * self.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
let a = f64::from(p[3]) / 255.0;
for (sum, channel) in colour.iter_mut().zip(p.iter().take(3)) {
*sum += decode(*channel) * a;
}
alpha += a;
}
}
let rgb = colour.map(|v| if alpha > 1e-12 { encode(v / alpha) } else { 0 });
self.output.extend_from_slice(&[rgb[0], rgb[1], rgb[2], (alpha * 255.0 / (SS * SS) as f64).round() as u8]);
self.pixel_done += 1;
Ok(())
}
fn encode_step(&mut self) -> Result<Option<Vec<u8>>> {
if self.row < self.request.height as usize {
let length = self.request.width as usize * 4;
let start = self.row * length;
let row = self.output.get(start..start + length).ok_or_else(|| invalid("missing PNG row"))?;
self.encoder
.as_mut()
.ok_or_else(|| invalid("missing PNG writer"))?
.write_all(row)
.map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?;
self.row += 1;
return Ok(None);
}
self.encoder
.take()
.ok_or_else(|| invalid("missing PNG writer"))?
.finish()
.map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?;
self.cancel.check()?;
let mut bytes = self.encoded.lock().map_err(|_| JobError::new(ErrorCode::Internal, "PNG output lock poisoned"))?;
let png = std::mem::take(&mut *bytes);
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err(JobError::new(ErrorCode::EncodingFailed, "invalid PNG output"));
}
self.output = Vec::new();
self.stage = Stage::Done;
Ok(Some(png))
}
}
fn raster_pixel(t: &Triangle, width: usize, back: Vec3, eye: Option<Vec3>, pixels: &mut [[u8; 4]], depth: &mut [f32]) -> Result<()> {
let [a, b, c] = t.points;
let (x, y) = (t.x as f64 + 0.5, t.y as f64 + 0.5);
let w0 = ((b[0] - x) * (c[1] - y) - (b[1] - y) * (c[0] - x)) / t.area;
let w1 = ((c[0] - x) * (a[1] - y) - (c[1] - y) * (a[0] - x)) / t.area;
let w2 = 1.0 - w0 - w1;
// A shared triangle edge belongs to exactly one raster triangle, including glass.
for (w, mut p, mut q) in [(w0, b, c), (w1, c, a), (w2, a, b)] {
if w < -1e-9 {
return Ok(());
}
if w.abs() <= 1e-9 {
if t.area < 0.0 {
std::mem::swap(&mut p, &mut q);
}
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
if !(dy < 0.0 || (dy == 0.0 && dx > 0.0)) {
return Ok(());
}
}
}
let z = (w0 * a[2] + w1 * b[2] + w2 * c[2]) as f32;
let index = t.y * width + t.x;
let old_depth = depth.get_mut(index).ok_or_else(|| invalid("missing pixel depth"))?;
if z > *old_depth {
return Ok(());
}
let weights = [w0 * t.inv_w[0], w1 * t.inv_w[1], w2 * t.inv_w[2]];
let sum: f64 = weights.iter().sum();
if !sum.is_finite() || sum <= 1e-12 {
return Ok(());
}
let n = t.normals.iter().zip(weights).fold(Vec3::ZERO, |v, (n, w)| v + *n * (w / sum)).normalized().unwrap_or(Vec3::Z);
let colour = if let Some(surface) = t.surface {
let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
let view = eye.map_or(back, |eye| eye - world);
let radiance = crate::studio::shade(surface, n, view).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
} else {
crate::raster::shade(n, back, t.colour)
};
if colour.iter().any(|v| !v.is_finite()) {
return Err(invalid("non-finite shaded colour"));
}
let pixel = pixels.get_mut(index).ok_or_else(|| invalid("missing colour pixel"))?;
if t.opacity >= 1.0 {
*old_depth = z;
*pixel = [encode(colour[0]), encode(colour[1]), encode(colour[2]), 255];
} else {
let old_alpha = f64::from(pixel[3]) / 255.0;
let alpha = t.opacity + old_alpha * (1.0 - t.opacity);
for (out, src) in pixel.iter_mut().take(3).zip(colour) {
*out = encode((src * t.opacity + decode(*out) * old_alpha * (1.0 - t.opacity)) / alpha);
}
pixel[3] = (alpha * 255.0).round() as u8;
}
Ok(())
}
fn decode(v: u8) -> f64 {
let s = f64::from(v) / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
}
fn encode(v: f64) -> u8 {
let l = v.clamp(0.0, 1.0);
let s = if l <= 0.0031308 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round() as u8
}
+147
View File
@@ -0,0 +1,147 @@
use super::*;
use crate::{Camera, ProductMesh, StandardView};
use solidcraft_geom::{Aabb3, Mesh};
fn request() -> ImageRequest {
let mut camera = Camera { distance: 50.0, ..Default::default() };
camera.set_view(StandardView::Front);
ImageRequest { camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
}
fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMesh {
let mesh = Mesh {
positions: vec![
Vec3::new(-10.0, 0.0, -10.0) + offset,
Vec3::new(10.0, 0.0, -10.0) + offset,
Vec3::new(10.0, 0.0, 10.0) + offset,
Vec3::new(-10.0, 0.0, 10.0) + offset,
],
normals: vec![Vec3::new(0.0, -1.0, 0.0); 4],
triangles: vec![[0, 1, 2], [0, 2, 3]],
tri_face: vec![0, 1],
..Default::default()
};
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, faces: Vec::new() }
}
fn scene(meshes: Vec<ProductMesh>) -> ProductScene {
let bounds = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.mesh.bounds()));
ProductScene { meshes, bounds }
}
fn finish(mut task: ImageTask) -> Vec<u8> {
for _ in 0..100_000 {
match task.step(4096).unwrap() {
TaskPoll::Complete(png) => return png,
TaskPoll::Pending(p) => assert!(p.done <= p.total, "{p:?}"),
}
}
panic!("bounded fixture did not finish");
}
#[test]
fn transparent_png_has_straight_srgb_colour_without_a_shared_edge_seam() {
let scene = scene(vec![square("Glass", [220, 40, 20], 0.5, Vec3::ZERO)]);
let png = finish(ImageTask::new(scene, request(), CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [0; 4]);
for (x, y) in [(32, 32), (31, 31), (30, 32), (32, 30)] {
let p = image.get_pixel(x, y).0;
assert!((i16::from(p[3]) - 128).abs() <= 1, "a shared diagonal must not double-blend: {p:?}");
assert!(p[0] > 180 && p[0] > p[1] + 60, "RGB is straight, not multiplied by 0.5: {p:?}");
}
let reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
assert_eq!(reader.info().srgb, Some(png::SrgbRenderingIntent::Perceptual));
}
#[test]
fn glass_is_sorted_back_to_front_independently_of_body_order() {
let request = request();
let far = square("Far", [240, 20, 20], 0.5, request.camera.back() * -5.0);
let near = square("Near", [20, 20, 240], 0.5, request.camera.back() * 5.0);
let a = finish(ImageTask::new(scene(vec![far.clone(), near.clone()]), request.clone(), CancelToken::default()).unwrap());
let b = finish(ImageTask::new(scene(vec![near, far]), request, CancelToken::default()).unwrap());
assert_eq!(a, b);
let image = image::load_from_memory(&a).unwrap().to_rgba8();
let p = image.get_pixel(32, 32).0;
assert!((i16::from(p[3]) - 192).abs() <= 1, "two half-transparent layers: {p:?}");
assert!(p[2] > p[0] + 30, "the near blue layer contributes more: {p:?}");
}
#[test]
fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
for stage in [Stage::Looks, Stage::Prepare, Stage::Sort, Stage::Clear, Stage::Glass, Stage::Downsample, Stage::Encode] {
let cancel = CancelToken::default();
let mut task = ImageTask::new(scene(vec![square("Glass", [40, 180, 100], 0.4, Vec3::ZERO)]), request(), cancel.clone()).unwrap();
let mut reached = false;
for _ in 0..100_000 {
if task.stage == stage {
reached = true;
break;
}
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
}
assert!(reached);
cancel.cancel();
assert_eq!(task.step(4096).err().unwrap().code, ErrorCode::Cancelled);
}
}
#[test]
fn encoding_yields_between_rows_and_completed_result_is_consumed_once() {
let mut task = ImageTask::new(scene(Vec::new()), request(), CancelToken::default()).unwrap();
while task.stage != Stage::Encode {
assert!(matches!(task.step(64).unwrap(), TaskPoll::Pending(_)));
}
let before = task.row;
assert!(matches!(task.step(usize::MAX).unwrap(), TaskPoll::Pending(_)));
assert!(task.row - before <= 4);
loop {
if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() {
assert_eq!(image::load_from_memory(&bytes).unwrap().width(), 64);
break;
}
}
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::NotCompleted);
}
#[test]
fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
let body = square("Product", [100, 120, 140], 1.0, Vec3::ZERO);
let mut bad_request = request();
bad_request.width = u32::MAX;
assert!(ImageTask::new(scene(vec![body.clone()]), bad_request, CancelToken::default()).is_err());
for bad in [0, 1] {
let mut body = body.clone();
if bad == 0 {
Arc::make_mut(&mut body.mesh).triangles[0][0] = u32::MAX;
} else {
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None });
}
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
let error = loop {
if let Err(error) = task.step(64) {
break error;
}
};
assert_eq!(error.code, ErrorCode::InvalidScene);
}
let mut body = body;
Arc::make_mut(&mut body.mesh).triangles.resize(MAX_TRIANGLES as usize + 1, [0, 1, 2]);
assert_eq!(ImageTask::new(scene(vec![body]), request(), CancelToken::default()).err().unwrap().code, ErrorCode::LimitExceeded);
assert_eq!(std::mem::size_of::<Draw>(), 16, "admission accounts for each of the three lists");
}
#[test]
fn png_output_budget_and_cancellation_fail_without_partial_publication() {
let bytes = Arc::new(Mutex::new(Vec::new()));
let cancel = CancelToken::default();
let mut writer = BufferWriter { bytes: bytes.clone(), limit: 4, cancel: cancel.clone() };
writer.write_all(b"PNG").unwrap();
assert!(writer.write_all(b"too large").is_err());
assert_eq!(*bytes.lock().unwrap(), b"PNG");
cancel.cancel();
assert!(writer.write_all(b"x").is_err());
assert!(writer.flush().is_err());
assert_eq!(*bytes.lock().unwrap(), b"PNG");
}
+140
View File
@@ -0,0 +1,140 @@
//! CPU product-image work and its device-independent, session-scoped protocol.
//! Hosts own execution; documents and history never contain these values.
use std::fmt;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use serde::{Deserialize, Serialize};
use crate::{BackendId, Camera, ExecutorId};
mod host;
mod image;
pub use host::{CpuHost, HostKind, JobHandle, JobStatus, RenderFrame};
pub use image::{Admission, ImageTask, TaskPoll, admit};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobId {
pub session: u64,
pub serial: u64,
pub generation: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Queued,
Preparing,
Running,
Encoding,
Cancelling,
Cancelled,
Completed,
Failed,
}
impl Phase {
pub fn terminal(self) -> bool {
matches!(self, Self::Cancelled | Self::Completed | Self::Failed)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct Progress {
pub phase: Phase,
pub done: u64,
pub total: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
InvalidRequest,
LimitExceeded,
AllocationFailed,
HostUnavailable,
QueueFull,
TimedOut,
Cancelled,
InvalidScene,
EncodingFailed,
WrongOwner,
UnknownJob,
NotCompleted,
Internal,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct JobError {
pub code: ErrorCode,
pub message: String,
}
impl JobError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self { code, message: message.into() }
}
}
impl fmt::Display for JobError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.code, self.message)
}
}
impl std::error::Error for JobError {}
pub type Result<T> = std::result::Result<T, JobError>;
#[derive(Clone, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
pub fn cancel(&self) {
self.0.store(true, Ordering::Release);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Acquire)
}
pub fn check(&self) -> Result<()> {
if self.is_cancelled() { Err(JobError::new(ErrorCode::Cancelled, "render cancelled")) } else { Ok(()) }
}
}
/// Fully resolved input; no mutable session, topology graph or GPU resources.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageRequest {
pub camera: Camera,
pub width: u32,
pub height: u32,
pub background: [u8; 3],
pub transparent_background: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ColourEncoding {
Srgb,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AlphaConvention {
Straight,
}
#[derive(Clone, Debug, Serialize)]
pub struct FrameInfo {
pub job: JobId,
pub scene_generation: u64,
pub backend: BackendId,
pub executor: ExecutorId,
pub width: u32,
pub height: u32,
pub encoding: ColourEncoding,
pub alpha: AlphaConvention,
}
+4
View File
@@ -7,8 +7,11 @@
mod backend;
mod camera;
mod curve_display;
pub mod job;
pub mod lighting;
mod product;
mod raster;
pub mod studio;
pub mod surface;
pub mod texture_input;
@@ -19,4 +22,5 @@ pub use backend::{
};
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use product::{FaceAppearance, ProductMesh, ProductScene};
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
+42
View File
@@ -0,0 +1,42 @@
//! Renderer-independent product geometry. No modelling overlays or device resources.
use std::sync::Arc;
use solidcraft_geom::{Aabb3, Mesh};
#[derive(Clone, Debug, PartialEq)]
pub struct FaceAppearance {
/// The existing mesh's B-rep face index, resolved by the document's appearance policy.
pub face: u32,
pub color: [u8; 3],
pub opacity: f64,
pub surface: Option<crate::surface::OpaqueSurface>,
}
#[derive(Clone, Debug)]
pub struct ProductMesh {
/// Existing computed body name, including occurrence identity where applicable.
pub name: String,
/// Established world-space mesh. Adapters must not apply occurrence transforms again.
pub mesh: Arc<Mesh>,
pub color: [u8; 3],
pub opacity: f64,
pub surface: Option<crate::surface::OpaqueSurface>,
pub faces: Vec<FaceAppearance>,
}
#[derive(Clone, Debug)]
pub struct ProductScene {
pub meshes: Vec<ProductMesh>,
pub bounds: Aabb3,
}
impl ProductScene {
pub fn required_features(&self) -> Vec<crate::RenderFeature> {
if self.meshes.iter().any(|m| m.surface.is_some() || m.faces.iter().any(|f| f.surface.is_some())) {
vec![crate::RenderFeature::Pbr]
} else {
Vec::new()
}
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ impl Canvas {
}
}
fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
pub(crate) fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
let n = if n.dot(view_dir) < 0.0 { -n } else { n };
let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir);
let diff = n.dot(key).max(0.0);
+51
View File
@@ -0,0 +1,51 @@
//! Original fixed direct-light studio used by opaque product preview consumers.
//! Relative linear radiance, not a calibrated illuminance or an HDRI environment.
use solidcraft_geom::Vec3;
use crate::surface::{OpaqueSurface, SurfaceError, unit_direction};
/// Fixed, neutral directional lights in the project's world frame (Z up).
/// Directions point towards each light and are analytically unit length.
pub const LIGHTS: [(Vec3, f64); 3] = [(Vec3::new(0.6, 0.0, 0.8), 3.0), (Vec3::new(-0.8, 0.0, 0.6), 1.0), (Vec3::new(0.0, -0.6, 0.8), 1.5)];
/// Sum BRDF * incident radiance * light cosine, without display transfer.
/// There are no cast shadows, indirect bounces, ambient term or emission.
pub fn shade(surface: OpaqueSurface, normal: Vec3, view: Vec3) -> Result<Vec3, SurfaceError> {
let n = unit_direction(normal)?;
let mut radiance = Vec3::ZERO;
for (light, intensity) in LIGHTS {
radiance += surface.brdf(n, view, light)? * (intensity * n.dot(light).max(0.0));
}
Ok(radiance)
}
/// Per-channel Reinhard preview tone map, still in linear sRGB.
/// Consumers encode exactly once after applying this to nonnegative finite radiance.
pub fn tone_map(radiance: Vec3) -> Result<Vec3, SurfaceError> {
if !radiance.is_finite() || radiance.x < 0.0 || radiance.y < 0.0 || radiance.z < 0.0 {
return Err(SurfaceError::Radiance);
}
let map = |c: f64| c / (1.0 + c);
Ok(Vec3::new(map(radiance.x), map(radiance.y), map(radiance.z)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
#[test]
fn direct_white_conductor_matches_closed_form_and_is_tone_mapped_once() {
let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 1.0, 1.5).unwrap();
let c = shade(s, Vec3::Z, Vec3::Z).unwrap();
let expected = (4.5 * 0.8 / 1.8 + 0.6 / 1.6) / (2.0 * PI);
assert!((c - Vec3::new(expected, expected, expected)).len() < 1e-14);
let mapped = tone_map(c).unwrap();
assert!((mapped.x - expected / (1.0 + expected)).abs() < 1e-14);
assert_eq!(tone_map(Vec3::ZERO).unwrap(), Vec3::ZERO);
assert!(tone_map(Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
assert!(tone_map(Vec3::new(-1.0, 0.0, 0.0)).is_err());
assert!(shade(s, Vec3::ZERO, Vec3::Z).is_err());
}
}
+8 -5
View File
@@ -25,6 +25,7 @@ pub enum SurfaceError {
Roughness,
DielectricIor,
Direction,
Radiance,
}
impl std::fmt::Display for SurfaceError {
@@ -35,6 +36,7 @@ impl std::fmt::Display for SurfaceError {
Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]",
Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]",
Self::Direction => "surface directions must be finite and nonzero",
Self::Radiance => "radiance must be finite and nonnegative",
})
}
}
@@ -81,15 +83,15 @@ impl OpaqueSurface {
/// No incident radiance, cosine weight, exposure or display transfer is included.
/// Back-facing/tangent directions return zero; malformed directions are errors.
pub fn brdf(self, normal: Vec3, view: Vec3, light: Vec3) -> Result<Vec3, SurfaceError> {
let n = unit(normal)?;
let v = unit(view)?;
let l = unit(light)?;
let n = unit_direction(normal)?;
let v = unit_direction(view)?;
let l = unit_direction(light)?;
let nv = n.dot(v).clamp(-1.0, 1.0);
let nl = n.dot(l).clamp(-1.0, 1.0);
if nv <= 0.0 || nl <= 0.0 {
return Ok(Vec3::ZERO);
}
let h = unit(v + l)?;
let h = unit_direction(v + l)?;
let nh = n.dot(h).clamp(0.0, 1.0);
let vh = v.dot(h).clamp(0.0, 1.0);
let alpha = self.roughness * self.roughness;
@@ -122,7 +124,8 @@ pub fn srgb8_to_linear(channel: u8) -> f64 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
fn unit(v: Vec3) -> Result<Vec3, SurfaceError> {
/// Normalize finite directions without overflow or an arbitrary magnitude threshold.
pub fn unit_direction(v: Vec3) -> Result<Vec3, SurfaceError> {
if !v.is_finite() {
return Err(SurfaceError::Direction);
}
+17 -3
View File
@@ -329,8 +329,15 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id),
Target::Toolbar => crate::toolbar_custom::toolbar_items(app),
Target::Workspace => {
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
for w in ["Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
let rendering = crate::render_workspace::active(app);
let mut v = vec![
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.render", "Render", "camera")
.with(json!({"workspace":"render"}))
.key(if rendering { "current" } else { "" })
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
];
for w in ["Animation", "Simulation", "Manufacture", "Drawing"] {
v.push(act("ui.workspace", w, "").key("coming later").on(false));
}
v
@@ -975,7 +982,13 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => {
crate::toolbar_custom::run(app, &item.id, &p)
}
"ui.workspace" => {}
"ui.workspace" | "ui.workspace.design" | "ui.workspace.render" => {
if let Some(workspace) = p.get("workspace").and_then(Value::as_str)
&& let Err(e) = crate::render_workspace::enter(app, workspace == "render")
{
app.set_status(e, true);
}
}
"ui.findJoint" => {
if let Some(id) = id_of(&p, "joint") {
crate::dialogs_assembly::find_joint(app, id);
@@ -1043,6 +1056,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
{
k.color = look.color;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
}
k.component = Some(name);
+18
View File
@@ -185,10 +185,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome {
crate::render_workspace::sync_backend(app);
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
match method {
"ui.workspace" => {
if let Some(workspace) = s("workspace") {
if !matches!(workspace, "design" | "render") {
return err("workspace must be design or render");
}
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
return err(e);
}
}
ok(json!({"workspace":if crate::render_workspace::active(app) {"render"} else {"design"}}))
}
"engine.execute" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
wrap(execute(app, id, p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))))
@@ -230,6 +242,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"frame_ms": app.frame_ms,
"file_busy": app.file_load.busy(),
"file_picker_busy": app.file_picker_busy(),
"file_dialog_open": app.file_picker_busy(),
"build_ms": app.viewport.build_ms,
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
@@ -283,6 +296,11 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
}
match (serde_json::from_value::<crate::UiState>(cur), serde_json::from_value::<solidcraft_engine::Visibility>(vis)) {
(Ok(u), Ok(v)) => {
if (u.tab == "RENDER") != crate::render_workspace::active(app)
&& let Err(e) = crate::render_workspace::enter(app, u.tab == "RENDER")
{
return err(e);
}
app.ui = u;
app.session.visibility = v;
ok(serde_json::to_value(&app.ui).unwrap_or_default())
+89 -7
View File
@@ -6,6 +6,7 @@
use egui::{Color32, RichText, Sense, Stroke, vec2};
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::pbr_appearance::PbrParameters;
use crate::SolveApp;
use crate::dialogs::{Kind, combo, row_label};
@@ -20,11 +21,12 @@ pub struct Look {
pub category: String,
pub color: [u8; 3],
pub opacity: f64,
pub pbr: Option<PbrParameters>,
}
/// Built-in looks, used until the engine offers a library (`appearance.library`).
fn builtin() -> Vec<Look> {
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o, pbr: None };
vec![
l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0),
l("Metal", "Steel - Brushed", [160, 164, 170], 1.0),
@@ -64,6 +66,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
pbr: x.get("pbr").and_then(|p| serde_json::from_value(p.clone()).ok()),
})
})
.collect();
@@ -98,6 +101,7 @@ pub struct Ap {
pub faces: bool,
pub color: [u8; 3],
pub opacity: f64,
pub pbr: Option<PbrParameters>,
/// The look picked last (its name), if the colour came from one.
pub look: Option<String>,
/// Library category shown (0: all).
@@ -141,6 +145,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
faces,
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
pbr: existing.as_ref().and_then(|l| l.pbr),
look: existing.map(|l| l.name),
category: 0,
search: String::new(),
@@ -200,11 +205,15 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
}
pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
if let Some(component) = &k.component {
let mut p = if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
} else {
apply_params(items, k.color, k.opacity, k.look.as_deref())
}?;
if let Some(pbr) = k.pbr {
p["pbr"] = json!(pbr);
}
Some(p)
}
/// Removing an override restores the inherited component or material appearance.
@@ -214,6 +223,7 @@ fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
obj.remove("appearance");
obj.remove("color");
obj.remove("opacity");
obj.remove("pbr");
}
Some(p)
}
@@ -244,6 +254,7 @@ fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
category: "In This Design".into(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
pbr: x.get("pbr").and_then(|p| serde_json::from_value(p.clone()).ok()),
},
count(x, "bodies") + count(x, "faces") + count(x, "components"),
))
@@ -252,7 +263,7 @@ fn in_design(app: &mut SolveApp) -> Vec<(Look, usize)> {
}
let mut out: Vec<(Look, usize)> = Vec::new();
for l in app.session.doc.appearances.bodies.values() {
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity };
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity, pbr: l.pbr };
match out.iter_mut().find(|(x, _)| *x == look) {
Some(e) => e.1 += 1,
None => out.push((look, 1)),
@@ -347,10 +358,49 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
row_label(ui, "Opacity");
ui.spacing_mut().slider_width = 110.0;
// Decimal formatting must not round a saved look merely by drawing its editor.
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() {
if ui
.add_enabled(
k.pbr.is_none(),
egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}")),
)
.changed()
{
k.remove_override = false;
}
ui.end_row();
row_label(ui, app.tr("appearance.surface_response"));
let mut enabled = k.pbr.is_some();
let toggle = ui.checkbox(&mut enabled, app.tr("appearance.pbr_opaque")).on_hover_text(app.tr("appearance.pbr_help"));
crate::scenario::publish_handle("appearance:pbr", toggle.rect.center());
if toggle.changed() {
k.pbr = enabled.then(PbrParameters::default);
if enabled {
k.opacity = 1.0;
}
k.remove_override = false;
}
ui.end_row();
if let Some(parameters) = k.pbr {
let mut metallic = parameters.metallic();
let mut roughness = parameters.roughness();
let mut ior = parameters.dielectric_ior();
for (label, handle, value, range) in [
("appearance.metallic", "appearance:metallic", &mut metallic, 0.0..=1.0),
("appearance.roughness", "appearance:roughness", &mut roughness, 0.045..=1.0),
("appearance.dielectric_ior", "appearance:ior", &mut ior, 1.0..=3.0),
] {
row_label(ui, app.tr(label));
let response = ui.add(egui::DragValue::new(value).range(range).speed(0.01));
crate::scenario::publish_handle(handle, response.rect.center());
if response.changed() {
k.remove_override = false;
}
ui.end_row();
}
if let Ok(parameters) = PbrParameters::new(metallic, roughness, ior) {
k.pbr = Some(parameters);
}
}
row_label(ui, "");
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
ui.horizontal(|ui| {
@@ -396,7 +446,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
egui::Id::new(("ap_used", *i)),
look.color,
look.opacity,
k.color == look.color && k.opacity == look.opacity,
k.color == look.color && k.opacity == look.opacity && k.pbr == look.pbr,
&format!("{} · {n} item(s)", look.name),
look.clone(),
)
@@ -404,6 +454,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
{
k.color = look.color;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
k.remove_override = false;
}
@@ -435,12 +486,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
ui.label(RichText::new(crate::i18n::tr_label("No matching appearances")).color(t.text_dim));
}
swatch_rows(ui, &shown, |ui, (i, l)| {
let picked_look = k.look.as_deref() == Some(l.name.as_str());
let picked_look = k.look.as_deref() == Some(l.name.as_str()) && k.pbr == l.pbr;
if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone())
.clicked()
{
k.color = l.color;
k.opacity = l.opacity;
k.pbr = l.pbr;
k.look = Some(l.name.clone());
k.remove_override = false;
}
@@ -452,9 +504,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let targets = drop_target(app, k.faces);
k.color = look.color;
k.opacity = look.opacity;
k.pbr = look.pbr;
k.look = Some(look.name.clone());
k.remove_override = false;
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
if let Some(mut p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
if let Some(pbr) = look.pbr {
p["pbr"] = json!(pbr);
}
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
@@ -500,6 +556,32 @@ mod tests {
SolveApp::new(s, Services::default())
}
#[test]
fn clicked_pbr_editor_commits_once_and_reopens_without_changing_mass() {
let mut h = crate::scenario::Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
let mass = h.app.session.execute("component.properties", &json!({})).unwrap();
let before = h.app.session.undo.len();
h.app.start("appearance.assign");
h.frames(3);
h.step(&json!({"click":{"handle":"appearance:pbr"}})).unwrap();
h.step(&json!({"widget":"Apply to selection"})).unwrap();
assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_some());
assert_eq!(h.app.session.undo.len(), before + 1);
h.step(&json!({"widget":"OK"})).unwrap();
assert_eq!(h.app.session.undo.len(), before + 1);
assert_eq!(h.app.session.execute("component.properties", &json!({})).unwrap(), mass);
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
let Kind::Appearance(k) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance editor") };
assert_eq!(k.pbr, Some(PbrParameters::default()));
assert_eq!(k.opacity, 1.0);
let params = selected_params(k, &h.app.dialog.as_ref().unwrap().inputs[0].items).unwrap();
assert_eq!(params["pbr"]["roughness"], 0.5);
}
#[test]
fn colours_parse_both_ways() {
assert_eq!(parse_color(&json!("#0a1B2c")), Some([10, 27, 44]));
+56 -29
View File
@@ -7,9 +7,12 @@ use serde_json::json;
use solidcraft_engine::io::catalog::Catalog;
#[cfg(not(target_arch = "wasm32"))]
use solidcraft_engine::io::catalog::Definition;
use std::sync::Arc;
#[derive(Clone, Debug, Default)]
pub struct LocalLibrary {
/// A delayed picker may update only the editor that requested it.
token: Arc<()>,
pub search: String,
pub path: String,
pub catalog: Option<Catalog>,
@@ -22,15 +25,55 @@ fn display_order(mut catalog: Catalog) -> Catalog {
catalog
}
/// Runs `f` on the open Appearance dialog's state, once a file picker has answered.
fn with_state(app: &mut SolveApp, f: impl FnOnce(&mut SolveApp, &mut Ap)) {
let Some(mut dialog) = app.dialog.take() else { return };
if let crate::dialogs::Kind::Appearance(k) = &mut dialog.kind {
f(app, k);
}
if app.dialog.is_none() {
app.dialog = Some(dialog);
}
fn editor<'a>(app: &'a mut SolveApp, token: &Arc<()>) -> Option<&'a mut Ap> {
let crate::dialogs::Kind::Appearance(k) = &mut app.dialog.as_mut()?.kind else { return None };
Arc::ptr_eq(&k.local.token, token).then_some(k)
}
fn save_library(app: &mut SolveApp, token: Arc<()>, overwrite: bool) {
let origin = app.session.doc.clone();
app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| {
let Some(path) = path else { return };
if !Arc::ptr_eq(&origin, &app.session.doc) {
app.set_status("The library export document changed while choosing its destination", true);
return;
}
match app.run("library.export", json!({"path":path,"overwrite":overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
if let Some(k) = editor(app, &token) {
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
k.note = Some(format!("Saved {count} reusable definitions. The design is unchanged."));
}
}
Err(error) => {
if let Some(k) = editor(app, &token) {
k.note = Some(error);
}
}
}
});
}
#[cfg(not(target_arch = "wasm32"))]
fn open_library(app: &mut SolveApp, token: Arc<()>) {
app.pick_open(move |app, path| {
let Some(path) = path else { return };
let Some(k) = editor(app, &token) else { return };
let mut local = k.local.clone();
local.path = path;
local.catalog = None;
local.selected = None;
let note = load(app, &mut local).unwrap_or_else(|error| error);
if let Some(k) = editor(app, &token) {
k.local = local;
k.note = Some(note);
}
});
}
/// Exports the design's library to `path` and shows it as the local library.
@@ -74,17 +117,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInpu
let save = ui.button(crate::i18n::tr_label("Export design library..."));
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
let overwrite = k.local.overwrite;
if app.services.pick_save.is_some() {
// The picker answers in a later frame (#1088); the export runs then.
app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| {
if let Some(path) = path {
with_state(app, |app, k| export(app, k, path, overwrite));
}
});
save_library(app, k.local.token.clone(), k.local.overwrite);
} else if !k.local.path.is_empty() {
let path = k.local.path.clone();
export(app, k, path, overwrite);
export(app, k, k.local.path.clone(), k.local.overwrite);
}
}
ui.label(
@@ -106,17 +142,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
}
ui.horizontal(|ui| {
if ui.button(crate::i18n::tr_label("Open library...")).clicked() {
// The picker answers in a later frame (#1088); the library loads then.
app.pick_open(|app, path| {
if let Some(path) = path {
with_state(app, |app, k| {
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
});
}
});
open_library(app, k.local.token.clone());
}
let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library")));
crate::scenario::publish_handle("library:load", load_button.rect.center());
@@ -194,6 +220,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
Ok(_) => {
if let Definition::Appearance(look) = entry.definition {
k.color = look.color;
k.pbr = None;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
+126 -1
View File
@@ -12,7 +12,13 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
source.execute("library.export", &json!({"path":path})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20]})).unwrap();
h.app
.run(
"appearance.assign",
json!({"bodies":["Body1"],"color":[20,20,20],
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
)
.unwrap();
h.app.session.mark_saved();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
@@ -27,6 +33,7 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap();
h.step(&json!({"widget":"Apply library appearance"})).unwrap();
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
let revision = h.app.session.revision;
h.step(&json!({"widget":"Export design library..."})).unwrap();
@@ -39,3 +46,121 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
assert!(h.app.dialog.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
}
fn appearance_fixture() -> Result<(Harness, Arc<()>), String> {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10}))?;
h.app.run("appearance.assign", json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70]}))?;
h.app.session.mark_saved();
let token = start_appearance(&mut h)?;
Ok((h, token))
}
fn start_appearance(h: &mut Harness) -> Result<Arc<()>, String> {
h.app.start("appearance.assign");
match h.app.dialog.as_ref().map(|dialog| &dialog.kind) {
Some(Kind::Appearance(k)) => Ok(k.local.token.clone()),
_ => Err("appearance dialog missing".into()),
}
}
#[test]
fn async_library_export_holds_its_document_until_the_answer() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let original = h.app.session.doc.clone();
let path = h.dir.join("delayed-library.json");
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
save_library(&mut h.app, token, false);
crate::file_picker::tick(&mut h.app, &ctx);
assert!(!path.exists());
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!(Arc::ptr_eq(&other, &h.app.session.doc));
let catalog = Catalog::read(&path.to_string_lossy()).unwrap();
assert_eq!(catalog.entries.len(), 1);
assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour");
crate::documents::switch(&mut h.app, 0);
assert!(Arc::ptr_eq(&original, &h.app.session.doc));
assert!(!h.app.session.is_dirty());
}
#[test]
fn async_library_export_captures_overwrite_consent_and_cancel_writes_nothing() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let path = h.dir.join("existing-library.json");
std::fs::write(&path, b"keep this file").unwrap();
let original = h.app.session.doc.clone();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
save_library(&mut h.app, token.clone(), false);
crate::file_picker::tick(&mut h.app, &ctx);
editor(&mut h.app, &token).unwrap().local.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"keep this file");
assert!(matches!(&h.app.status, Some((_, _, true))));
save_library(&mut h.app, token, 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"keep this file");
assert!(Arc::ptr_eq(&original, &h.app.session.doc));
}
#[test]
fn async_library_open_does_not_replace_a_new_appearance_editor() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let path = h.dir.join("source-library.json");
h.app.run("library.export", json!({"path":path})).unwrap();
let original = h.app.session.doc.clone();
let ctx = egui::Context::default();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
// Prepare a different editor before the picker holds user commands, then model
// an internal host replacing it while the old asynchronous answer is pending.
let original_editor = h.app.dialog.take();
let newer = start_appearance(&mut h).unwrap();
editor(&mut h.app, &newer).unwrap().local.path = "new editor draft".into();
let replacement_editor = h.app.dialog.take();
h.app.dialog = original_editor;
crate::picker_test_support::open(&mut h.app, &slot);
open_library(&mut h.app, token);
crate::file_picker::tick(&mut h.app, &ctx);
h.app.dialog = replacement_editor;
*slot.borrow_mut() = Some(Some(path.to_string_lossy().into_owned()));
crate::file_picker::tick(&mut h.app, &ctx);
let k = editor(&mut h.app, &newer).unwrap();
assert_eq!(k.local.path, "new editor draft");
assert!(k.local.catalog.is_none());
assert!(Arc::ptr_eq(&original, &h.app.session.doc));
}
#[test]
fn delayed_library_export_refuses_an_internally_changed_design_without_overwriting() {
use crate::picker_test_support::{Slot, save};
let (mut h, token) = appearance_fixture().unwrap();
let path = h.dir.join("keep.json");
std::fs::write(&path, b"keep library").unwrap();
let slot: Slot = Default::default();
save(&mut h.app, &slot);
save_library(&mut h.app, token, true);
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 library");
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))));
}
+9
View File
@@ -27,6 +27,7 @@ pub struct DocSlot {
/// The per-design part of the UI state (what is hidden, shown or locked).
#[derive(Clone, Debug, Default)]
struct DocView {
render: crate::render_workspace::ViewState,
sketch_choices: crate::sketch_families::Choices,
hidden_profiles: Vec<u64>,
shown_dims: Vec<u64>,
@@ -64,6 +65,7 @@ impl Documents {
fn take_view(app: &mut SolveApp) -> DocView {
let u = &mut app.ui;
DocView {
render: std::mem::take(&mut app.render_workspace.view),
sketch_choices: std::mem::take(&mut app.sketch_choices),
hidden_profiles: std::mem::take(&mut u.hidden_profiles),
shown_dims: std::mem::take(&mut u.shown_dims),
@@ -72,6 +74,9 @@ fn take_view(app: &mut SolveApp) -> DocView {
}
fn put_view(app: &mut SolveApp, v: DocView) {
app.render_workspace.view = v.render;
crate::render_workspace::invalidate_scene(app);
crate::render_workspace::sync_backend(app);
app.sketch_choices = v.sketch_choices;
let u = &mut app.ui;
u.hidden_profiles = v.hidden_profiles;
@@ -81,6 +86,9 @@ fn put_view(app: &mut SolveApp, v: DocView) {
/// Put `slot` into the app and return what was there.
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
if crate::render_workspace::active(app) {
let _ = crate::render_workspace::enter(app, false);
}
// Whatever was going on in the design leaves with it.
crate::sketch_text_move::cancel_drag(app);
crate::dialogs::cancel(app);
@@ -162,6 +170,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
app.menu.props = None;
crate::sketch_attachment_ui::clear(app);
app.session.replace_design(session);
crate::render_workspace::reset_document(app);
app.sketch_choices = crate::sketch_families::Choices::default();
app.session.invalidate_view(rev);
app.cam = Camera::default();
+79 -5
View File
@@ -11,6 +11,9 @@ use egui_wgpu::wgpu::util::DeviceExt;
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
pub const TRI_SIZE: usize = 28;
#[path = "pbr_gpu.rs"]
mod pbr;
pub const PBR_SIZE: usize = pbr::VERTEX_SIZE;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
pub const LINE_SIZE: usize = 32;
/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their
@@ -31,6 +34,8 @@ pub struct PlannedBody {
pub struct GpuScene {
/// Opaque triangles. A zero normal draws the colour flat (unlit).
pub tris: Vec<u8>,
/// Opaque product PBR vertices; linear base color and explicit optical parameters.
pub pbr: Vec<u8>,
/// Translucent triangles (alpha from the colour), drawn after the opaque ones without
/// writing depth.
pub trans: Vec<u8>,
@@ -99,6 +104,13 @@ pub struct BlockBytes {
}
impl GpuScene {
pub fn pbr_tri(&mut self, p: [f32; 3], n: [f32; 3], surface: solidcraft_engine::render::surface::OpaqueSurface) {
let base = surface.base_color().to_f32();
let parameters = [surface.metallic() as f32, surface.roughness() as f32, surface.dielectric_ior() as f32];
for value in p.into_iter().chain(n).chain(base).chain(parameters) {
self.pbr.extend_from_slice(&value.to_le_bytes());
}
}
pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) {
for v in p.iter().chain(&n) {
self.tris.extend_from_slice(&v.to_le_bytes());
@@ -176,6 +188,8 @@ const IMG_SIZE: usize = 24;
/// Per-frame parameters.
pub struct ViewportCallback {
/// Product preview uses its own batches, retaining the Design viewport's uploads.
pub product: bool,
pub key: u64,
pub slot: SceneSlot,
pub sketch_key: u64,
@@ -265,6 +279,7 @@ struct Batches {
blocks: std::collections::HashMap<u64, BlockGpu>,
key: Option<u64>,
tris: Option<Batch>,
pbr: Option<Batch>,
trans: Option<Batch>,
glass: Option<Batch>,
/// The glass triangles as built, and the view direction they were last sorted for.
@@ -315,6 +330,7 @@ impl Batches {
}
}
self.tris = joined(blocks.iter().map(|b| &b.tris), upload(device, "sc_tris", &sc.tris, TRI_SIZE));
self.pbr = upload(device, "sc_pbr", &sc.pbr, PBR_SIZE);
self.trans = joined(blocks.iter().map(|b| &b.trans), upload(device, "sc_trans", &sc.trans, TRI_SIZE));
self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST);
self.glass_cpu = sc.glass;
@@ -401,6 +417,7 @@ struct Resources {
tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// Highlight triangles over the model (depth test ≤, no depth write).
tri_hl: wgpu::RenderPipeline,
pbr: wgpu::RenderPipeline,
trans: wgpu::RenderPipeline,
/// Glass: far sides (front faces culled), then near sides (back faces culled).
glass_far: wgpu::RenderPipeline,
@@ -415,6 +432,8 @@ struct Resources {
bind: wgpu::BindGroup,
linear_out: bool,
model: Batches,
product: Batches,
empty: Batches,
sketch: Batches,
highlight: Batches,
preview: Batches,
@@ -471,6 +490,11 @@ fn tri_layout() -> wgpu::VertexBufferLayout<'static> {
/// A planned body's vertices as the GPU evaluation writes them: the position, then the normal
/// at [`solidcraft_gpu::NORMAL_OFFSET`] (each padded to four floats).
fn pbr_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Float32x3];
wgpu::VertexBufferLayout { array_stride: PBR_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A }
}
fn planned_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 2] = [
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 },
@@ -502,8 +526,9 @@ pub struct GpuTarget {
impl Resources {
fn new(device: &wgpu::Device, t: GpuTarget, tess: Option<solidcraft_gpu::TessGpu>) -> Self {
let source = format!("{SHADER}\n{}", pbr::SOURCE);
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(source.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sc_viewport"),
entries: &[wgpu::BindGroupLayoutEntry {
@@ -730,6 +755,7 @@ impl Resources {
access_bind,
access_version: 0,
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha),
pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None),
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
glass_far: glass("sc_glass_far", wgpu::Face::Front),
glass_near: glass("sc_glass_near", wgpu::Face::Back),
@@ -742,6 +768,8 @@ impl Resources {
bind,
linear_out: t.format.is_srgb(),
model: Batches::default(),
product: Batches::default(),
empty: Batches::default(),
sketch: Batches::default(),
highlight: Batches::default(),
preview: Batches::default(),
@@ -875,6 +903,12 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
if self.product {
res.product.take(device, self.key, &self.slot, None);
res.product.sort_glass(queue, self.back);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
return Vec::new();
}
res.model.take(device, self.key, &self.slot, res.tess.as_ref().map(|t| (t, &mut *encoder)));
res.sketch.take(device, self.sketch_key, &self.sketch_slot, None);
res.highlight.take(device, self.hl_key, &self.hl_slot, None);
@@ -965,7 +999,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
}
}
};
let (m, sk, h, pv) = (&res.model, &res.sketch, &res.highlight, &res.preview);
let (m, sk, h, pv) =
if self.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) };
lines(pass, &res.overlay, &m.under);
lines(pass, &res.overlay, &sk.under);
pass.set_bind_group(1, &res.access_bind, &[]);
@@ -995,6 +1030,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
planned(pass, &res.tri_planned);
}
}
tris(pass, &res.pbr, &m.pbr);
tris(pass, &res.tri_hl, &h.tris);
lines(pass, &res.line, &m.lines);
lines(pass, &res.line, &sk.lines);
@@ -1002,7 +1038,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
lines(pass, &res.line_hidden, &h.hidden);
lines(pass, &res.line, &pv.lines);
lines(pass, &res.line, &h.lines);
for (id, q) in &res.quads {
for (id, q) in res.quads.iter().filter(|_| !self.product) {
if let Some((_, bind)) = res.textures.get(id) {
pass.set_pipeline(&res.image);
pass.set_bind_group(1, bind, &[]);
@@ -1066,14 +1102,52 @@ pub(crate) fn render_offscreen(
analysis: [f32; 8],
colors: [f32; 36],
) -> Option<Vec<u8>> {
offscreen(scene, highlight, view_proj, back, eye, size, Offscreen { tess, product: false, srgb: false, analysis, analysis_colors: colors })
}
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn render_product_offscreen(
scene: GpuScene,
view_proj: [[f32; 4]; 4],
back: [f32; 3],
eye: [f32; 4],
size: u32,
srgb: bool,
) -> Option<Vec<u8>> {
offscreen(scene, None, view_proj, back, eye, size, Offscreen { tess: None, product: true, srgb, analysis: [0.0; 8], analysis_colors: [0.0; 36] })
}
#[cfg(all(test, not(target_arch = "wasm32")))]
struct Offscreen<'a> {
tess: Option<&'a str>,
product: bool,
srgb: bool,
analysis: [f32; 8],
analysis_colors: [f32; 36],
}
#[cfg(all(test, not(target_arch = "wasm32")))]
fn offscreen(
scene: GpuScene,
highlight: Option<GpuScene>,
view_proj: [[f32; 4]; 4],
back: [f32; 3],
eye: [f32; 4],
size: u32,
options: Offscreen<'_>,
) -> Option<Vec<u8>> {
let Offscreen { tess, product, srgb, analysis, analysis_colors } = options;
let (adapter, device, queue) = offscreen_device()?;
let (format, depth) = (wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Depth24PlusStencil8);
eprintln!("offscreen adapter: {:?}", adapter.get_info().backend);
let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm };
let depth = wgpu::TextureFormat::Depth24PlusStencil8;
let compute = tess.and_then(|shader| tessellation(&adapter, &device, &queue, shader));
let target = GpuTarget { format, depth: Some(depth), samples: 1, tess: compute.is_some() };
let mut resources = egui_wgpu::CallbackResources::default();
resources.insert(Resources::new(&device, target, compute));
let none = || Arc::new(Mutex::new(None));
let cb = ViewportCallback {
product,
key: 1,
slot: Arc::new(Mutex::new(Some(scene))),
sketch_key: 0,
@@ -1089,7 +1163,7 @@ pub(crate) fn render_offscreen(
cap: [0.0; 4],
images: Vec::new(),
analysis,
analysis_colors: colors,
analysis_colors,
access: None,
outline: false,
eye,
+65 -6
View File
@@ -98,6 +98,7 @@ pub mod preview;
mod primitive_units_tests;
pub mod recovery_ui;
pub mod ref_images;
pub mod render_workspace;
#[cfg(test)]
mod revolve_edit_tests;
#[cfg(test)]
@@ -217,7 +218,7 @@ impl Default for UiState {
}
}
/// Host services (file pickers) injected by the app so this crate stays portable.
/// Host services injected by the app so this crate stays portable.
#[derive(Default)]
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> FilePickerFuture>>,
@@ -247,6 +248,7 @@ pub struct SolveApp {
pub services: Services,
file_picker: Option<file_picker::Pending>,
pub viewport: viewport::ViewportState,
pub render_workspace: render_workspace::State,
pub tool: Option<tools::Tool>,
pub dialog: Option<dialogs::Dialog>,
/// The Change Parameters window, when open.
@@ -331,7 +333,16 @@ pub struct SolveApp {
}
impl SolveApp {
pub fn new(session: Session, services: Services) -> Self {
pub fn new(mut session: Session, services: Services) -> Self {
#[cfg(test)]
let image_host = Some(solidcraft_engine::render::job::CpuHost::cooperative());
#[cfg(not(test))]
let image_host = solidcraft_engine::render::job::CpuHost::for_platform().map_err(|e| log::error!("render image host: {e}")).ok();
if let Err(e) = session.set_render_job_host(image_host.clone()) {
log::error!("render image host: {e}");
}
let mut render_workspace = render_workspace::State::default();
render_workspace.image_host = image_host;
SolveApp {
session,
ui: UiState::default(),
@@ -343,6 +354,7 @@ impl SolveApp {
services,
file_picker: None,
viewport: viewport::ViewportState::default(),
render_workspace,
tool: None,
dialog: None,
params: None,
@@ -408,6 +420,19 @@ impl SolveApp {
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples, !self.cpu_meshing));
render_workspace::invalidate_scene(self);
self.render_workspace.host = match render_workspace::host_executor(
rs.device.limits().max_texture_dimension_2d,
self.viewport.gpu.is_some_and(|g| g.depth.is_some()),
samples.max(1),
) {
Ok(executor) => Some(executor),
Err(error) => {
self.set_status(error, true);
None
}
};
render_workspace::sync_backend(self);
self.msaa_supported = rs.msaa_supported.clone();
let info = rs.adapter.get_info();
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
@@ -541,6 +566,7 @@ impl SolveApp {
let label = language.label(command_label(id));
self.session.echo(language.format("ui.command_done", &[("command", label)]));
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
render_workspace::reset_document(self);
self.sketch_choices = sketch_families::Choices::default();
dim_view::cancel();
self.menu.props = None;
@@ -882,6 +908,15 @@ impl SolveApp {
}
pub fn fit_view(&mut self) {
if render_workspace::active(self) {
self.cancel_view_animation();
match solidcraft_engine::product_scene::extract(&self.session) {
Ok(scene) if !scene.bounds.is_empty() => self.cam.fit(&scene.bounds),
Err(e) => self.set_status(e.to_string(), true),
_ => {}
}
return;
}
self.cam_anim = None;
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
@@ -960,6 +995,13 @@ impl SolveApp {
/// Animated view changes for the axis view and navigation bar.
pub fn animate_view(&mut self, v: &str) {
if render_workspace::active(self) && matches!(v, "fit" | "home") {
if v == "home" {
self.cam.set_view(StandardView::Iso);
}
self.fit_view();
return;
}
self.pending_fit = None;
let mut to = self.cam;
match v {
@@ -993,7 +1035,11 @@ impl SolveApp {
/// change, or at once (control requests, so a screenshot right after shows it).
pub fn follow_view_request(&mut self, animate: bool) {
let Some(mut to) = self.session.camera.take() else { return };
to.up = self.cam.up;
if render_workspace::active(self) {
self.ui.up_axis = to.up;
} else {
to.up = self.cam.up;
}
if animate {
self.animate_to(to);
} else if to.is_valid() {
@@ -1033,6 +1079,8 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
file_picker::tick(self, ctx);
render_workspace::sync_backend(self);
render_workspace::tick(self, ctx);
file_load::tick(self, ctx);
self.esc_handled = false;
prefs::apply(self, ctx);
@@ -1143,8 +1191,9 @@ impl SolveApp {
}
}
toolbar::toolbar(self, ui);
let left_timeline = self.ui.show_timeline && self.preferences.timeline_left;
if self.ui.show_timeline && !left_timeline {
let show_timeline = self.ui.show_timeline && !render_workspace::active(self);
let left_timeline = show_timeline && self.preferences.timeline_left;
if show_timeline && !left_timeline {
timeline::timeline(self, ui);
}
palette::palette_bar(self, ui);
@@ -1157,9 +1206,16 @@ impl SolveApp {
} else if left_timeline {
timeline_left::left_panel(self, ui);
}
if render_workspace::active(self) {
render_workspace::panel(self, ui);
}
preview::update(self, ui.ctx());
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
viewport::show(self, ui);
if render_workspace::active(self) {
render_workspace::show(self, ui);
} else {
viewport::show(self, ui);
}
});
dialogs::show(self, ui.ctx());
issue_report::CadInput::block(self, ui, report_input);
@@ -1303,3 +1359,6 @@ pub fn now_ms() -> f64 {
use web_time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(test)]
mod picker_test_support;
+3 -2
View File
@@ -660,11 +660,12 @@ fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
}
#[test]
fn the_workspace_menu_lists_design_and_greys_the_rest() {
fn the_workspace_menu_lists_design_and_render_and_greys_future_workspaces() {
let app = sample_app();
let items = context_menu::items(&app, &Target::Workspace);
assert!(find(&items, "Design").enabled);
assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later");
assert!(find(&items, "Render").enabled);
assert!(!find(&items, "Animation").enabled);
assert!(!items.iter().any(|i| i.label.contains("Generative")));
}
+78
View File
@@ -0,0 +1,78 @@
//! Original GPU translation of the L1 opaque surface and fixed studio reference.
//! Appended to the existing viewport shader so camera/output conventions are shared.
pub const VERTEX_SIZE: usize = 48;
pub const SOURCE: &str = r#"
struct PbrOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) base: vec3<f32>,
@location(2) wp: vec3<f32>,
@location(3) parameters: vec3<f32>,
};
fn pbr_unit(v: vec3<f32>) -> vec3<f32> {
let scale = max(max(abs(v.x), abs(v.y)), abs(v.z));
if (scale == 0.0) { return vec3<f32>(0.0, 0.0, 1.0); }
let s = v / scale;
return s / length(s);
}
fn pbr_encode(c: vec3<f32>) -> vec3<f32> {
return select(c * 12.92, 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), c > vec3<f32>(0.0031308));
}
@vertex
fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) base: vec3<f32>, @location(3) parameters: vec3<f32>) -> PbrOut {
var o: PbrOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.base = base;
o.wp = p;
o.parameters = parameters;
return o;
}
fn pbr_fresnel(f0: vec3<f32>, cosine: f32) -> vec3<f32> {
let t = 1.0 - cosine;
return f0 + (vec3<f32>(1.0) - f0) * (t * t * t * t * t);
}
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
let nv = clamp(dot(n, v), -1.0, 1.0);
let nl = clamp(dot(n, l), -1.0, 1.0);
if (nv <= 0.0 || nl <= 0.0) { return vec3<f32>(0.0); }
let h = pbr_unit(v + l);
let nh = clamp(dot(n, h), 0.0, 1.0);
let vh = clamp(dot(v, h), 0.0, 1.0);
let alpha = parameters.y * parameters.y;
let a2 = alpha * alpha;
let denominator = (1.0 - nh * nh) + nh * nh * a2;
let distribution = a2 / (3.141592653589793 * denominator * denominator);
let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv));
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl);
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl)) * diffuse;
}
@fragment
fn fs_pbr(i: PbrOut) -> @location(0) vec4<f32> {
let n = pbr_unit(i.n);
var view = u.back.xyz;
if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; }
let v = pbr_unit(view);
let lights = array<vec4<f32>, 3>(vec4<f32>(0.6, 0.0, 0.8, 3.0), vec4<f32>(-0.8, 0.0, 0.6, 1.0), vec4<f32>(0.0, -0.6, 0.8, 1.5));
var radiance = vec3<f32>(0.0);
for (var k = 0u; k < 3u; k += 1u) {
let light = lights[k];
radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0));
}
let mapped = radiance / (vec3<f32>(1.0) + radiance);
if (u.back.w > 0.5) { return vec4<f32>(mapped, 1.0); }
return vec4<f32>(pbr_encode(mapped), 1.0);
}
"#;
+25
View File
@@ -0,0 +1,25 @@
//! Controlled host futures shared by Render and library picker regressions.
use crate::{SolveApp, file_picker::FilePickerFuture};
use std::{
cell::RefCell,
future::Future,
pin::Pin,
rc::Rc,
task::{Context, Poll},
};
pub type Slot = Rc<RefCell<Option<Option<String>>>>;
struct Manual(Slot);
impl Future for Manual {
type Output = Option<String>;
fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Self::Output> {
self.0.borrow_mut().take().map_or(Poll::Pending, Poll::Ready)
}
}
pub fn save(app: &mut SolveApp, slot: &Slot) {
let slot = slot.clone();
app.services.pick_save = Some(Box::new(move |_, _| Box::pin(Manual(slot.clone())) as FilePickerFuture));
}
pub fn open(app: &mut SolveApp, slot: &Slot) {
let slot = slot.clone();
app.services.pick_open = Some(Box::new(move || Box::pin(Manual(slot.clone())) as FilePickerFuture));
}
+535
View File
@@ -0,0 +1,535 @@
//! Integrated product preview. Shares the installed device/pipelines, not Design's scene policy.
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use egui::{Color32, Sense};
use serde_json::json;
use solidcraft_engine::doc::render_settings::RenderSetup;
use solidcraft_engine::render::{
Availability, Camera, ExecutorDescriptor, ExecutorId, ProductScene, RenderLimits, RenderMode, RenderOperation, RenderProfile, RenderRequest,
ScratchLayout,
};
use crate::gpu::{GpuScene, SceneSlot, ViewportCallback};
use crate::{SolveApp, theme::Tokens};
const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024;
#[cfg(test)]
#[path = "render_workspace_tests.rs"]
mod tests;
pub struct State {
pub host: Option<ExecutorDescriptor>,
pub image_host: Option<solidcraft_engine::render::job::CpuHost>,
pub view: ViewState,
revision: u64,
design_camera: Option<(Camera, bool)>,
slot: SceneSlot,
key: Option<u64>,
scene_signature: Option<(u64, usize, u64)>,
scene: Option<ProductScene>,
}
/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs.
#[derive(Clone, Debug)]
pub struct ViewState {
pub name: String,
draft: RenderSetup,
dirty: bool,
job: Option<solidcraft_engine::render::job::JobId>,
completed: Option<solidcraft_engine::render::job::JobId>,
transparent: bool,
overwrite: bool,
}
impl Default for ViewState {
fn default() -> Self {
Self { name: "Studio".into(), draft: RenderSetup::default(), dirty: false, job: None, completed: None, transparent: false, overwrite: false }
}
}
impl Default for State {
fn default() -> Self {
Self {
host: None,
image_host: None,
view: Default::default(),
revision: u64::MAX,
design_camera: None,
slot: Arc::new(Mutex::new(None)),
key: None,
scene_signature: None,
scene: None,
}
}
}
pub fn active(app: &SolveApp) -> bool {
app.ui.tab == "RENDER"
}
pub fn host_executor(max_dimension: u32, depth: bool, samples: u32) -> Result<ExecutorDescriptor, String> {
if !matches!(samples, 1 | 2 | 4 | 8 | 16) {
return Err("unsupported GPU sample count".into());
}
Ok(ExecutorDescriptor {
id: ExecutorId::new("gpu-viewport").map_err(|e| e.to_string())?,
availability: if depth && max_dimension > 0 {
Availability::Available
} else {
Availability::Unavailable { reason: "product preview requires an installed device with a depth buffer".into() }
},
profiles: vec![RenderProfile {
mode: RenderMode::Preview,
operation: RenderOperation::Viewport,
formats: Vec::new(),
features: vec![solidcraft_engine::render::RenderFeature::Pbr],
}],
limits: RenderLimits { max_dimension: u64::from(max_dimension.clamp(1, 4096)), ..Default::default() },
scratch: ScratchLayout {
sample_scale: 1,
sample_bytes_per_pixel: 8 * u64::from(samples),
output_bytes_per_pixel: 8,
encoding_bytes_per_pixel: 0,
},
})
}
pub fn sync_backend(app: &mut SolveApp) {
if app.session.render_job_host().is_none()
&& let Some(host) = &app.render_workspace.image_host
&& let Err(e) = app.session.set_render_job_host(Some(host.clone()))
{
app.set_status(e.to_string(), true);
}
if app.session.render_viewport() == app.render_workspace.host.as_ref() {
return;
}
if let Err(e) = app.session.set_render_viewport(app.render_workspace.host.clone()) {
app.set_status(e.to_string(), true);
}
}
/// Cooperative work belongs to the frame loop, never to a status/control request. Keep
/// scheduling frames even after leaving Render so a browser job can finish in Design.
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(host) = &app.render_workspace.image_host {
match host.pump(1024) {
Ok(true) => ctx.request_repaint(),
Ok(false) => {}
Err(e) => app.set_status(e.to_string(), true),
}
}
}
pub fn reset_document(app: &mut SolveApp) {
let host = app.render_workspace.host.clone();
let image_host = app.render_workspace.image_host.clone();
app.render_workspace = State { host, image_host, ..Default::default() };
if active(app) {
app.ui.tab = "SOLID".into();
}
sync_backend(app);
}
pub fn invalidate_scene(app: &mut SolveApp) {
app.render_workspace.revision = u64::MAX;
app.render_workspace.scene_signature = None;
app.render_workspace.scene = None;
app.render_workspace.key = None;
}
pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
if render {
if app.session.active_sketch.is_some() || app.tool.is_some() || app.dialog.is_some() || app.file_load.busy() {
return Err("finish the current modelling operation before entering Render".into());
}
app.run("render.scene", json!({}))?;
if !active(app) {
app.render_workspace.design_camera = Some((app.cam, app.ui.perspective));
app.ui.tab = "RENDER".into();
let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?;
if !scene.bounds.is_empty() {
app.cam.fit(&scene.bounds);
}
}
} else {
if let Some((camera, perspective)) = app.render_workspace.design_camera.take() {
app.cam = camera;
app.ui.up_axis = camera.up;
app.ui.perspective = perspective;
}
app.ui.tab = "SOLID".into();
}
app.cancel_view_animation();
Ok(())
}
pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty {
app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default();
app.render_workspace.revision = app.session.revision;
}
egui::Panel::right("sc_render_settings")
.exact_size(285.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(Tokens::get().panel).inner_margin(10))
.show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(app.tr("cmd.render_settings_get"));
ui.label(app.tr("render.preview"));
ui.small(app.tr("render.opaque_preview_help"));
ui.separator();
let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect();
let old_name = app.render_workspace.view.name.clone();
egui::ComboBox::from_id_salt("render_setups").selected_text(&old_name).show_ui(ui, |ui| {
for name in names {
ui.selectable_value(&mut app.render_workspace.view.name, name.clone(), name);
}
});
ui.label(app.tr("render.setup_name"));
ui.add(egui::TextEdit::singleline(&mut app.render_workspace.view.name).char_limit(128));
if app.render_workspace.view.name != old_name {
app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default();
app.render_workspace.view.dirty = false;
}
ui.separator();
ui.label(app.tr("render.background"));
if ui.color_edit_button_srgb(&mut app.render_workspace.view.draft.background).changed() {
app.render_workspace.view.dirty = true;
}
let mut perspective = app.cam.fov > 0.0;
if ui.checkbox(&mut perspective, app.tr("render.perspective")).changed() {
app.cam.fov = if perspective { std::f64::consts::FRAC_PI_4 } else { 0.0 };
app.render_workspace.view.dirty = true;
}
if ui.button(app.tr("render.fit")).clicked() {
match solidcraft_engine::product_scene::extract(&app.session) {
Ok(scene) if !scene.bounds.is_empty() => app.cam.fit(&scene.bounds),
Err(e) => app.set_status(e.to_string(), true),
_ => {}
}
}
if ui.button(app.tr("render.restore_camera")).clicked() {
let name = app.render_workspace.view.name.clone();
if app.run("render.settings.apply", json!({"name":name})).is_ok() {
app.follow_view_request(false);
}
}
if ui.button(app.tr("render.save_setup")).clicked() {
let name = app.render_workspace.view.name.clone();
let setup = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if app.run("render.settings.set", json!({"name":name,"set":setup})).is_ok() {
app.render_workspace.view.dirty = false;
}
}
if app.render_workspace.view.dirty {
ui.label(app.tr("render.unsaved"));
}
if ui.button(app.tr("render.reload_settings")).clicked() {
app.render_workspace.view.dirty = false;
app.render_workspace.revision = u64::MAX;
}
if app.render_workspace.view.draft.backend.as_str() != "builtin" || app.render_workspace.view.draft.mode != RenderMode::Preview {
ui.label(app.tr("render.unavailable_preview"));
if ui.button(app.tr("render.use_preview")).clicked() {
app.render_workspace.view.draft.backend = solidcraft_engine::render::builtin_backend().id;
app.render_workspace.view.draft.mode = RenderMode::Preview;
app.render_workspace.view.dirty = true;
}
}
ui.separator();
image_controls(app, ui);
ui.separator();
ui.small(app.tr("render.navigation_help"));
if ui.button(app.tr("render.return_design")).clicked()
&& let Err(e) = enter(app, false)
{
app.set_status(e, true);
}
});
});
}
fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.heading(app.tr("render.image_title"));
ui.small(app.tr("render.opaque_image_summary"));
ui.horizontal(|ui| {
ui.label(app.tr("render.image_width"));
if ui.add(egui::DragValue::new(&mut app.render_workspace.view.draft.width).range(1..=4096)).changed() {
app.render_workspace.view.dirty = true;
}
ui.label(app.tr("render.image_height"));
if ui.add(egui::DragValue::new(&mut app.render_workspace.view.draft.height).range(1..=4096)).changed() {
app.render_workspace.view.dirty = true;
}
});
let transparent_label = app.tr("render.image_transparent").to_string();
ui.checkbox(&mut app.render_workspace.view.transparent, transparent_label);
if ui.button(app.tr("render.image_check")).clicked() {
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})).is_ok() {
app.set_status(app.tr("render.image_supported").to_string(), false);
}
}
let mut busy = false;
if let Some(job) = app.render_workspace.view.job {
match app.session.execute("render.status", &json!({"job":job})) {
Ok(status) => {
let phase = status["progress"]["phase"].as_str().unwrap_or("unknown");
busy = matches!(phase, "queued" | "preparing" | "running" | "encoding" | "cancelling");
ui.label(match phase {
"queued" => app.tr("render.image_queued"),
"preparing" => app.tr("render.image_preparing"),
"running" => app.tr("render.image_running"),
"encoding" => app.tr("render.image_encoding"),
"cancelling" => app.tr("render.image_cancelling"),
"cancelled" => app.tr("render.image_cancelled"),
"completed" => app.tr("render.image_completed"),
"failed" => app.tr("render.image_failed"),
_ => app.tr("render.image_unknown"),
});
if phase == "completed" {
app.render_workspace.view.completed = Some(job);
}
if let Some(message) = status["error"]["message"].as_str() {
ui.label(message);
if status["error"]["code"] == "limit_exceeded" {
ui.small(app.tr("render.image_reduce"));
}
}
if busy {
let done = status["progress"]["done"].as_u64().unwrap_or(0);
let total = status["progress"]["total"].as_u64().unwrap_or(1).max(1);
ui.add(egui::ProgressBar::new((done as f64 / total as f64).clamp(0.0, 1.0) as f32));
ui.ctx().request_repaint();
if ui.button(app.tr("render.image_cancel")).clicked() {
let _ = app.run("render.cancel", json!({"job":job}));
}
}
if let Some(revision) = status["scene_generation"].as_u64()
&& revision != app.session.revision
{
ui.small(app.tr("render.image_stale"));
}
}
Err(e) => {
ui.label(e.to_string());
}
}
}
if ui.add_enabled(!busy, egui::Button::new(app.tr("render.image_start"))).clicked() {
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})) {
app.render_workspace.view.job = serde_json::from_value(reply["job"].clone()).ok();
ui.ctx().request_repaint();
}
}
let overwrite_label = app.tr("render.image_overwrite").to_string();
ui.checkbox(&mut app.render_workspace.view.overwrite, overwrite_label);
if ui.add_enabled(app.render_workspace.view.completed.is_some(), egui::Button::new(app.tr("render.image_export"))).clicked()
&& let Some(job) = app.render_workspace.view.completed
{
export_image(app, job, app.render_workspace.view.overwrite);
}
}
/// Keep the selected image and overwrite consent fixed while the upstream picker holds model input.
fn export_image(app: &mut SolveApp, job: solidcraft_engine::render::job::JobId, overwrite: bool) {
let origin = app.session.doc.clone();
app.pick_save("render.png", &["png"], move |app, path| {
let Some(path) = path else { return };
if !Arc::ptr_eq(&origin, &app.session.doc) {
app.set_status("The render export document changed while choosing its destination", true);
return;
}
let _ = app.run("render.export", json!({"job":job,"path":path,"overwrite":overwrite}));
});
}
/// Convert only product surfaces; geometry budgets are admitted before vertex allocation.
pub fn gpu_scene(scene: &ProductScene) -> Result<(u64, GpuScene), String> {
let bytes = scene
.meshes
.iter()
.try_fold(0usize, |n, m| m.mesh.triangles.len().checked_mul(3 * crate::gpu::PBR_SIZE).and_then(|x| n.checked_add(x)))
.ok_or("product geometry size overflow")?;
if bytes > MAX_GEOMETRY_BYTES {
return Err("product preview exceeds the 128 MiB geometry upload budget".into());
}
let mut hash = std::collections::hash_map::DefaultHasher::new();
let mut gpu = GpuScene::default();
for body in &scene.meshes {
if body.faces.len() > 262_144 {
return Err("product preview exceeds the face-appearance admission cap".into());
}
if !body.opacity.is_finite()
|| !(0.0..=1.0).contains(&body.opacity)
|| body.faces.iter().any(|f| !f.opacity.is_finite() || !(0.0..=1.0).contains(&f.opacity))
{
return Err("invalid product appearance opacity".into());
}
(Arc::as_ptr(&body.mesh) as usize).hash(&mut hash);
body.name.hash(&mut hash);
body.color.hash(&mut hash);
body.opacity.to_bits().hash(&mut hash);
hash_surface(body.surface, &mut hash);
let looks: std::collections::BTreeMap<_, _> = body.faces.iter().map(|l| (l.face, l)).collect();
for face in &body.faces {
face.face.hash(&mut hash);
face.color.hash(&mut hash);
face.opacity.to_bits().hash(&mut hash);
hash_surface(face.surface, &mut hash);
}
for (index, triangle) in body.mesh.triangles.iter().enumerate() {
let face = body.mesh.tri_face.get(index).and_then(|f| looks.get(f));
let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface), |l| (l.color, l.opacity, l.surface));
if surface.is_some() && opacity != 1.0 {
return Err("opaque PBR requires opacity 1".into());
}
if opacity <= 0.0 {
continue;
}
let vertices = triangle.map(|i| {
let p = body.mesh.positions.get(i as usize)?.to_f32();
let n = *body.mesh.normals.get(i as usize)?;
let n = if surface.is_some() { solidcraft_engine::render::surface::unit_direction(n).ok()? } else { n }.to_f32();
p.into_iter().chain(n).all(f32::is_finite).then_some((p, n))
});
let [Some(a), Some(b), Some(c)] = vertices else { return Err("invalid product triangle".into()) };
let rgba = [color[0], color[1], color[2], (opacity * 255.0).round() as u8];
let (buffer, stride) = if surface.is_some() {
(&mut gpu.pbr, crate::gpu::PBR_SIZE)
} else if opacity < 0.999 {
(&mut gpu.glass, crate::gpu::TRI_SIZE)
} else {
(&mut gpu.tris, crate::gpu::TRI_SIZE)
};
buffer.try_reserve(3 * stride).map_err(|_| "product preview vertex allocation failed".to_string())?;
for (p, n) in [a, b, c] {
if let Some(surface) = surface {
if n.iter().all(|v| *v == 0.0) {
return Err("PBR requires nonzero surface normals".into());
}
gpu.pbr_tri(p, n, surface);
} else if opacity < 0.999 {
gpu.glass_tri(p, n, rgba);
} else {
gpu.tri(p, n, rgba);
}
}
}
}
Ok((hash.finish(), gpu))
}
fn hash_surface(surface: Option<solidcraft_engine::render::surface::OpaqueSurface>, hash: &mut impl Hasher) {
surface.is_some().hash(hash);
if let Some(s) = surface {
let c = s.base_color();
for value in [c.x, c.y, c.z, s.metallic(), s.roughness(), s.dielectric_ior()] {
value.to_bits().hash(hash);
}
}
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
let delta = ui.input(|i| i.pointer.delta());
if response.dragged_by(egui::PointerButton::Primary) {
app.cam.orbit(f64::from(delta.x), f64::from(delta.y));
}
if response.dragged_by(egui::PointerButton::Middle) {
app.cam.pan(f64::from(delta.x), f64::from(delta.y), f64::from(rect.height()));
}
if response.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
app.cam.zoom_at((-f64::from(scroll) * 0.0015 * crate::prefs::zoom_sign(app)).exp(), None);
}
}
let bg = app.render_workspace.view.draft.background;
ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2]));
let result = (|| -> Result<(), String> {
if app.viewport.gpu.is_none() {
return Err("GPU product preview is unavailable on this host. Saved setups remain available.".into());
}
let ppp = ui.ctx().pixels_per_point();
let setup = &app.render_workspace.view.draft;
if setup.exposure_ev != 0.0 || setup.environment_rotation != 0.0 || setup.environment_intensity != 1.0 {
return Err("fixed studio preview requires exposure 0, environment rotation 0 and intensity 1".into());
}
let mut request = RenderRequest {
backend: Some(setup.backend.clone()),
mode: setup.mode,
operation: RenderOperation::Viewport,
width: (rect.width() * ppp).max(1.0) as u64,
height: (rect.height() * ppp).max(1.0) as u64,
format: None,
features: Vec::new(),
policy: Default::default(),
};
solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?;
if app.session.model.is_stale() {
return Err("finish the modelling gesture before previewing".into());
}
let mut visibility = std::collections::hash_map::DefaultHasher::new();
app.session.visibility.hidden_bodies.hash(&mut visibility);
app.session.visibility.hidden_occurrences.hash(&mut visibility);
let signature = (app.session.revision, Arc::as_ptr(&app.session.world_state()) as usize, visibility.finish());
if app.render_workspace.scene_signature != Some(signature) {
let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?;
let (key, gpu) = gpu_scene(&scene)?;
if app.render_workspace.key != Some(key) {
if let Ok(mut slot) = app.render_workspace.slot.lock() {
*slot = Some(gpu);
}
app.render_workspace.key = Some(key);
}
app.render_workspace.scene = Some(scene);
app.render_workspace.scene_signature = Some(signature);
}
let scene = app.render_workspace.scene.as_ref().ok_or("no product scene")?;
request.features = scene.required_features();
solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?;
let key = app.render_workspace.key.ok_or("no product geometry")?;
if scene.bounds.is_empty() {
return Err("Create a visible solid in Design to preview it here.".into());
}
let camera = app.cam;
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
let eye = camera.eye();
let slot = app.render_workspace.slot.clone();
let callback = ViewportCallback {
product: true,
key,
slot: slot.clone(),
sketch_key: 0,
sketch_slot: slot.clone(),
hl_key: 0,
hl_slot: slot.clone(),
pv_key: 0,
pv_slot: slot,
view_proj: camera.view_proj(f64::from(rect.width() / rect.height().max(1.0)), radius).to_f32(),
back: camera.back().to_f32(),
size_px: [rect.width() * ppp, rect.height() * ppp],
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
outline: false,
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }],
};
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
Ok(())
})();
if let Err(error) = result {
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, error, egui::FontId::proportional(14.0), Tokens::get().text);
}
}
@@ -0,0 +1,472 @@
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))));
}
}
+7 -2
View File
@@ -57,6 +57,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
"MESH" => &["CREATE", "PREPARE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SHEET METAL" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"PLASTIC" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"RENDER" => &["SCENE"],
_ => &["MAKE", "ADD-INS", "UTILITY", "INSPECT", "SELECT"],
}
}
@@ -505,7 +506,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
app.tr_label(crate::toolbar_custom::WORKSPACE),
app.tr_label(if crate::render_workspace::active(app) { "RENDER" } else { crate::toolbar_custom::WORKSPACE }),
FontId::proportional(12.0),
t.text,
);
@@ -521,7 +522,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
if sketching {
tabs.push("SKETCH");
}
tabs.extend(TABS.iter());
if crate::render_workspace::active(app) {
tabs.push("RENDER");
} else {
tabs.extend(TABS.iter());
}
for tab in tabs {
let display_tab = app.tr_label(tab);
let text_width = painter.layout_no_wrap(display_tab.to_string(), FontId::proportional(12.0), t.text).size().x;
+1
View File
@@ -2258,6 +2258,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
let ppp = ui.ctx().pixels_per_point();
let cb = ViewportCallback {
product: false,
key: app.viewport.key,
slot: app.viewport.slot.clone(),
sketch_key: app.viewport.sketch_key,