Render R2: persist named render setups without re-evaluating geometry (#667)
render.settings.set saves validated named render setups (camera, output size, exposure, environment) as one undo step, and render.settings.get reads them back without applying a camera. When validated render settings are the only change, the engine keeps the evaluated model instead of rebuilding it. Setups are saved under design format 21; format-20 designs read as having none. Fixes #658; refs #620. Contributed by @0xlillith. Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
This commit is contained in:
1 parent
771a11feab
commit
289b521a65
22 files changed
+641
-16
No files matched your search
Generated
+1
@@ -3488,6 +3488,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "レンダリング設定を保存"
|
||||
}
|
||||
@@ -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": "렌더링 설정 저장"
|
||||
}
|
||||
@@ -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": "Сохранить настройки рендеринга"
|
||||
}
|
||||
@@ -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": "保存渲染设置"
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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`.)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user