Render: add backend capability discovery and strict request resolution (#650)

Adds render.backends, a metadata-only command that lists the render backends, their executors and profiles, with strict request resolution in the render crate (docs/render-backends.md).
This commit is contained in:
selune authored and GitHub committed 2026-10-13 10:17:41 -04:00
1 parent 1e4387f3ea
commit 0e500cacd3
8 files changed
+832

No files matched your search

+9
View File
@@ -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 `<file>.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
+2
View File
@@ -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
}
+91
View File
@@ -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<Value> {
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<Value> {
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", &params);
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);
}
}
+393
View File
@@ -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<Self, ResolveError> {
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<String> for $name {
type Error = ResolveError;
fn try_from(value: String) -> Result<Self, Self::Error> {
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<RenderFormat>,
pub features: Vec<RenderFeature>,
}
/// 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<u64, ResolveError> {
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<RenderProfile>,
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<ExecutorDescriptor>,
}
/// 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<BackendId>,
pub mode: RenderMode,
pub operation: RenderOperation,
pub width: u64,
pub height: u64,
pub format: Option<RenderFormat>,
#[serde(default)]
pub features: Vec<RenderFeature>,
#[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<RenderFormat>,
pub estimated_scratch_bytes: u64,
/// Always empty under the only supported policy, Strict.
pub warnings: Vec<String>,
}
#[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<u64, ResolveError> {
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::<BTreeSet<_>>() {
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<ResolvedRequest, ResolveError> {
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;
+190
View File
@@ -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::<ExecutorId>(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::<RenderRequest>(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::<RenderRequest>(invalid).is_err(), "{key}");
}
for text in ["{", "[]", "null", "{\"width\":NaN}", "{\"width\":1e999}"] {
assert!(serde_json::from_str::<RenderRequest>(text).is_err());
}
}
+6
View File
@@ -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};
+6
View File
@@ -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 |
+135
View File
@@ -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).