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

Generated
+1
View File
@@ -3488,6 +3488,7 @@ dependencies = [
"sha2",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-render",
"solidcraft-sketch",
"thiserror 2.0.21",
"web-time",
+5
View File
@@ -89,6 +89,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Render foundation
- R2 adds bounded named render setups in document format 21 and the read/save
`render.settings.get/set` commands. Proven render-only changes preserve geometry
and prepared world data through undo/redo and checkpoint restore. This slice does
not add a Render workspace, PBR, environments or image jobs; see
[settings and refresh policy](docs/render-settings.md).
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
+3 -1
View File
@@ -752,5 +752,7 @@
"update.restart_later": "Später",
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket."
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.",
"cmd.render_settings_get": "Rendereinstellungen",
"cmd.render_settings_set": "Rendereinstellungen speichern"
}
+3 -1
View File
@@ -1011,5 +1011,7 @@
"update.restart_later": "Later",
"update.installer_started": "The installer starts when SolidCraft closes.",
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead."
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.",
"cmd.render_settings_get": "Render Settings",
"cmd.render_settings_set": "Save Render Settings"
}
+3 -1
View File
@@ -1011,5 +1011,7 @@
"update.restart_later": "後で",
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。"
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。",
"cmd.render_settings_get": "レンダリング設定",
"cmd.render_settings_set": "レンダリング設定を保存"
}
+3 -1
View File
@@ -752,5 +752,7 @@
"update.restart_later": "나중에",
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다."
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.",
"cmd.render_settings_get": "렌더링 설정",
"cmd.render_settings_set": "렌더링 설정 저장"
}
+3 -1
View File
@@ -957,5 +957,7 @@
"Line Spacing": "Межстрочный интервал",
"Path": "Траектория",
"sketch_text.on_path": "Текст следует по траектории: кривая задаёт его положение",
"cmd.material_properties_validate": "Проверить данные физического материала"
"cmd.material_properties_validate": "Проверить данные физического материала",
"cmd.render_settings_get": "Настройки рендеринга",
"cmd.render_settings_set": "Сохранить настройки рендеринга"
}
+3 -1
View File
@@ -1011,5 +1011,7 @@
"update.restart_later": "稍后",
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。"
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。",
"cmd.render_settings_get": "渲染设置",
"cmd.render_settings_set": "保存渲染设置"
}
+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);
}
+1 -1
View File
@@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract;
`io::write_design_container` is the fallible container writer.
Packages have `container.json` with `{"format":"solidcraft-container/1"}` and
`document.json` with the versioned parametric document (currently `solidcraft/20`).
`document.json` with the versioned parametric document (currently `solidcraft/21`).
The independent versions let document migrations evolve without changing blob encodings.
Existing serialized document fields are retained automatically, without a second copy
of the document schema or a deep clone of embedded payloads.
+6 -3
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **350 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **353 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (99)
@@ -192,11 +192,12 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Plane Along Path | `construct.plane.along_path` | SOLID › CONSTRUCT |
| Perpendicular Plane | `construct.plane.perpendicular` | SOLID › CONSTRUCT |
## Sheet metal (11)
## Sheet metal (12)
| Command | Id | Toolbar |
|---|---|---|
| Flange | `sheet.flange` | SHEET METAL › CREATE |
| Perforate | `sheet.perforate` | SHEET METAL › CREATE |
| Hem | `sheet.hem` | SHEET METAL › CREATE |
| Fold | `sheet.fold` | SHEET METAL › CREATE |
| Unfold | `sheet.unfold` | SHEET METAL › MODIFY |
@@ -513,11 +514,13 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Insert Part | `parts.insert` | – |
| Change Part Size | `parts.resize` | – |
## `render.*` (1)
## `render.*` (3)
| Command | Id | Toolbar |
|---|---|---|
| Render Backends | `render.backends` | – |
| Render Settings | `render.settings.get` | – |
| Save Render Settings | `render.settings.set` | – |
## `select.*` (2)
+68
View File
@@ -0,0 +1,68 @@
# Persistent render settings (R2)
Named setups save common render intent in the document. They do not start a render,
apply the navigation camera or make a currently unavailable backend available.
The Render workspace, scene extraction, assets, PBR materials and jobs are later slices
of [#620](https://github.com/bherbruck/solidcraft/issues/620).
## Commands
```json
{"command":"render.settings.set","params":{"name":"Studio","set":{"width":1600,"height":900,"exposure_ev":1.5}}}
{"command":"render.settings.get","params":{"name":"Studio"}}
{"command":"render.settings.get","params":{}}
```
`set` creates a named setup from defaults or replaces supplied top-level fields in an
existing setup. A supplied `camera` replaces the whole camera; it is not a recursive
patch. Omitted fields retain their values. Unknown fields (including camera fields),
invalid types and invalid values fail before document mutation. A new empty `set`
saves the defaults; setting an existing setup to identical values is a no-op,
preserving redo history and revision.
Each effective edit is one undo step and marks the document dirty. There is no delete
or rename command in this slice.
Named `get` returns `{name, setup}` or an error for a missing name. Without a name,
it returns `{setups, defaults}`. Reads do not evaluate the model or change history.
| Field | Default | Saved contract |
|---|---|---|
| `backend` | `builtin` | Valid R1 backend ID, at most 64 ASCII bytes; unavailable IDs survive reopen |
| `mode` | `preview` | R1 `preview` / `final` mode; availability checked by a future render request |
| `camera` | Existing `Camera::default()` | Snapshot of target, yaw, pitch, roll, distance, fov and up; mm/radians, existing Z-up default |
| `width`, `height` | 1024, 768 | Positive, each at most 4096; product at most 4,194,304 pixels |
| `format` | `png` | R1 output format (`png` / `exr`); saving EXR does not add an encoder |
| `exposure_ev` | 0 | Finite, -32 through 32 |
| `environment_rotation` | 0 | Finite radians, magnitude at most 1,000,000 |
| `environment_intensity` | 1 | Finite, 0 through 100 |
| `background` | [236, 240, 245] | Three sRGB byte values, independent of future illumination |
Camera target coordinates and positive distance are bounded to magnitude 1e9 mm;
angles to 1e6 radians. FOV is finite, `0 <= fov < pi`; zero means orthographic in
the existing camera model. Both existing camera up axes are retained. Setup names
are trimmed, nonempty, at most 128 UTF-8 bytes, without control characters. A document
has at most 32 setups. Validation also runs during document deserialization.
## Persistence and evaluation
Document format 21 adds `render: {setups: {name: setup}}`. Format-20 and older designs migrate with
an empty map; empty render state is omitted when saved. Existing geometry, appearances,
opacity and physical materials are untouched. Plain JSON, design containers and crash
journal replay use the same document/command representation. Older builds (0.11.0 reads up to 20) reject format
21 instead of silently losing setups. This slice adds only render intent. No runtime capability, job, texture or device is saved.
The engine avoids geometry evaluation only after proving that the document differs
solely in validated render settings, with unchanged active sketch and pending moves.
The proof compares the complete document after normalizing the render field, so mixed
geometry/appearance/placement edits take the usual conservative refresh path. It uses
a document clone/comparison, not a constant-time or whole-application speedup claim.
Unchanged render pointers short-circuit the check for normal modelling commands.
Render-only edits, undo/redo and checkpoint restore preserve model state, its stale
marker, cached tessellation and the prepared world state. A stale live-drag result never
becomes authoritative through a settings edit; the next model-reading command still
settles it normally. Revision advances to let future consumers invalidate shading work.
Actual render accumulation and resource invalidation are not implemented in R2.
No new third-party dependency or asset is introduced. `doc` depends on the existing
pure `render` crate for its shared camera and R1 identifiers, within the L2 → L1 rule.