diff --git a/ROADMAP.md b/ROADMAP.md index 76521aa6..318df773 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,6 +59,15 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | | M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +## Render foundation + +- R1 adds renderer-independent backend/profile metadata, strict request resolution and + bounded image/scratch admission checks, exposed by the read-only `render.backends` + command. It describes the current CPU raster snapshot path without evaluating the model. + Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR, + render jobs and optional engines remain future work; see [the contract](docs/render-backends.md) + and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620). + ## Dimensioning and error reporting - MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4ad89a23..21a33552 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ pub(crate) use measure_sel::measure_items; mod kernel_heal_tests; mod parameters; mod plastic; +mod render; mod sheet; mod sketch; pub(crate) use sketch::author_path_for_rebind; @@ -222,6 +223,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(section::COMMANDS.iter()); v.extend(view_navigation::COMMANDS.iter()); v.extend(diagnostics::COMMANDS.iter()); + v.extend(render::COMMANDS.iter()); v } diff --git a/crates/engine/src/cmd/render.rs b/crates/engine/src/cmd/render.rs new file mode 100644 index 00000000..d7005c2c --- /dev/null +++ b/crates/engine/src/cmd/render.rs @@ -0,0 +1,91 @@ +//! Render capability discovery. No rendering, host probing or document mutation occurs here. + +use serde_json::{Value, json}; + +use super::CommandSpec; +use crate::params::bad; +use crate::{Result, Session}; + +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")]; + +fn backends(_: &mut Session, params: &Value) -> Result { + if !params.as_object().is_some_and(|p| p.is_empty()) { + return Err(bad("render.backends", "expected an empty parameter object")); + } + Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [crate::render::builtin_backend()]})) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + fn assert_unchanged(s: &mut Session, params: Value) -> Result { + let doc = s.doc.clone(); + let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale()); + let camera = s.camera; + let undo = s.undo.clone(); + let redo = s.redo.clone(); + let result = s.execute("render.backends", ¶ms); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale())); + assert_eq!(camera, s.camera); + for (before, after) in [(&undo, &s.undo), (&redo, &s.redo)] { + assert_eq!(before.len(), after.len()); + for (a, b) in before.iter().zip(after.iter()) { + assert!(Arc::ptr_eq(&a.doc, &b.doc)); + assert_eq!((&a.label, a.active_sketch, &a.pending_moves), (&b.label, b.active_sketch, &b.pending_moves)); + } + } + result + } + + #[test] + fn discovery_preserves_clean_dirty_and_undo_redo_state() { + let mut s = Session::default(); + let r = assert_unchanged(&mut s, Value::Null).unwrap(); + assert_eq!(r["schema_version"], 1); + assert_eq!(r["default_backend"], "builtin"); + assert_eq!(r["backends"].as_array().unwrap().len(), 1); + let descriptor: crate::render::BackendDescriptor = serde_json::from_value(r["backends"][0].clone()).unwrap(); + assert_eq!(descriptor, crate::render::builtin_backend()); + s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 5})).unwrap(); + s.execute("view.apply", &json!({"view": "top"})).unwrap(); + assert!(s.is_dirty()); + assert_unchanged(&mut s, json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.redo.is_empty()); + assert_unchanged(&mut s, json!({})).unwrap(); + for p in [json!([]), json!(3), json!({"backend": "missing"}), json!({"width": "bad"})] { + assert!(assert_unchanged(&mut s, p).is_err()); + } + } + + #[test] + fn discovery_does_not_settle_a_live_drag_or_rebuild_geometry() { + 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()); + s.evaluations = 0; + let model = s.model.state(); + assert_unchanged(&mut s, json!({})).unwrap(); + assert_unchanged(&mut s, json!({"unexpected": true})).unwrap_err(); + assert!(Arc::ptr_eq(&model, &s.model.state())); + assert_eq!(s.evaluations, 0); + } +} diff --git a/crates/render/src/backend.rs b/crates/render/src/backend.rs new file mode 100644 index 00000000..d9f4ba97 --- /dev/null +++ b/crates/render/src/backend.rs @@ -0,0 +1,393 @@ +//! Capability discovery and strict request resolution. This is metadata, not a render-job API. +//! GPU hosts may describe their own executors later; no device or UI types belong here. + +use std::collections::BTreeSet; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Maximum ASCII bytes in a backend or executor identifier. +pub const MAX_RENDER_ID_BYTES: usize = 64; + +macro_rules! render_id { + ($name:ident) => { + #[doc = "A validated, bounded identifier: lowercase ASCII letter followed by letters, digits, '.', '_' or '-'."] + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(try_from = "String", into = "String")] + pub struct $name(String); + + impl $name { + pub fn new(value: &str) -> Result { + if value.is_empty() + || value.len() > MAX_RENDER_ID_BYTES + || !value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + || !value.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')) + { + return Err(ResolveError::InvalidId); + } + Ok(Self(value.into())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = ResolveError; + fn try_from(value: String) -> Result { + Self::new(&value) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + }; +} + +render_id!(BackendId); +render_id!(ExecutorId); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenderMode { + Quick, + Preview, + Final, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenderOperation { + Viewport, + Image, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenderFormat { + Png, + Exr, +} + +/// Features callers can require. Listing a variant does not advertise support for it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenderFeature { + Hdri, + Pbr, + Refraction, + Progressive, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Availability { + Available, + Unavailable { reason: String }, +} + +/// A supported combination, rather than the Cartesian product of independent feature flags. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenderProfile { + pub mode: RenderMode, + pub operation: RenderOperation, + /// Image profiles list their formats; viewport profiles have none. + pub formats: Vec, + pub features: Vec, +} + +/// Admission policy for the new resolver. Existing snapshot entry points retain their limits. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenderLimits { + pub max_dimension: u64, + pub max_pixels: u64, + pub max_scratch_bytes: u64, +} + +impl Default for RenderLimits { + fn default() -> Self { + Self { max_dimension: 4096, max_pixels: 4_194_304, max_scratch_bytes: 256 * 1024 * 1024 } + } +} + +/// Estimated pixel-buffer cost, excluding scene/texture/geometry and codec-internal memory. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScratchLayout { + pub sample_scale: u64, + pub sample_bytes_per_pixel: u64, + pub output_bytes_per_pixel: u64, + pub encoding_bytes_per_pixel: u64, +} + +impl ScratchLayout { + /// Current rasterizer: 2× in each dimension, RGBA8 + f32 depth at both resolutions, + /// plus an allowance for one RGBA copy and encoded output (8 bytes/output pixel). + pub const CPU_RASTER: Self = Self { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 }; + + pub fn estimate(self, width: u64, height: u64) -> Result { + let overflow = ResolveError::SizeOverflow; + let pixels = width.checked_mul(height).ok_or(overflow.clone())?; + let sw = width.checked_mul(self.sample_scale).ok_or(overflow.clone())?; + let sh = height.checked_mul(self.sample_scale).ok_or(overflow.clone())?; + let samples = sw.checked_mul(sh).ok_or(overflow.clone())?; + let sampled = samples.checked_mul(self.sample_bytes_per_pixel).ok_or(overflow.clone())?; + let output = pixels.checked_mul(self.output_bytes_per_pixel).ok_or(overflow.clone())?; + let encoding = pixels.checked_mul(self.encoding_bytes_per_pixel).ok_or(overflow.clone())?; + let bytes = sampled.checked_add(output).and_then(|n| n.checked_add(encoding)).ok_or(overflow.clone())?; + // A request valid on a 64-bit host must not overflow allocations on wasm32. + for value in [sw, sh, samples, pixels, bytes] { + let value = usize::try_from(value).map_err(|_| overflow.clone())?; + if value > isize::MAX as usize { + return Err(overflow); + } + } + Ok(bytes) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutorDescriptor { + pub id: ExecutorId, + pub availability: Availability, + pub profiles: Vec, + pub limits: RenderLimits, + pub scratch: ScratchLayout, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackendDescriptor { + pub id: BackendId, + pub label: String, + pub availability: Availability, + pub executors: Vec, +} + +/// Only strict resolution exists. Unknown JSON policies fail rather than imply a fallback. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RenderPolicy { + #[default] + Strict, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenderRequest { + /// Omitted means the integrated backend, never "whichever engine happens to be available". + #[serde(default)] + pub backend: Option, + pub mode: RenderMode, + pub operation: RenderOperation, + pub width: u64, + pub height: u64, + pub format: Option, + #[serde(default)] + pub features: Vec, + #[serde(default)] + pub policy: RenderPolicy, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ResolvedRequest { + pub backend: BackendId, + pub executor: ExecutorId, + pub profile: RenderProfile, + pub width: u64, + pub height: u64, + pub format: Option, + pub estimated_scratch_bytes: u64, + /// Always empty under the only supported policy, Strict. + pub warnings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolveError { + InvalidId, + InvalidRequest(&'static str), + InvalidRegistry(&'static str), + DuplicateBackend(BackendId), + DuplicateExecutor { backend: BackendId, executor: ExecutorId }, + UnknownBackend(BackendId), + BackendUnavailable(String), + NoAvailableExecutor, + UnsupportedProfile { mode: RenderMode, operation: RenderOperation }, + UnsupportedFormat, + UnsupportedFeature(RenderFeature), + InvalidDimensions, + LimitExceeded { resource: &'static str, requested: u64, limit: u64 }, + SizeOverflow, +} + +impl fmt::Display for ResolveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidId => write!( + f, + "render identifier must be 1–64 ASCII bytes, start with a lowercase letter and contain only lowercase letters, digits, '.', '_' or '-'" + ), + Self::InvalidRequest(reason) => write!(f, "invalid render request: {reason}"), + Self::InvalidRegistry(reason) => write!(f, "invalid render registry: {reason}"), + Self::DuplicateBackend(id) => write!(f, "duplicate render backend: {}", id.as_str()), + Self::DuplicateExecutor { backend, executor } => write!(f, "duplicate executor {} in backend {}", executor.as_str(), backend.as_str()), + Self::UnknownBackend(id) => write!(f, "unknown render backend: {}", id.as_str()), + Self::BackendUnavailable(reason) => write!(f, "render backend unavailable: {reason}"), + Self::NoAvailableExecutor => write!(f, "render backend has no available executor"), + Self::UnsupportedProfile { mode, operation } => write!(f, "unsupported render profile: {mode:?}/{operation:?}"), + Self::UnsupportedFormat => write!(f, "unsupported render output format for this profile"), + Self::UnsupportedFeature(feature) => write!(f, "unsupported render feature: {feature:?}"), + Self::InvalidDimensions => write!(f, "render dimensions must be positive"), + Self::LimitExceeded { resource, requested, limit } => write!(f, "render {resource} exceeds limit: {requested} > {limit}"), + Self::SizeOverflow => write!(f, "render buffer size overflows the supported address space"), + } + } +} + +impl std::error::Error for ResolveError {} + +/// The existing CPU raster snapshot capability. No device is probed and no job is started. +pub fn builtin_backend() -> BackendDescriptor { + BackendDescriptor { + id: BackendId("builtin".into()), + label: "Integrated".into(), + availability: Availability::Available, + executors: vec![ExecutorDescriptor { + id: ExecutorId("cpu-raster".into()), + availability: Availability::Available, + profiles: vec![RenderProfile { + mode: RenderMode::Preview, + operation: RenderOperation::Image, + formats: vec![RenderFormat::Png], + features: Vec::new(), + }], + limits: RenderLimits::default(), + scratch: ScratchLayout::CPU_RASTER, + }], + } +} + +fn validate_registry(backends: &[BackendDescriptor]) -> Result<(), ResolveError> { + if backends.len() > 32 { + return Err(ResolveError::InvalidRegistry("too many backends (maximum 32)")); + } + let mut ids = BTreeSet::new(); + for backend in backends { + if !ids.insert(&backend.id) { + return Err(ResolveError::DuplicateBackend(backend.id.clone())); + } + if backend.executors.len() > 16 { + return Err(ResolveError::InvalidRegistry("too many executors (maximum 16 per backend)")); + } + let mut executors = BTreeSet::new(); + for executor in &backend.executors { + if !executors.insert(&executor.id) { + return Err(ResolveError::DuplicateExecutor { backend: backend.id.clone(), executor: executor.id.clone() }); + } + if executor.profiles.len() > 16 || executor.scratch.sample_scale == 0 { + return Err(ResolveError::InvalidRegistry("invalid profile count or zero sample scale")); + } + let mut profiles = BTreeSet::new(); + for profile in &executor.profiles { + if profile.formats.len() > 16 || profile.features.len() > 32 { + return Err(ResolveError::InvalidRegistry("too many formats or features")); + } + if !profiles.insert((profile.mode, profile.operation)) { + return Err(ResolveError::InvalidRegistry("duplicate mode/operation profile")); + } + let formats: BTreeSet<_> = profile.formats.iter().collect(); + let features: BTreeSet<_> = profile.features.iter().collect(); + if formats.len() != profile.formats.len() || features.len() != profile.features.len() { + return Err(ResolveError::InvalidRegistry("duplicate format or feature")); + } + if (profile.operation == RenderOperation::Image) == profile.formats.is_empty() { + return Err(ResolveError::InvalidRegistry("image profiles require formats; viewport profiles must have none")); + } + } + } + } + Ok(()) +} + +fn check_limit(resource: &'static str, requested: u64, limit: u64) -> Result<(), ResolveError> { + if requested > limit { Err(ResolveError::LimitExceeded { resource, requested, limit }) } else { Ok(()) } +} + +fn check_request(executor: &ExecutorDescriptor, profile: &RenderProfile, request: &RenderRequest) -> Result { + match (request.operation, request.format) { + (RenderOperation::Image, Some(format)) if profile.formats.contains(&format) => {} + (RenderOperation::Viewport, None) => {} + _ => return Err(ResolveError::UnsupportedFormat), + } + // Stable diagnostics even if callers reorder required features. + for feature in request.features.iter().collect::>() { + if !profile.features.contains(feature) { + return Err(ResolveError::UnsupportedFeature(*feature)); + } + } + let limits = executor.limits; + check_limit("width", request.width, limits.max_dimension)?; + check_limit("height", request.height, limits.max_dimension)?; + let pixels = request.width.checked_mul(request.height).ok_or(ResolveError::SizeOverflow)?; + check_limit("pixels", pixels, limits.max_pixels)?; + let scratch = executor.scratch.estimate(request.width, request.height)?; + check_limit("estimated scratch bytes", scratch, limits.max_scratch_bytes)?; + Ok(scratch) +} + +/// Selects the lexicographically first available executor that satisfies the complete request. +/// Order in the registry never implies priority; explicit backends never fall back to another. +pub fn resolve_request(backends: &[BackendDescriptor], request: &RenderRequest) -> Result { + validate_registry(backends)?; + if request.width == 0 || request.height == 0 { + return Err(ResolveError::InvalidDimensions); + } + if request.features.len() > 32 { + return Err(ResolveError::InvalidRequest("too many required features (maximum 32)")); + } + let id = request.backend.clone().unwrap_or_else(|| BackendId("builtin".into())); + let backend = backends.iter().find(|b| b.id == id).ok_or_else(|| ResolveError::UnknownBackend(id.clone()))?; + if let Availability::Unavailable { reason } = &backend.availability { + return Err(ResolveError::BackendUnavailable(reason.clone())); + } + let mut executors: Vec<_> = backend.executors.iter().filter(|e| e.availability == Availability::Available).collect(); + executors.sort_by(|a, b| a.id.cmp(&b.id)); + if executors.is_empty() { + return Err(ResolveError::NoAvailableExecutor); + } + let mut failure = None; + for executor in executors { + let Some(profile) = executor.profiles.iter().find(|p| p.mode == request.mode && p.operation == request.operation) else { continue }; + match check_request(executor, profile, request) { + Ok(estimated_scratch_bytes) => { + let mut profile = profile.clone(); + profile.formats.sort(); + profile.features.sort(); + return Ok(ResolvedRequest { + backend: id, + executor: executor.id.clone(), + profile, + width: request.width, + height: request.height, + format: request.format, + estimated_scratch_bytes, + warnings: Vec::new(), + }); + } + Err(error) => { + failure.get_or_insert(error); + } + } + } + Err(failure.unwrap_or(ResolveError::UnsupportedProfile { mode: request.mode, operation: request.operation })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/backend/tests.rs b/crates/render/src/backend/tests.rs new file mode 100644 index 00000000..a08f7c21 --- /dev/null +++ b/crates/render/src/backend/tests.rs @@ -0,0 +1,190 @@ +use super::*; +use serde_json::json; + +fn request() -> RenderRequest { + RenderRequest { + backend: None, + mode: RenderMode::Preview, + operation: RenderOperation::Image, + width: 640, + height: 480, + format: Some(RenderFormat::Png), + features: Vec::new(), + policy: RenderPolicy::Strict, + } +} + +#[test] +fn builtin_resolves_only_the_existing_raster_snapshot_profile() { + let backends = [builtin_backend()]; + let r = resolve_request(&backends, &request()).unwrap(); + assert_eq!(r.backend.as_str(), "builtin"); + assert_eq!(r.executor.as_str(), "cpu-raster"); + assert_eq!(r.profile.mode, RenderMode::Preview); + assert_eq!(r.estimated_scratch_bytes, 640 * 480 * 48); + assert!(r.warnings.is_empty() && r.profile.features.is_empty()); + for mode in [RenderMode::Quick, RenderMode::Final] { + assert_eq!( + resolve_request(&backends, &RenderRequest { mode, ..request() }), + Err(ResolveError::UnsupportedProfile { mode, operation: RenderOperation::Image }) + ); + } + assert!(matches!( + resolve_request(&backends, &RenderRequest { operation: RenderOperation::Viewport, format: None, ..request() }), + Err(ResolveError::UnsupportedProfile { .. }) + )); + for format in [None, Some(RenderFormat::Exr)] { + assert_eq!(resolve_request(&backends, &RenderRequest { format, ..request() }), Err(ResolveError::UnsupportedFormat)); + } + for feature in [RenderFeature::Hdri, RenderFeature::Pbr, RenderFeature::Refraction, RenderFeature::Progressive] { + assert_eq!( + resolve_request(&backends, &RenderRequest { features: vec![feature], ..request() }), + Err(ResolveError::UnsupportedFeature(feature)) + ); + } +} + +#[test] +fn registry_order_is_not_backend_or_executor_priority() { + let mut second = builtin_backend(); + second.id = BackendId::new("test-engine").unwrap(); + second.executors.first_mut().unwrap().id = ExecutorId::new("z-cpu").unwrap(); + let mut other = second.executors.first().unwrap().clone(); + other.id = ExecutorId::new("a-cpu").unwrap(); + second.executors.push(other); + let mut backends = vec![builtin_backend(), second]; + let req = RenderRequest { backend: Some(BackendId::new("test-engine").unwrap()), ..request() }; + let expected = resolve_request(&backends, &req).unwrap(); + assert_eq!(expected.executor.as_str(), "a-cpu"); + backends.reverse(); + backends.first_mut().unwrap().executors.reverse(); + assert_eq!(resolve_request(&backends, &req).unwrap(), expected); + assert_eq!(resolve_request(&backends, &request()).unwrap().backend.as_str(), "builtin"); + // A candidate that cannot satisfy the request must not hide a later capable executor. + backends.first_mut().unwrap().executors.first_mut().unwrap().limits.max_dimension = 1; + assert_eq!(resolve_request(&backends, &req).unwrap().executor.as_str(), "z-cpu"); +} + +#[test] +fn explicit_unknown_or_unavailable_backend_never_falls_back() { + let builtin = builtin_backend(); + let unknown = BackendId::new("missing").unwrap(); + assert_eq!( + resolve_request(std::slice::from_ref(&builtin), &RenderRequest { backend: Some(unknown.clone()), ..request() }), + Err(ResolveError::UnknownBackend(unknown)) + ); + let mut second = builtin.clone(); + second.id = BackendId::new("test-engine").unwrap(); + second.availability = Availability::Unavailable { reason: "device initialization failed".into() }; + let req = RenderRequest { backend: Some(second.id.clone()), ..request() }; + assert_eq!(resolve_request(&[builtin, second], &req), Err(ResolveError::BackendUnavailable("device initialization failed".into()))); + let mut backend = builtin_backend(); + backend.executors.first_mut().unwrap().availability = Availability::Unavailable { reason: "executor disabled".into() }; + assert_eq!(resolve_request(&[backend], &request()), Err(ResolveError::NoAvailableExecutor)); +} + +#[test] +fn ambiguous_registries_are_rejected_before_selection() { + let mut b = builtin_backend(); + assert!(matches!(resolve_request(&[b.clone(), b.clone()], &request()), Err(ResolveError::DuplicateBackend(_)))); + b.executors.push(b.executors.first().unwrap().clone()); + assert!(matches!(resolve_request(&[b], &request()), Err(ResolveError::DuplicateExecutor { .. }))); + let mut b = builtin_backend(); + let e = b.executors.first_mut().unwrap(); + e.profiles.push(e.profiles.first().unwrap().clone()); + assert!(matches!(resolve_request(&[b], &request()), Err(ResolveError::InvalidRegistry(_)))); +} + +#[test] +fn capabilities_are_not_combined_across_execution_paths() { + let mut b = builtin_backend(); + let e = b.executors.first_mut().unwrap(); + e.id = ExecutorId::new("a-png-with-hdri").unwrap(); + e.profiles.first_mut().unwrap().features = vec![RenderFeature::Hdri]; + let mut second = e.clone(); + second.id = ExecutorId::new("z-exr-without-hdri").unwrap(); + let profile = second.profiles.first_mut().unwrap(); + profile.formats = vec![RenderFormat::Exr]; + profile.features.clear(); + b.executors.push(second); + let req = RenderRequest { format: Some(RenderFormat::Exr), features: vec![RenderFeature::Hdri], ..request() }; + assert_eq!(resolve_request(std::slice::from_ref(&b), &req), Err(ResolveError::UnsupportedFormat)); + b.executors.reverse(); + assert_eq!(resolve_request(&[b], &req), Err(ResolveError::UnsupportedFormat)); +} + +#[test] +fn resolved_profiles_are_canonical_and_large_feature_lists_are_rejected() { + let mut b = builtin_backend(); + let profile = b.executors.first_mut().unwrap().profiles.first_mut().unwrap(); + profile.formats.push(RenderFormat::Exr); + profile.features = vec![RenderFeature::Pbr, RenderFeature::Hdri]; + let expected = resolve_request(std::slice::from_ref(&b), &request()).unwrap(); + let profile = b.executors.first_mut().unwrap().profiles.first_mut().unwrap(); + profile.formats.reverse(); + profile.features.reverse(); + assert_eq!(resolve_request(&[b], &request()).unwrap(), expected); + assert!(matches!( + resolve_request(&[builtin_backend()], &RenderRequest { features: vec![RenderFeature::Hdri; 33], ..request() }), + Err(ResolveError::InvalidRequest(_)) + )); +} + +#[test] +fn dimension_pixel_and_scratch_limits_are_checked_without_allocating_images() { + for (width, height) in [(0, 480), (640, 0)] { + assert_eq!(resolve_request(&[builtin_backend()], &RenderRequest { width, height, ..request() }), Err(ResolveError::InvalidDimensions)); + } + for (width, height, resource) in [(4097, 1, "width"), (1, 4097, "height"), (4096, 4096, "pixels")] { + assert!(matches!( + resolve_request(&[builtin_backend()], &RenderRequest { width, height, ..request() }), + Err(ResolveError::LimitExceeded { resource: r, .. }) if r == resource + )); + } + let mut b = builtin_backend(); + b.executors.first_mut().unwrap().limits.max_scratch_bytes = 640 * 480 * 48 - 1; + assert!(matches!(resolve_request(&[b], &request()), Err(ResolveError::LimitExceeded { resource: "estimated scratch bytes", .. }))); + // Both policy boundaries are inclusive. + let r = resolve_request(&[builtin_backend()], &RenderRequest { width: 2048, height: 2048, ..request() }).unwrap(); + assert_eq!(r.estimated_scratch_bytes, 201_326_592); + assert!(resolve_request(&[builtin_backend()], &RenderRequest { width: 4096, height: 1, ..request() }).is_ok()); +} + +#[test] +fn checked_arithmetic_covers_pixels_supersampling_buffers_and_sums() { + let mut b = builtin_backend(); + b.executors.first_mut().unwrap().limits = RenderLimits { max_dimension: u64::MAX, max_pixels: u64::MAX, max_scratch_bytes: u64::MAX }; + assert_eq!(resolve_request(&[b], &RenderRequest { width: u64::MAX, height: 2, ..request() }), Err(ResolveError::SizeOverflow)); + let s = ScratchLayout::CPU_RASTER; + for (width, height) in [(u64::MAX, 1), (u64::MAX / 2, 2), (u64::MAX / 8, 1)] { + assert_eq!(s.estimate(width, height), Err(ResolveError::SizeOverflow)); + } + let sum = ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: u64::MAX - 1, output_bytes_per_pixel: 1, encoding_bytes_per_pixel: 1 }; + assert_eq!(sum.estimate(1, 1), Err(ResolveError::SizeOverflow)); +} + +#[test] +fn identifiers_and_json_reject_unsafe_or_unsupported_input() { + for id in ["", "Builtin", "../builtin", "with space", "1cpu", "café", &"a".repeat(MAX_RENDER_ID_BYTES + 1)] { + assert!(BackendId::new(id).is_err(), "{id}"); + assert!(serde_json::from_value::(json!(id)).is_err(), "{id}"); + } + assert!(BackendId::new(&"a".repeat(MAX_RENDER_ID_BYTES)).is_ok()); + let valid = serde_json::to_value(request()).unwrap(); + assert_eq!(serde_json::from_value::(valid.clone()).unwrap(), request()); + for (key, value) in [ + ("width", json!(-1)), + ("width", json!(1.5)), + ("height", json!("480")), + ("mode", json!("photo")), + ("policy", json!("fallback")), + ("unexpected", json!(true)), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!(serde_json::from_value::(invalid).is_err(), "{key}"); + } + for text in ["{", "[]", "null", "{\"width\":NaN}", "{\"width\":1e999}"] { + assert!(serde_json::from_str::(text).is_err()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 90f5e999..9329525e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -4,10 +4,16 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod backend; mod camera; mod curve_display; mod raster; +pub use backend::{ + Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits, + RenderMode, RenderOperation, RenderPolicy, RenderProfile, RenderRequest, ResolveError, ResolvedRequest, ScratchLayout, builtin_backend, + resolve_request, +}; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; diff --git a/docs/features.md b/docs/features.md index 6a765142..b469f779 100644 --- a/docs/features.md +++ b/docs/features.md @@ -481,6 +481,12 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Insert Part | `parts.insert` | – | | Change Part Size | `parts.resize` | – | +## `render.*` (1) + +| Command | Id | Toolbar | +|---|---|---| +| Render Backends | `render.backends` | – | + ## `select.*` (2) | Command | Id | Toolbar | diff --git a/docs/render-backends.md b/docs/render-backends.md new file mode 100644 index 00000000..21eeaf2f --- /dev/null +++ b/docs/render-backends.md @@ -0,0 +1,135 @@ +# Render backend capabilities (R1) + +`render.backends` discovers render metadata without opening a window, probing a GPU, +evaluating the model or changing the document, camera, dirty state or undo/redo history. +It also leaves a model made stale by a live sketch gesture stale. + +```sh +solidcraft-cli exec render.backends '{}' +``` + +The control channel and MCP `execute` use the same engine command. Parameters must be +an empty object; a null parameter value is normalized to `{}` by the engine. Other +parameters fail with a normal command error. + +## Discovery schema + +The command returns this JSON object (schema version 1): + +```json +{ + "schema_version": 1, + "default_backend": "builtin", + "backends": [{ + "id": "builtin", + "label": "Integrated", + "availability": {"status": "available"}, + "executors": [{ + "id": "cpu-raster", + "availability": {"status": "available"}, + "profiles": [{ + "mode": "preview", + "operation": "image", + "formats": ["png"], + "features": [] + }], + "limits": { + "max_dimension": 4096, + "max_pixels": 4194304, + "max_scratch_bytes": 268435456 + }, + "scratch": { + "sample_scale": 2, + "sample_bytes_per_pixel": 8, + "output_bytes_per_pixel": 8, + "encoding_bytes_per_pixel": 8 + } + }] + }] +} +``` + +An unavailable backend or executor instead has +`"availability": {"status": "unavailable", "reason": "..."}`. These are runtime +descriptors, not design-file settings. Executor IDs are scoped to their backend. +Backend/executor IDs are 1–64 ASCII bytes: a lowercase letter followed by lowercase +letters, digits, `.`, `_` or `-`. Both construction and deserialization validate IDs. + +The integrated backend currently describes only the existing CPU raster snapshot +path. Its `preview`/`image` profile exports PNG; it does not claim HDRI, PBR, +refraction, progressive rendering or a final-quality integrator. The existing GPU +viewport remains separate and is not advertised here until a GPU host can register +and probe its actual capabilities. CPU and GPU are execution paths, not separate +user-selectable engine families. + +## Pure request resolution + +`solidcraft_render::resolve_request` accepts a descriptor slice and a `RenderRequest`. +It validates and resolves metadata only: it does not allocate an image, start a job, +reroute `snapshot` or call a renderer. A request can be deserialized from: + +```json +{ + "backend": "builtin", + "mode": "preview", + "operation": "image", + "width": 640, + "height": 480, + "format": "png", + "features": [], + "policy": "strict" +} +``` + +`backend` defaults to `builtin` when absent/null. `features` defaults to `[]`, and +`policy` defaults to `strict`. Mode, operation and positive integer dimensions are +required. An image requires a supported format; a viewport requires absent/null +`format`. Unknown fields, enum values, invalid IDs and incorrectly typed values fail +deserialization. Dimensions are checked by the resolver. No floating-point request +fields are introduced. + +Modes are `quick`, `preview`, `final`; operations are `viewport`, `image`; formats +are `png`, `exr`. Required features can be `hdri`, `pbr`, `refraction`, `progressive`. +These names define requests, not promises of support. Each executor lists complete +mode/operation profiles with their formats and features. Capabilities are never +combined across executors to create an unsupported profile. + +Resolution rejects duplicate backend IDs, executor IDs within a backend and duplicate +mode/operation profiles. Registry sizes are bounded to 32 backends, 16 executors per +backend and 16 profiles per executor; a request allows at most 32 feature entries. +Available executors are considered in lexicographic ID order, and the first executor +that satisfies the entire request is selected. Registry order does not convey priority. +Returned profile formats/features are sorted, so their declaration order does not +change the resolved result. If every candidate fails, the first matching executor's +error is returned; unsupported profile is reported when none has that combination. + +The resolved value contains `backend`, `executor`, `profile`, `width`, `height`, +`format`, `estimated_scratch_bytes` and `warnings`. Under strict policy warnings are +empty and no requested setting is downgraded. Errors are typed `ResolveError` values: +invalid IDs/request/registry, duplicate IDs, unknown/unavailable backend, no available +executor, unsupported profile/format/feature, invalid dimensions, exceeded limits or +size overflow. There is no fallback policy or public job API in R1. + +## Resource admission policy + +The new resolver's default limits are conservative policy values, not measured adapter +capacity: 1–4096 pixels per dimension, at most 4,194,304 output pixels and a 256 MiB +estimated pixel-buffer budget. Each executor supplies configurable `RenderLimits` and +a `ScratchLayout`. Checked arithmetic covers dimensions, supersampling, buffer products, +sums and the target's addressable allocation size, including wasm32. + +For the existing CPU raster path the estimate is 48 bytes per output pixel: four +supersamples at 8 bytes each (RGBA8 plus f32 depth), an 8-byte output canvas, and an +8-byte allowance for an RGBA copy and encoded output. At 640 × 480 this is 14,745,600 +bytes; at 2048 × 2048 it is 201,326,592 bytes. A 4096 × 4096 request exceeds the pixel +limit despite each dimension being permitted. + +This is an admission estimate, not a total process-memory bound. It excludes scene, +texture, projected-geometry, acceleration-structure and codec-internal allocations. +Future execution hosts must enforce their own complete allocation/cancellation policy. +The legacy rasterizer, CLI snapshots and GPU viewport keep their existing behaviour +and limits; none is routed through this resolver in R1. + +The follow-on Render workspace, persistent setups, job hosts and optional engines are +tracked in [#620](https://github.com/bherbruck/solidcraft/issues/620). The implemented +foundation is scoped by [#642](https://github.com/bherbruck/solidcraft/issues/642).