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);
}
}