Files
OneCAD/crates/io/src/catalog.rs
T
selune a261aaa841 Render: integrated workspace, preview image jobs and opaque PBR (#1046)
Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change.

- Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added.
- Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing.
- Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it.
- Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru.

Supersedes #706 and #800. Refs #620, #673, #756, #1004.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 19:24:36 -04:00

293 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Portable scalar libraries; no renderer, network, external resources or design links.
use crate::{IoError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use solidcraft_doc::{Document, UserMaterial, appearance::Look, valid_density};
use std::collections::BTreeMap;
pub const FORMAT: &str = "solidcraft-library/1";
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_ENTRIES: usize = 1024;
/// Bound source inspection even when many assignments reuse the same definition.
pub const MAX_SOURCE_REFERENCES: usize = 100_000;
fn invalid(message: impl Into<String>) -> IoError {
IoError::Invalid(message.into())
}
fn text(value: &str, max: usize) -> bool {
!value.is_empty() && value == value.trim() && value.len() <= max && !value.chars().any(char::is_control)
}
fn identifier(value: &str) -> bool {
value.len() <= 64
&& value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
&& value.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Provenance {
pub license: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// Information only: never fetched or treated as a local resource path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
}
impl Default for Provenance {
fn default() -> Self {
Self { license: "LicenseRef-User-Provided".into(), author: None, source_url: None }
}
}
impl Provenance {
fn validate(&self) -> Result<()> {
if !text(&self.license, 128)
|| self.author.as_ref().is_some_and(|s| !text(s, 256))
|| self.source_url.as_ref().is_some_and(|s| !text(s, 2048))
{
return Err(invalid("invalid library provenance (license 128, author 256, source URL 2048 bytes)"));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScalarLook {
pub name: String,
pub color: [u8; 3],
pub opacity: f64,
}
impl ScalarLook {
pub fn from_look(look: &Look) -> Result<Self> {
if look.pbr.is_some() {
return Err(invalid("scalar catalog v1 cannot preserve PBR parameters; export refused"));
}
Ok(Self { name: look.name.clone(), color: look.color, opacity: look.opacity })
}
pub fn to_look(&self) -> Look {
Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: None }
}
fn validate(&self) -> Result<()> {
if !text(&self.name, 128) || !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) {
return Err(invalid("library appearance needs a 1–128 byte name and finite opacity 0–1"));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScalarMaterial {
pub name: String,
pub density_g_cm3: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<ScalarLook>,
}
impl ScalarMaterial {
pub fn from_material(material: &UserMaterial) -> Result<Self> {
Ok(Self {
name: material.name.clone(),
density_g_cm3: material.density,
appearance: material.appearance.as_ref().map(ScalarLook::from_look).transpose()?,
})
}
pub fn to_material(&self, name: String) -> UserMaterial {
UserMaterial { name, density: self.density_g_cm3, appearance: self.appearance.as_ref().map(ScalarLook::to_look) }
}
fn validate(&self) -> Result<()> {
if !text(&self.name, 128) || !valid_density(self.density_g_cm3) {
return Err(invalid("invalid library material name or density"));
}
if let Some(look) = &self.appearance {
look.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
pub enum Definition {
Appearance(ScalarLook),
Material(ScalarMaterial),
}
impl Definition {
pub fn name(&self) -> &str {
match self {
Self::Appearance(l) => &l.name,
Self::Material(m) => &m.name,
}
}
fn validate(&self) -> Result<()> {
match self {
Self::Appearance(l) => l.validate(),
Self::Material(m) => m.validate(),
}
}
fn kind(&self) -> &str {
match self {
Self::Appearance(_) => "appearance",
Self::Material(_) => "material",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub id: String,
pub revision: String,
pub provenance: Provenance,
pub definition: Definition,
}
impl Entry {
/// Revision is SHA-256 of compact serde JSON `[provenance, definition]` in this v1 contract.
pub fn content_revision(&self) -> Result<String> {
self.provenance.validate()?;
self.definition.validate()?;
let bytes = serde_json::to_vec(&(&self.provenance, &self.definition)).map_err(|e| invalid(e.to_string()))?;
Ok(format!("{:x}", Sha256::digest(bytes)))
}
pub fn new(pack_id: &str, definition: Definition, provenance: Provenance) -> Result<Self> {
if !identifier(pack_id) {
return Err(invalid("invalid library identifier"));
}
let mut out = Self { id: String::new(), revision: String::new(), provenance, definition };
out.revision = out.content_revision()?;
let prefix = out.revision.get(..16).ok_or_else(|| invalid("invalid library revision"))?;
out.id = format!("{pack_id}/{}-{prefix}", out.definition.kind());
Ok(out)
}
fn validate(&self, pack_id: &str) -> Result<()> {
let (pack, local) = self.id.split_once('/').ok_or_else(|| invalid("entry ID must be qualified by its library ID"))?;
if pack != pack_id || !identifier(local) || self.revision != self.content_revision()? {
return Err(invalid("invalid entry identity or content revision"));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Catalog {
pub format: String,
pub id: String,
pub name: String,
#[serde(deserialize_with = "bounded_entries")]
pub entries: Vec<Entry>,
}
fn bounded_entries<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Vec<Entry>, D::Error> {
struct Entries;
impl<'de> serde::de::Visitor<'de> for Entries {
type Value = Vec<Entry>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("at most 1024 scalar library entries")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error> {
let mut entries = Vec::new();
loop {
if entries.len() == MAX_ENTRIES {
if sequence.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(serde::de::Error::custom("scalar library exceeds 1024 entries"));
}
break;
}
let Some(entry) = sequence.next_element::<Entry>()? else { break };
let revision = entry.content_revision().map_err(serde::de::Error::custom)?;
if entry.revision != revision || entry.id.len() > 129 {
return Err(serde::de::Error::custom("invalid scalar library revision or identity"));
}
entries.try_reserve(1).map_err(|_| serde::de::Error::custom("could not reserve scalar library entries"))?;
entries.push(entry);
}
Ok(entries)
}
}
deserializer.deserialize_seq(Entries)
}
impl Catalog {
pub fn validate(&self) -> Result<()> {
if self.format != FORMAT || !identifier(&self.id) || !text(&self.name, 128) || self.entries.len() > MAX_ENTRIES {
return Err(invalid("unsupported or invalid scalar library (v1, 1024 entries, 128 byte name)"));
}
let mut ids = std::collections::BTreeSet::new();
for entry in &self.entries {
entry.validate(&self.id)?;
if !ids.insert(&entry.id) {
return Err(invalid("duplicate library entry identity"));
}
}
Ok(())
}
pub fn decode(bytes: &[u8]) -> Result<Self> {
if bytes.len() > MAX_BYTES {
return Err(invalid("scalar library exceeds 4 MiB"));
}
let catalog: Self = serde_json::from_slice(bytes).map_err(|e| invalid(format!("invalid scalar library: {e}")))?;
catalog.validate()?;
Ok(catalog)
}
pub fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let bytes = serde_json::to_vec_pretty(self).map_err(|e| invalid(e.to_string()))?;
if bytes.len() > MAX_BYTES {
return Err(invalid("scalar library exceeds 4 MiB"));
}
Ok(bytes)
}
pub fn read(path: &str) -> Result<Self> {
// Check the VFS size first as well: its browser read clones the stored bytes.
if crate::vfs::len(path).map_err(|e| invalid(e.to_string()))? > MAX_BYTES as u64 {
return Err(invalid("scalar library exceeds 4 MiB"));
}
Self::decode(&crate::vfs::read(path, MAX_BYTES).map_err(|e| invalid(e.to_string()))?)
}
pub fn from_document(doc: &Document, id: &str, name: &str, provenance: Provenance) -> Result<Self> {
if !identifier(id) || !text(name, 128) {
return Err(invalid("invalid scalar library identifier or name"));
}
provenance.validate()?;
let references = doc
.appearances
.bodies
.len()
.saturating_add(doc.appearances.components.len())
.saturating_add(doc.appearances.faces.len())
.saturating_add(doc.user_materials.len().saturating_mul(2));
if references > MAX_SOURCE_REFERENCES {
return Err(invalid("library export exceeds 100000 source references"));
}
let mut entries = BTreeMap::new();
let mut insert = |definition| -> Result<()> {
let entry = Entry::new(id, definition, provenance.clone())?;
if let Some(previous) = entries.get(&entry.id) {
if previous != &entry {
return Err(invalid("library content identifier collision"));
}
} else {
if entries.len() >= MAX_ENTRIES {
return Err(invalid("scalar library exceeds 1024 unique entries"));
}
entries.insert(entry.id.clone(), entry);
}
Ok(())
};
for look in doc.appearances.bodies.values().chain(doc.appearances.components.values()).chain(doc.appearances.faces.iter().map(|f| &f.look)) {
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
}
for material in &doc.user_materials {
insert(Definition::Material(ScalarMaterial::from_material(material)?))?;
if let Some(look) = &material.appearance {
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
}
}
let out = Self { format: FORMAT.into(), id: id.into(), name: name.into(), entries: entries.into_values().collect() };
out.validate()?;
Ok(out)
}
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;