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:
1 parent
771a11feab
commit
289b521a65
22 files changed
+641
-16
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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(¤t).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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user