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 <[email protected]>
This commit is contained in:
selune authored and GitHub committed 2026-10-14 17:11:39 -04:00
1 parent 771a11feab
commit 289b521a65
22 files changed
+641 -16

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ workspace = true
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-render = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+16
View File
@@ -1614,6 +1614,9 @@ pub struct DesignLink {
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Named, renderer-independent setups. Device/job state is never saved here.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
#[serde(default = "default_format")]
pub format: String,
pub name: String,
@@ -1828,6 +1831,7 @@ impl Default for Document {
impl Document {
pub fn new(name: &str) -> Self {
Document {
render: Default::default(),
format: default_format(),
name: name.into(),
units: default_units(),
@@ -1859,6 +1863,18 @@ impl Document {
}
}
/// A complete structural proof: no non-render field is allowed to differ. Keeping this
/// comparison based on the whole Document also includes fields added in future versions.
/// The clone/comparison is only needed when render intent actually differs.
pub fn differs_only_in_render(&self, previous: &Document) -> bool {
if std::sync::Arc::ptr_eq(&self.render, &previous.render) || self.render == previous.render || self.render.validate().is_err() {
return false;
}
let mut normalized = previous.clone();
normalized.render = self.render.clone();
*self == normalized
}
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
+8 -2
View File
@@ -153,13 +153,18 @@
//! The saved visibility can also list hidden assembly joints (`visibility.hidden_joints`,
//! joint ids): their glyphs aren't drawn, but they still constrain and drive the parts. A
//! design without it shows every joint, as it always did; 0.10.0 would show them all.
//! - 21: named render setups (`render: {setups: {name: setup}}`): renderer-independent render
//! intent (camera, resolution, exposure, environment). Version 20 designs need no
//! transformation (an absent field means no setups, and empty render state is not saved); the
//! version stamp makes 0.11.0 (which reads up to 20) ask for an update instead of silently
//! dropping saved setups.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 20;
pub const FORMAT: u32 = 21;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -176,7 +181,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 19] = [
const JSON_STEPS: [JsonStep; 20] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -196,6 +201,7 @@ const JSON_STEPS: [JsonStep; 19] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+1
View File
@@ -40,6 +40,7 @@ pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
pub mod project;
mod project3d;
pub mod render_settings;
pub use project3d::iso_grid;
pub mod sheet;
+257
View File
@@ -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());
}
}
+2
View File
@@ -14,9 +14,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.doc_only()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.doc_only()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
+66 -3
View File
@@ -1,15 +1,75 @@
//! Render capability discovery. No rendering, host probing or document mutation occurs here.
//! Render capability discovery and persistent intent. No rendering or host probing.
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("render.backends", "Render Backends", backends)
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.backends", "Render Backends", backends)
.noundo()
.doc_only()
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md")];
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"),
CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo().doc_only()
.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"),
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct GetSettings {
name: Option<String>,
}
fn get_settings(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.settings.get";
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
match request.name {
None => Ok(json!({"setups": s.doc.render.setups, "defaults": RenderSetup::default()})),
Some(name) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
Ok(json!({"name": name, "setup": setup}))
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SetSettings {
name: String,
set: Value,
}
fn set_settings(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.settings.set";
let request: SetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
RenderSettings::validate_name(&request.name).map_err(|e| bad(cmd, e.to_string()))?;
let patch = request.set.as_object().filter(|p| p.len() <= 10).ok_or_else(|| bad(cmd, "`set` must be an object with at most 10 fields"))?;
let current = s.doc.render.setups.get(&request.name).cloned().unwrap_or_default();
let mut value = serde_json::to_value(&current).map_err(|e| bad(cmd, e.to_string()))?;
let object = value.as_object_mut().ok_or_else(|| bad(cmd, "invalid render setup"))?;
for (key, value) in patch {
object.insert(key.clone(), value.clone());
}
let setup: RenderSetup = serde_json::from_value(value).map_err(|e| bad(cmd, e.to_string()))?;
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
let reply = json!({"name": request.name, "setup": setup});
if s.doc.render.setups.get(&request.name) == Some(&setup) {
return Ok(reply);
}
let mut settings = (*s.doc.render).clone();
settings.setups.insert(request.name, setup);
settings.validate().map_err(|e| bad(cmd, e.to_string()))?;
s.doc_mut().render = Arc::new(settings);
Ok(reply)
}
fn backends(_: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
@@ -89,3 +149,6 @@ mod tests {
assert_eq!(s.evaluations, 0);
}
}
#[cfg(test)]
mod settings_tests;
@@ -0,0 +1,131 @@
use super::*;
fn save(s: &mut Session, patch: Value) {
s.execute("render.settings.set", &json!({"name":"Studio", "set":patch})).unwrap();
}
#[test]
fn settings_history_preserves_geometry_cache_and_dirty_state() {
let mut s = Session::default();
let component = s.execute("component.create", &json!({"name":"Part"})).unwrap();
s.execute("solid.box", &json!({"length":10,"width":20,"height":5})).unwrap();
s.execute("component.activate", &json!({"component":0})).unwrap();
s.execute("occurrence.move", &json!({"occurrence":component["occurrence"],"translate":[30,0,0]})).unwrap();
s.mark_saved();
let model = s.model.state();
let world = s.world_state();
assert!(!Arc::ptr_eq(&model, &world), "exercise the prepared occurrence cache");
let mesh = model.bodies[0].mesh();
let undo = s.undo.len();
s.evaluations = 0;
save(&mut s, json!({"exposure_ev":2,"backend":"future-backend"}));
assert!(s.is_dirty());
assert_eq!(s.undo.len(), undo + 1);
let doc = s.doc.clone();
let revision = s.revision;
save(&mut s, json!({"exposure_ev":2}));
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.revision, revision);
assert_eq!(s.undo.len(), undo + 1);
let restored = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(restored.render, s.doc.render);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.is_dirty());
s.execute("render.settings.get", &json!({})).unwrap();
assert_eq!(s.redo.len(), 1);
s.execute("edit.redo", &json!({})).unwrap();
let checkpoint = s.checkpoint();
save(&mut s, json!({"width":800}));
s.execute("edit.undo", &json!({})).unwrap();
let revision = s.revision;
save(&mut s, json!({"exposure_ev":2}));
assert_eq!(s.redo.len(), 1, "a no-op edit preserves redo");
assert_eq!(s.revision, revision);
s.execute("edit.redo", &json!({})).unwrap();
s.restore_checkpoint(checkpoint).unwrap();
assert_eq!(s.doc.render.setups["Studio"].width, 1024);
assert!(Arc::ptr_eq(&model, &s.model.state()));
assert!(Arc::ptr_eq(&mesh, &s.model.state().bodies[0].mesh()));
assert!(Arc::ptr_eq(&world, &s.world_state()));
assert_eq!(s.evaluations, 0);
// A geometry change and its history still require normal evaluation.
s.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
assert!(s.evaluations > 0);
s.evaluations = 0;
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.evaluations > 0);
s.evaluations = 0;
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.evaluations > 0);
let checkpoint = s.checkpoint();
let expected = s.doc.clone();
s.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap();
s.evaluations = 0;
s.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint})).unwrap();
assert_eq!(*s.doc, *expected);
assert!(s.evaluations > 0);
}
#[test]
fn invalid_settings_and_limit_are_atomic() {
let mut s = Session::default();
let mut camera_with_typo = serde_json::to_value(solidcraft_render::Camera::default()).unwrap();
camera_with_typo["fov_degrees"] = json!(45);
for patch in [
json!({"width":0}),
json!({"width":4097}),
json!({"height":null}),
json!({"unknown":true}),
json!({"camera":{"distance":0}}),
json!({"camera":camera_with_typo}),
json!({"exposure_ev":33}),
json!({"environment_intensity":101}),
] {
let doc = s.doc.clone();
let state = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
assert!(s.execute("render.settings.set", &json!({"name":"Studio","set":patch})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
}
for i in 0..32 {
s.execute("render.settings.set", &json!({"name":i.to_string(),"set":{}})).unwrap();
}
let doc = s.doc.clone();
assert!(s.execute("render.settings.set", &json!({"name":"overflow","set":{}})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert!(s.execute("render.settings.get", &json!({"name":"missing"})).is_err());
assert_eq!(s.evaluations, 0);
}
#[test]
fn settings_history_and_checkpoint_preserve_a_stale_live_drag() {
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());
let model = s.model.state();
s.evaluations = 0;
let checkpoint = s.execute("edit.checkpoint", &json!({})).unwrap()["checkpoint"].clone();
save(&mut s, json!({"exposure_ev":1}));
s.execute("render.settings.get", &json!({"name":"Studio"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
s.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint})).unwrap();
assert!(s.doc.render.is_empty());
assert!(s.model.is_stale());
assert!(Arc::ptr_eq(&model, &s.model.state()));
assert_eq!(s.evaluations, 0);
s.execute("sketch.finish", &json!({})).unwrap();
assert!(!s.model.is_stale());
assert!(s.evaluations > 0);
}
+30
View File
@@ -28,6 +28,14 @@ fn child() {
s.execute("document.inspect", &json!({})).unwrap();
}
match mode.as_str() {
"render" => {
s.execute("render.settings.set", &json!({"name":"Studio","set":{"exposure_ev":2}})).unwrap();
s.execute("render.settings.get", &json!({"name":"Studio"})).unwrap();
s.execute("render.settings.set", &json!({"name":"Studio","set":{"exposure_ev":3}})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
let _ = s.execute("diagnostics.crash_test", &json!({"mode":"abort"}));
}
"command" => {
for _ in 0..3 {
let r = s.execute("diagnostics.crash_test", &json!({"mode": "panic"}));
@@ -157,6 +165,28 @@ fn an_abort_is_reported_at_the_next_start_with_a_replay() {
let _ = fs::remove_dir_all(&d);
}
#[test]
fn render_settings_survive_unclean_exit_and_journal_replay() {
let d = fresh_dir("render");
assert!(!run_child(&d, "render").1);
assert!(run_child(&d, "none").1);
let (_, report) = only_report(&d);
let replay: Value = serde_json::from_slice(&fs::read(report["replay_path"].as_str().unwrap()).unwrap()).unwrap();
let commands = replay["commands"].as_array().unwrap();
assert!(!commands.iter().any(|c| c["command"] == "render.settings.get"));
assert_eq!(commands.iter().filter(|c| c["command"] == "render.settings.set").count(), 2);
let mut recovered = Session::default();
for command in commands {
let id = command["command"].as_str().unwrap();
if id != "diagnostics.crash_test" {
recovered.execute(id, &command["params"]).unwrap();
}
}
assert_eq!(recovered.doc.features.len(), 1);
assert_eq!(recovered.doc.render.setups["Studio"].exposure_ev, 3.0);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_journal_reads_back_its_base_commands_and_the_one_in_flight() {
let d = fresh_dir("journal");
+27 -1
View File
@@ -477,6 +477,23 @@ impl Session {
}
}
/// Only persistent render intent changed. Never evaluate or clear a stale model here.
fn render_only_change(&self, before: &Snapshot) -> bool {
self.active_sketch == before.active_sketch && self.pending_moves == before.pending_moves && self.doc.differs_only_in_render(&before.doc)
}
fn refresh_render(&mut self, previous: &Arc<Document>) {
self.revision += 1;
// Geometry/placements have been proved identical. Retarget the cache's document key
// instead of discarding prepared meshes merely because the document Arc changed.
if let Ok(mut cache) = self.world_cache.lock()
&& let Some(cache) = cache.as_mut()
&& Arc::ptr_eq(&cache.doc, previous)
{
cache.doc = self.doc.clone();
}
}
/// Show the sketch a live frame changed, leaving the features after it as they were last
/// evaluated (marked stale); a full `refresh` when the model cannot show it that way.
fn refresh_live(&mut self, edit: LiveEdit) {
@@ -583,6 +600,8 @@ impl Session {
Ok((v, live_edit)) => {
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
if changed {
let render_only = self.render_only_change(&before);
let previous_doc = before.doc.clone();
if spec.undoable {
self.undo.push(before);
if self.undo.len() > MAX_UNDO {
@@ -592,6 +611,7 @@ impl Session {
}
match live_edit {
Some(edit) => self.refresh_live(edit),
None if render_only => self.refresh_render(&previous_doc),
None => self.refresh(),
}
}
@@ -653,13 +673,19 @@ impl Session {
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
let before =
Snapshot { label: String::new(), doc: self.doc.clone(), active_sketch: self.active_sketch, pending_moves: self.pending_moves.clone() };
self.doc = c.doc;
self.active_sketch = c.active_sketch;
self.pending_moves = c.pending_moves;
self.undo = c.undo;
self.redo = c.redo;
self.selection.clear();
self.refresh();
if self.render_only_change(&before) {
self.refresh_render(&before.doc);
} else if !Arc::ptr_eq(&self.doc, &before.doc) || self.active_sketch != before.active_sketch || self.pending_moves != before.pending_moves {
self.refresh();
}
Ok(())
}
+3
View File
@@ -1128,7 +1128,10 @@ mod tests {
doc.units = "in".into();
doc.named_views.push(solidcraft_doc::NamedView { name: "View".into(), ..Default::default() });
doc.param_comments.insert("test".into(), "metadata".into());
let setup = solidcraft_doc::render_settings::RenderSetup { exposure_ev: 1.5, ..Default::default() };
std::sync::Arc::make_mut(&mut doc.render).setups.insert("Studio".into(), setup);
let expected = crate::read_design(&crate::write_design(&doc)).unwrap();
assert_eq!(expected.render, doc.render);
assert_eq!(crate::read_design(&write_design_container(&doc).unwrap()).unwrap(), expected);
}