Render R2: persist named render setups without re-evaluating geometry (#667)
render.settings.set saves validated named render setups (camera, output size, exposure, environment) as one undo step, and render.settings.get reads them back without applying a camera. When validated render settings are the only change, the engine keeps the evaluated model instead of rebuilding it. Setups are saved under design format 21; format-20 designs read as having none. Fixes #658; refs #620. Contributed by @0xlillith. Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
This commit is contained in:
1 parent
771a11feab
commit
289b521a65
22 files changed
+641
-16
No files matched your search
@@ -0,0 +1,257 @@
|
||||
//! Saved render intent, independent of runtime backend availability and GPU resources.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{BackendId, Camera, RenderFormat, RenderLimits, RenderMode, UpAxis};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
pub const MAX_RENDER_SETUPS: usize = 32;
|
||||
pub const MAX_SETUP_NAME_BYTES: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RenderSetup {
|
||||
pub backend: BackendId,
|
||||
pub mode: RenderMode,
|
||||
#[serde(deserialize_with = "SavedCamera::deserialize")]
|
||||
pub camera: Camera,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: RenderFormat,
|
||||
pub exposure_ev: f64,
|
||||
/// Procedural environment orientation, in radians. No HDRI is loaded in R2.
|
||||
pub environment_rotation: f64,
|
||||
pub environment_intensity: f64,
|
||||
/// sRGB background. Alpha/output colour management belong to rendering phases.
|
||||
pub background: [u8; 3],
|
||||
}
|
||||
|
||||
// Reuse Camera's representation and defaults, but reject misspelled saved fields. A remote
|
||||
// derive constructs Camera itself, so adding a Camera field requires updating this adapter.
|
||||
// Navigation/named-view deserialization keeps its existing compatibility policy.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(remote = "Camera", deny_unknown_fields)]
|
||||
struct SavedCamera {
|
||||
target: Vec3,
|
||||
yaw: f64,
|
||||
pitch: f64,
|
||||
#[serde(default)]
|
||||
roll: f64,
|
||||
distance: f64,
|
||||
fov: f64,
|
||||
#[serde(default)]
|
||||
up: UpAxis,
|
||||
}
|
||||
|
||||
impl Default for RenderSetup {
|
||||
fn default() -> Self {
|
||||
// The built-in descriptor owns the canonical default backend identifier.
|
||||
Self {
|
||||
backend: solidcraft_render::builtin_backend().id,
|
||||
mode: RenderMode::Preview,
|
||||
camera: Camera::default(),
|
||||
width: 1024,
|
||||
height: 768,
|
||||
format: RenderFormat::Png,
|
||||
exposure_ev: 0.0,
|
||||
environment_rotation: 0.0,
|
||||
environment_intensity: 1.0,
|
||||
background: [236, 240, 245],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSetup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let limits = RenderLimits::default();
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| u64::from(self.width) > limits.max_dimension
|
||||
|| u64::from(self.height) > limits.max_dimension
|
||||
|| u64::from(self.width) * u64::from(self.height) > limits.max_pixels
|
||||
{
|
||||
return Err(DocError::Invalid("render output exceeds the dimension/pixel limits".into()));
|
||||
}
|
||||
let camera = &self.camera;
|
||||
if !camera.is_valid()
|
||||
|| [camera.target.x, camera.target.y, camera.target.z, camera.distance].into_iter().any(|x| x.abs() > 1e9)
|
||||
|| [camera.yaw, camera.pitch, camera.roll].into_iter().any(|x| x.abs() > 1e6)
|
||||
|| !camera.fov.is_finite()
|
||||
|| !(0.0..std::f64::consts::PI).contains(&camera.fov)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid render camera (finite mm/radians, positive distance, 0 <= fov < pi required)".into()));
|
||||
}
|
||||
if !self.exposure_ev.is_finite() || !(-32.0..=32.0).contains(&self.exposure_ev) {
|
||||
return Err(DocError::Invalid("render exposure must be finite and between -32 and 32 EV".into()));
|
||||
}
|
||||
if !self.environment_rotation.is_finite() || self.environment_rotation.abs() > 1e6 {
|
||||
return Err(DocError::Invalid("render environment rotation must be finite, bounded radians".into()));
|
||||
}
|
||||
if !self.environment_intensity.is_finite() || !(0.0..=100.0).contains(&self.environment_intensity) {
|
||||
return Err(DocError::Invalid("render environment intensity must be finite and between 0 and 100".into()));
|
||||
}
|
||||
// Desired mode/format/backend may be unavailable on this host. Saving intent is not preflight.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "UncheckedSettings")]
|
||||
pub struct RenderSettings {
|
||||
pub setups: BTreeMap<String, RenderSetup>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct UncheckedSettings {
|
||||
setups: BTreeMap<String, RenderSetup>,
|
||||
}
|
||||
|
||||
impl TryFrom<UncheckedSettings> for RenderSettings {
|
||||
type Error = DocError;
|
||||
fn try_from(value: UncheckedSettings) -> Result<Self> {
|
||||
let settings = Self { setups: value.setups };
|
||||
settings.validate()?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSettings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.setups.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
if name.trim().is_empty() || name != name.trim() || name.len() > MAX_SETUP_NAME_BYTES || name.chars().any(char::is_control) {
|
||||
return Err(DocError::Invalid("render setup name must be trimmed, nonempty, at most 128 bytes and contain no control characters".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.setups.len() > MAX_RENDER_SETUPS {
|
||||
return Err(DocError::Invalid("too many render setups (maximum 32)".into()));
|
||||
}
|
||||
for (name, setup) in &self.setups {
|
||||
Self::validate_name(name)?;
|
||||
setup.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Document;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn camera_fields_are_strict_without_changing_existing_camera_defaults() {
|
||||
let mut camera = serde_json::to_value(Camera::default()).unwrap();
|
||||
camera["fov_degrees"] = serde_json::json!(45);
|
||||
let saved = serde_json::json!({"setups":{"Studio":{"camera":camera}}});
|
||||
let mut doc = serde_json::to_value(Document::default()).unwrap();
|
||||
doc["render"] = saved.clone();
|
||||
assert!(Document::from_json(&doc.to_string()).is_err());
|
||||
assert!(serde_json::from_value::<RenderSettings>(saved).is_err());
|
||||
camera.as_object_mut().unwrap().remove("fov_degrees");
|
||||
camera.as_object_mut().unwrap().remove("roll");
|
||||
camera.as_object_mut().unwrap().remove("up");
|
||||
let setup: RenderSetup = serde_json::from_value(serde_json::json!({"camera":camera})).unwrap();
|
||||
assert_eq!(setup.camera, Camera::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_named_setups_round_trip_without_changing_other_data() {
|
||||
let old = Document::new("Legacy");
|
||||
let mut json = serde_json::to_value(&old).unwrap();
|
||||
json["format"] = serde_json::json!("solidcraft/16");
|
||||
json.as_object_mut().unwrap().remove("render");
|
||||
let mut loaded = Document::from_json(&json.to_string()).unwrap();
|
||||
assert!(loaded.render.is_empty());
|
||||
assert_eq!(loaded.format, crate::format::format_string(crate::format::FORMAT));
|
||||
// Upstream owns 19 for editable offsets; saved render intent needs 20.
|
||||
for version in [17, 18, 19] {
|
||||
json["format"] = serde_json::json!(crate::format::format_string(version));
|
||||
assert_eq!(Document::from_json(&json.to_string()).unwrap(), loaded);
|
||||
}
|
||||
let before = loaded.clone();
|
||||
let setup = RenderSetup {
|
||||
backend: serde_json::from_value(serde_json::json!("future-backend")).unwrap(),
|
||||
exposure_ev: 2.5,
|
||||
mode: RenderMode::Final,
|
||||
format: RenderFormat::Exr,
|
||||
camera: Camera { up: solidcraft_render::UpAxis::Y, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut loaded.render).setups.insert("Studio".into(), setup);
|
||||
assert!(loaded.differs_only_in_render(&before));
|
||||
assert_eq!(Document::from_json(&loaded.to_json()).unwrap(), loaded);
|
||||
let mut previous_draft = serde_json::to_value(&loaded).unwrap();
|
||||
previous_draft["format"] = serde_json::json!("solidcraft/17");
|
||||
assert_eq!(Document::from_json(&previous_draft.to_string()).unwrap(), loaded);
|
||||
loaded.units = "in".into();
|
||||
assert!(!loaded.differs_only_in_render(&before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_document_changes_never_qualify_as_render_only() {
|
||||
let before = Document::default();
|
||||
let mut render_only = before.clone();
|
||||
Arc::make_mut(&mut render_only.render).setups.insert("Studio".into(), RenderSetup::default());
|
||||
assert!(render_only.differs_only_in_render(&before));
|
||||
for (key, value) in [
|
||||
("marker", serde_json::json!(0)),
|
||||
("materials", serde_json::json!({"Body1":"Steel"})),
|
||||
("visibility", serde_json::json!({"hidden_bodies":["Body1"]})),
|
||||
("body_components", serde_json::json!({"Body1":1})),
|
||||
("next_id", serde_json::json!(99)),
|
||||
] {
|
||||
let mut json = serde_json::to_value(&render_only).unwrap();
|
||||
json[key] = value;
|
||||
let mixed: Document = serde_json::from_value(json).unwrap();
|
||||
assert!(!mixed.differs_only_in_render(&before), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_intent_is_rejected_at_deserialization() {
|
||||
for patch in [
|
||||
serde_json::json!({"width":0}),
|
||||
serde_json::json!({"width":4097}),
|
||||
serde_json::json!({"width":4096,"height":4096}),
|
||||
serde_json::json!({"exposure_ev":33}),
|
||||
serde_json::json!({"environment_rotation":1000001}),
|
||||
serde_json::json!({"environment_intensity":-1}),
|
||||
serde_json::json!({"background":[256,0,0]}),
|
||||
serde_json::json!({"unknown":1}),
|
||||
] {
|
||||
let json = serde_json::json!({"setups":{"Studio":patch}});
|
||||
assert!(serde_json::from_value::<RenderSettings>(json).is_err());
|
||||
}
|
||||
for name in ["", " padded", "line\nfeed"] {
|
||||
assert!(RenderSettings::validate_name(name).is_err());
|
||||
}
|
||||
let mut settings = RenderSettings::default();
|
||||
for i in 0..=MAX_RENDER_SETUPS {
|
||||
settings.setups.insert(i.to_string(), RenderSetup::default());
|
||||
}
|
||||
assert!(serde_json::from_value::<RenderSettings>(serde_json::to_value(settings).unwrap()).is_err());
|
||||
let mut setup = RenderSetup::default();
|
||||
setup.camera.fov = f64::NAN;
|
||||
assert!(setup.validate().is_err());
|
||||
for field in ["yaw", "pitch", "roll", "distance", "fov"] {
|
||||
let mut value = serde_json::to_value(RenderSetup::default()).unwrap();
|
||||
value["camera"][field] = serde_json::json!(1e12);
|
||||
let setup: RenderSetup = serde_json::from_value(value).unwrap();
|
||||
assert!(setup.validate().is_err(), "{field}");
|
||||
}
|
||||
setup.camera = Camera::default();
|
||||
setup.environment_intensity = f64::INFINITY;
|
||||
assert!(setup.validate().is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user