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:
1 parent
1e4387f3ea
commit
0e500cacd3
8 files changed
+832
No files matched your search
@@ -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 |
|
| 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 |
|
| 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
|
## Dimensioning and error reporting
|
||||||
|
|
||||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub(crate) use measure_sel::measure_items;
|
|||||||
mod kernel_heal_tests;
|
mod kernel_heal_tests;
|
||||||
mod parameters;
|
mod parameters;
|
||||||
mod plastic;
|
mod plastic;
|
||||||
|
mod render;
|
||||||
mod sheet;
|
mod sheet;
|
||||||
mod sketch;
|
mod sketch;
|
||||||
pub(crate) use sketch::author_path_for_rebind;
|
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(section::COMMANDS.iter());
|
||||||
v.extend(view_navigation::COMMANDS.iter());
|
v.extend(view_navigation::COMMANDS.iter());
|
||||||
v.extend(diagnostics::COMMANDS.iter());
|
v.extend(diagnostics::COMMANDS.iter());
|
||||||
|
v.extend(render::COMMANDS.iter());
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", ¶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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,16 @@
|
|||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod backend;
|
||||||
mod camera;
|
mod camera;
|
||||||
mod curve_display;
|
mod curve_display;
|
||||||
mod raster;
|
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 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 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};
|
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||||
@@ -481,6 +481,12 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
|||||||
| Insert Part | `parts.insert` | – |
|
| Insert Part | `parts.insert` | – |
|
||||||
| Change Part Size | `parts.resize` | – |
|
| Change Part Size | `parts.resize` | – |
|
||||||
|
|
||||||
|
## `render.*` (1)
|
||||||
|
|
||||||
|
| Command | Id | Toolbar |
|
||||||
|
|---|---|---|
|
||||||
|
| Render Backends | `render.backends` | – |
|
||||||
|
|
||||||
## `select.*` (2)
|
## `select.*` (2)
|
||||||
|
|
||||||
| Command | Id | Toolbar |
|
| Command | Id | Toolbar |
|
||||||
|
|||||||
@@ -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).
|
||||||
Reference in new issue
Block a user