diff --git a/Cargo.lock b/Cargo.lock index 3e5d2def..f537310a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3488,6 +3488,7 @@ dependencies = [ "sha2", "solidcraft-geom", "solidcraft-kernel", + "solidcraft-render", "solidcraft-sketch", "thiserror 2.0.21", "web-time", diff --git a/ROADMAP.md b/ROADMAP.md index 0b4d738c..c5186ba2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/assets/locales/de.json b/assets/locales/de.json index 2407ac69..a683ae0d 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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" } diff --git a/assets/locales/en.json b/assets/locales/en.json index f948a700..2f2e6471 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 0c491348..4d19b5c4 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "レンダリング設定を保存" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 8580a14d..f8d148ef 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "렌더링 설정 저장" } diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 35bf5de8..d5c55210 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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": "Сохранить настройки рендеринга" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 42727d85..5cb7ddb6 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "保存渲染设置" } diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 30240420..4c6253da 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -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 } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 10ceb954..861b357a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, #[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`.) diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 3ef957af..ffb8377b 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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 { /// 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 diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7387263a..0e1ffcb7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/render_settings.rs b/crates/doc/src/render_settings.rs new file mode 100644 index 00000000..bff01ff8 --- /dev/null +++ b/crates/doc/src/render_settings.rs @@ -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, +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct UncheckedSettings { + setups: BTreeMap, +} + +impl TryFrom for RenderSettings { + type Error = DocError; + fn try_from(value: UncheckedSettings) -> Result { + 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::(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::(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::(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()); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cf7578c8..72d94c9e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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) diff --git a/crates/engine/src/cmd/render.rs b/crates/engine/src/cmd/render.rs index d7005c2c..8a51111c 100644 --- a/crates/engine/src/cmd/render.rs +++ b/crates/engine/src/cmd/render.rs @@ -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, +} + +fn get_settings(s: &mut Session, params: &Value) -> Result { + 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 { + 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 { 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; diff --git a/crates/engine/src/cmd/render/settings_tests.rs b/crates/engine/src/cmd/render/settings_tests.rs new file mode 100644 index 00000000..ba0babac --- /dev/null +++ b/crates/engine/src/cmd/render/settings_tests.rs @@ -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); +} diff --git a/crates/engine/src/crash_tests.rs b/crates/engine/src/crash_tests.rs index b51de855..fc4d24bd 100644 --- a/crates/engine/src/crash_tests.rs +++ b/crates/engine/src/crash_tests.rs @@ -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"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9ed2571f..04fb0e2b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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) { + 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(()) } diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index e04476ff..9074323a 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -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); } diff --git a/docs/design-container.md b/docs/design-container.md index 28d621b3..f17d886f 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -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. diff --git a/docs/features.md b/docs/features.md index 85ac59b6..0873c383 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 ''`, 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 ''`, 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) diff --git a/docs/render-settings.md b/docs/render-settings.md new file mode 100644 index 00000000..e33fd7f9 --- /dev/null +++ b/docs/render-settings.md @@ -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.