Local appearance and material libraries (#863)

Adds local scalar appearance and material libraries: a catalog in the io crate with safe writes, engine commands to list and apply library entries, and a library dialog in the UI, with docs.
This commit is contained in:
selune authored and GitHub committed 2026-10-14 12:21:35 -04:00
1 parent 8ea1fd643f
commit d399effbf7
21 files changed
+1285 -2

No files matched your search

+6
View File
@@ -73,6 +73,12 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Library foundations (#785, #786, #787)
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
selected appearance/material revisions into existing design definitions in one undo step.
Appearance offers local loading/search/export; physical materials use existing commands.
This slice is independent of the render stack and does not change the document format.
See [the contract and limits](docs/book/src/dev/local-libraries.md).
- Proposed phases cover offline bundled/project/user packs, physical materials and
appearances, editable wood mapping, and parameterized component/assembly families.
Stable identity/revision, copied design definitions, provenance and resource limits
+3
View File
@@ -702,6 +702,9 @@
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
"sketch_text.path_clear": "Text von der Kurve lösen",
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage",
"cmd.library_inspect": "Lokale Bibliothek prüfen",
"cmd.library_export": "Designbibliothek exportieren",
"cmd.library_apply": "Lokalen Bibliothekseintrag anwenden",
"Thin": "Dünnwandig",
"Thin Extrude": "Dünnwandig extrudieren",
"Wall Location": "Wandlage",
+3
View File
@@ -959,6 +959,9 @@
"Line Spacing": "Line Spacing",
"Path": "Path",
"sketch_text.on_path": "The text follows its path: the curve places it",
"cmd.library_inspect": "Inspect Local Library",
"cmd.library_export": "Export Design Library",
"cmd.library_apply": "Apply Local Library Entry",
"Thin": "Thin",
"Thin Extrude": "Thin Extrude",
"Wall Location": "Wall Location",
+3
View File
@@ -959,6 +959,9 @@
"Line Spacing": "行間",
"Path": "パス",
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
"cmd.library_inspect": "ローカルライブラリを確認",
"cmd.library_export": "デザインライブラリを書き出す",
"cmd.library_apply": "ローカルライブラリ項目を適用",
"Thin": "薄肉",
"Thin Extrude": "薄肉押し出し",
"Wall Location": "壁の位置",
+3
View File
@@ -702,6 +702,9 @@
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다",
"cmd.library_inspect": "로컬 라이브러리 확인",
"cmd.library_export": "설계 라이브러리 내보내기",
"cmd.library_apply": "로컬 라이브러리 항목 적용",
"Thin": "얇은 벽",
"Thin Extrude": "얇은 벽 돌출",
"Wall Location": "벽 위치",
+3
View File
@@ -959,6 +959,9 @@
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
"ui.analysis": "分析",
"ui.edit_section_analysis": "编辑截面分析",
"cmd.library_inspect": "检查本地库",
"cmd.library_export": "导出设计库",
"cmd.library_apply": "应用本地库条目",
"Thin": "薄壁",
"Thin Extrude": "薄壁拉伸",
"Wall Location": "壁位置",
+199
View File
@@ -0,0 +1,199 @@
//! Scalar local libraries: inspect/export files and copy exact revisions into a design.
use super::CommandSpec;
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
use solidcraft_io::catalog::{Catalog, Definition, Provenance};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("library.inspect", "Inspect Local Library", inspect).noundo().doc_only()
.params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"),
CommandSpec::new("library.export", "Export Design Library", export).noundo().doc_only()
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"),
CommandSpec::new("library.apply", "Apply Local Library Entry", apply).enabled(super::not_in_sketch)
.params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"),
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Inspect {
path: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Export {
path: String,
#[serde(default = "default_id")]
id: String,
#[serde(default = "default_name")]
name: String,
#[serde(default)]
provenance: Provenance,
#[serde(default)]
overwrite: bool,
}
fn default_id() -> String {
"user.local".into()
}
fn default_name() -> String {
"My library".into()
}
#[derive(Default, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Targets {
#[serde(default)]
bodies: Vec<String>,
#[serde(default)]
faces: Vec<Face>,
#[serde(default)]
components: Vec<String>,
}
#[derive(Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Face {
body: String,
point: [f64; 3],
}
impl Targets {
fn validate(&self) -> Result<()> {
let cmd = "library.apply";
if self.bodies.len().saturating_add(self.faces.len()).saturating_add(self.components.len()) > 1024 {
return Err(bad(cmd, "at most 1024 library application targets"));
}
for name in self.bodies.iter().chain(&self.components).chain(self.faces.iter().map(|f| &f.body)) {
if name.is_empty() || name.len() > 1024 || name.chars().any(char::is_control) {
return Err(bad(cmd, "invalid library target name"));
}
}
if self.faces.iter().any(|f| f.point.iter().any(|x| !x.is_finite() || x.abs() > 1e9)) {
return Err(bad(cmd, "invalid library target point"));
}
Ok(())
}
fn empty(&self) -> bool {
self.bodies.is_empty() && self.faces.is_empty() && self.components.is_empty()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Apply {
path: String,
entry: String,
revision: String,
#[serde(default)]
targets: Targets,
name: Option<String>,
}
fn path(cmd: &str, value: &str) -> Result<()> {
if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) {
return Err(bad(cmd, "invalid library path"));
}
solidcraft_io::vfs::check(value).map_err(|e| bad(cmd, e.to_string()))
}
fn nested(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
crate::find_command(id).ok_or_else(|| bad("library.apply", "required assignment command is unavailable"))?.run_nested(s, p)
}
fn inspect(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.inspect";
let request: Inspect = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, e.to_string()))?;
path(cmd, &request.path)?;
let catalog = Catalog::read(&request.path).map_err(|e| bad(cmd, e.to_string()))?;
Ok(json!({"catalog":catalog}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.export";
let request: Export = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, e.to_string()))?;
path(cmd, &request.path)?;
if !request.path.to_ascii_lowercase().ends_with(".json") {
return Err(bad(cmd, "library export path must end in .json"));
}
let catalog = Catalog::from_document(&s.doc, &request.id, &request.name, request.provenance).map_err(|e| bad(cmd, e.to_string()))?;
let bytes = catalog.encode().map_err(|e| bad(cmd, e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
let result = if request.overwrite {
solidcraft_io::write_atomic(std::path::Path::new(&request.path), &bytes, false)
} else {
solidcraft_io::safe_write::create_atomic(std::path::Path::new(&request.path), &bytes)
};
#[cfg(target_arch = "wasm32")]
let result = if !request.overwrite && solidcraft_io::vfs::exists(&request.path) {
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "library file already exists"))
} else {
solidcraft_io::vfs::write(&request.path, &bytes)
};
result.map_err(|e| bad(cmd, format!("could not export library: {e}")))?;
Ok(json!({"path":request.path,"entries":catalog.entries.len(),"bytes":bytes.len(),"catalog":catalog}))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.apply";
let request: Apply = serde_json::from_value(p.clone()).map_err(|e| bad(cmd, e.to_string()))?;
path(cmd, &request.path)?;
request.targets.validate()?;
if request.entry.len() > 129 || request.revision.len() != 64 {
return Err(bad(cmd, "invalid entry ID or revision"));
}
let catalog = Catalog::read(&request.path).map_err(|e| bad(cmd, e.to_string()))?;
let entry = catalog.entries.iter().find(|entry| entry.id == request.entry).ok_or_else(|| bad(cmd, "library entry is missing"))?;
if entry.revision != request.revision {
return Err(bad(cmd, "library entry changed; reload and explicitly select its new revision"));
}
let result = match &entry.definition {
Definition::Appearance(look) => {
if request.name.is_some() || request.targets.empty() {
return Err(bad(cmd, "appearance entries need targets and do not take a material name"));
}
let mut target = serde_json::to_value(&request.targets).map_err(|e| bad(cmd, e.to_string()))?;
target["appearance"] = json!(look.name);
target["color"] = json!(look.color);
target["opacity"] = json!(look.opacity);
nested(s, "appearance.assign", &target)?
}
Definition::Material(material) => {
if !request.targets.faces.is_empty() || !request.targets.components.is_empty() {
return Err(bad(cmd, "physical material assignment takes body targets only"));
}
if request.targets.bodies.iter().any(|body| s.model.state().body(body).is_none()) {
return Err(bad(cmd, "physical material targets must name existing bodies"));
}
let wanted = request.name.as_deref().unwrap_or(&material.name).trim();
if wanted.is_empty() || wanted.len() > 128 || wanted.chars().any(char::is_control) {
return Err(bad(cmd, "invalid physical material name"));
}
let existing = s.doc.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(wanted)).cloned();
let name = existing.as_ref().map_or(wanted, |m| m.name.as_str()).to_string();
let copy = material.to_material(name.clone());
let reused = if let Some(existing) = existing {
if existing != copy {
return Err(bad(cmd, "physical material name conflicts; choose a new name explicitly"));
}
true
} else {
let mut params = json!({"name":name,"density_g_cm3":material.density_g_cm3});
if let Some(look) = &material.appearance {
params["color"] = json!(look.color);
params["opacity"] = json!(look.opacity);
}
nested(s, "material.create", &params)?;
// The existing colour API labels a custom look after its material.
// Preserve the library's exact look label inside this same transaction.
let imported = s
.doc_mut()
.user_materials
.iter_mut()
.find(|m| m.name == name)
.ok_or_else(|| bad(cmd, "imported physical material is missing"))?;
imported.appearance = copy.appearance.clone();
false
};
if !request.targets.bodies.is_empty() {
nested(s, "material.assign", &json!({"bodies":request.targets.bodies,"material":name}))?;
}
json!({"material":name,"reused":reused,"bodies":request.targets.bodies})
}
};
Ok(json!({"entry":entry.id,"revision":entry.revision,"copied":true,"result":result}))
}
#[cfg(test)]
#[path = "libraries_tests.rs"]
mod tests;
+220
View File
@@ -0,0 +1,220 @@
use super::*;
use solidcraft_io::catalog::{Definition, Entry, FORMAT, ScalarLook, ScalarMaterial};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
struct File(String);
impl File {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
Self(
std::env::temp_dir()
.join(format!("solidcraft-catalog-{}-{}.json", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)))
.to_string_lossy()
.into_owned(),
)
}
fn write(&self, catalog: &Catalog) {
std::fs::write(&self.0, catalog.encode().unwrap()).unwrap();
}
}
impl Drop for File {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn catalog() -> Catalog {
Catalog {
format: FORMAT.into(),
id: "user.test".into(),
name: "Original scalar test library".into(),
entries: vec![
Entry::new(
"user.test",
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
Provenance::default(),
)
.unwrap(),
Entry::new(
"user.test",
Definition::Material(ScalarMaterial {
name: "Timber density".into(),
density_g_cm3: 0.7,
appearance: Some(ScalarLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0 }),
}),
Provenance::default(),
)
.unwrap(),
],
}
}
fn session() -> Session {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
s.mark_saved();
s
}
fn apply_entry(s: &mut Session, file: &File, entry: &Entry, targets: Value) -> Result<Value> {
s.execute("library.apply", &json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"targets":targets}))
}
#[test]
fn catalog_export_obeys_file_scope_in_new_file_and_overwrite_modes() {
let file = File::new();
let base = std::path::Path::new(&file.0).with_extension("scope");
std::fs::create_dir(&base).unwrap();
let root = base.join("allowed");
let outside = base.join("outside");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&outside).unwrap();
let sentinel = outside.join("existing.json");
std::fs::write(&sentinel, b"previous contents").unwrap();
let mut s = session();
let before = s.doc.clone();
let undo = s.undo.len();
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || {
for path in [outside.join("new.json"), root.join("../outside/traversal.json"), sentinel.clone()] {
for overwrite in [false, true] {
let error = s.execute("library.export", &json!({"path":path,"overwrite":overwrite})).unwrap_err();
assert!(error.to_string().contains("outside the folders this agent may use"), "{error}");
}
}
for overwrite in [false, true] {
s.execute("library.export", &json!({"path":root.join("allowed.json"),"overwrite":overwrite})).unwrap();
}
});
assert_eq!(std::fs::read(&sentinel).unwrap(), b"previous contents");
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1);
assert!(root.join("allowed.json").is_file());
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(s.undo.len(), undo);
assert!(!s.is_dirty());
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn catalog_appearance_copy_keeps_mass_and_survives_undo_and_cold_reopen_without_file() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let mut s = session();
let original_format = s.doc.format.clone();
let before = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
let undo = s.undo.len();
apply_entry(&mut s, &file, &catalog.entries[0], json!({"bodies":["Body1"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert_eq!(s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap(), before);
let saved = solidcraft_io::write_design(&s.doc);
std::fs::remove_file(&file.0).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.appearances.bodies.is_empty());
s.execute("edit.redo", &json!({})).unwrap();
let reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
assert_eq!(reopened.doc.appearances, s.doc.appearances);
assert_eq!(reopened.doc.format, original_format, "scalar library adds no document schema");
}
#[test]
fn catalog_physical_copy_preserves_exact_look_label_and_is_one_transaction() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let mut s = session();
let undo = s.undo.len();
apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["Body1"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.doc.user_materials[0].appearance.as_ref().unwrap().name, "Timber tint");
let mass = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
assert!((mass - 0.7).abs() < 1e-8);
let saved = solidcraft_io::write_design(&s.doc);
std::fs::remove_file(&file.0).unwrap();
let mut reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
let actual = reopened.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
assert!((actual - 0.7).abs() < 1e-8);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc.user_materials.is_empty());
assert!(s.doc.materials.is_empty());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc.user_materials, reopened.doc.user_materials);
}
#[test]
fn catalog_conflicts_stale_revisions_and_invalid_targets_are_atomic() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let mut s = session();
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":1.2})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let redo = s.redo.len();
let doc = s.doc.clone();
let undo = s.undo.len();
let revision = s.revision;
assert!(apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["missing"]})).is_err());
assert_eq!(*s.doc, *doc);
assert_eq!((s.undo.len(), s.redo.len(), s.revision), (undo, redo, revision));
s.execute("edit.redo", &json!({})).unwrap();
let doc = s.doc.clone();
let history = (s.undo.len(), s.redo.len(), s.revision);
assert!(apply_entry(&mut s, &file, &catalog.entries[1], json!({"bodies":["Body1"]})).is_err());
assert_eq!(*s.doc, *doc);
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
let mut changed = catalog.clone();
if let Definition::Appearance(look) = &mut changed.entries[0].definition {
look.color = [1, 2, 3];
}
changed.entries[0].revision = changed.entries[0].content_revision().unwrap();
file.write(&changed);
assert!(apply_entry(&mut s, &file, &catalog.entries[0], json!({"bodies":["Body1"]})).is_err());
assert_eq!(*s.doc, *doc);
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
}
#[test]
fn catalog_explicit_material_rename_and_identical_definition_reuse() {
let file = File::new();
let catalog = catalog();
file.write(&catalog);
let entry = &catalog.entries[1];
let mut s = session();
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":1.2})).unwrap();
let params = json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"name":"Imported timber"});
s.execute("library.apply", &params).unwrap();
assert_eq!(s.doc.user_materials.len(), 2);
let undo = s.undo.len();
let revision = s.revision;
let again = s.execute("library.apply", &params).unwrap();
assert_eq!(again["result"]["reused"], true);
assert_eq!((s.undo.len(), s.revision), (undo, revision));
assert_eq!(s.doc.user_materials[0].density, 1.2);
}
#[test]
fn catalog_inspection_export_and_no_clobber_do_not_touch_design_or_history() {
let file = File::new();
let mut s = session();
s.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
s.mark_saved();
let doc = s.doc.clone();
let undo = s.undo.len();
let revision = s.revision;
let result = s.execute("library.export", &json!({"path":file.0})).unwrap();
assert_eq!(result["entries"], 1);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.revision), (undo, revision));
assert!(!s.is_dirty());
let before = std::fs::read(&file.0).unwrap();
s.execute("library.inspect", &json!({"path":file.0})).unwrap();
assert!(s.execute("library.export", &json!({"path":file.0})).is_err());
assert_eq!(before, std::fs::read(&file.0).unwrap());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.revision), (undo, revision));
s.execute("library.export", &json!({"path":file.0,"overwrite":true})).unwrap();
assert_eq!(before, std::fs::read(&file.0).unwrap());
for command in ["library.inspect", "library.export", "library.apply"] {
assert!(s.execute(command, &json!({"path":file.0,"unknown":true})).is_err());
}
}
+2
View File
@@ -38,6 +38,7 @@ mod config;
mod file;
mod inspect;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
@@ -254,6 +255,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(inspect::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
+285
View File
@@ -0,0 +1,285 @@
//! 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) -> Self {
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 }
}
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) -> Self {
Self { name: material.name.clone(), density_g_cm3: material.density, appearance: material.appearance.as_ref().map(ScalarLook::from_look) }
}
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;
+125
View File
@@ -0,0 +1,125 @@
use super::*;
use serde_json::json;
fn catalog() -> Catalog {
let entry = Entry::new(
"user.test",
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
Provenance::default(),
)
.unwrap();
Catalog { format: FORMAT.into(), id: "user.test".into(), name: "Original test library".into(), entries: vec![entry] }
}
#[test]
fn catalog_roundtrip_is_deterministic_and_revision_covers_provenance_and_definition() {
let original = catalog();
let bytes = original.encode().unwrap();
let restored = Catalog::decode(&bytes).unwrap();
assert_eq!(original, restored);
assert_eq!(bytes, restored.encode().unwrap());
let mut changed = restored.clone();
changed.entries[0].provenance.license = "CC0-1.0".into();
assert!(changed.validate().is_err());
changed.entries[0].revision = changed.entries[0].content_revision().unwrap();
assert_ne!(changed.entries[0].revision, original.entries[0].revision);
changed.validate().unwrap();
if let Definition::Appearance(look) = &mut changed.entries[0].definition {
look.color = [1, 2, 3];
}
assert!(changed.validate().is_err());
}
#[test]
fn strict_schema_rejects_unknown_kinds_fields_versions_and_bad_identity() {
let value = serde_json::to_value(catalog()).unwrap();
for changed in [
{
let mut v = value.clone();
v["format"] = json!("solidcraft-library/2");
v
},
{
let mut v = value.clone();
v["entries"][0]["definition"]["kind"] = json!("texture_set");
v
},
{
let mut v = value.clone();
v["entries"][0]["definition"]["value"]["roughness"] = json!(0.5);
v
},
{
let mut v = value.clone();
v["entries"][0]["id"] = json!("other.pack/appearance-a");
v
},
{
let mut v = value.clone();
v["entries"][0]["revision"] = json!("0".repeat(64));
v
},
{
let mut v = value.clone();
v["id"] = json!("USER.TEST");
v
},
{
let mut v = value.clone();
v["unknown"] = json!(true);
v
},
] {
assert!(Catalog::decode(&serde_json::to_vec(&changed).unwrap()).is_err());
}
}
#[test]
fn scalar_validation_refuses_nonfinite_density_opacity_counts_strings_and_oversized_input() {
let mut invalid = catalog();
invalid.entries.push(invalid.entries[0].clone());
assert!(invalid.validate().is_err());
invalid.entries = vec![catalog().entries[0].clone(); MAX_ENTRIES + 1];
assert!(invalid.validate().is_err());
assert!(Catalog::decode(&serde_json::to_vec(&invalid).unwrap()).is_err());
let entry = catalog().entries.remove(0);
let mut boundary = catalog();
boundary.entries = (0..MAX_ENTRIES).map(|i| Entry { id: format!("user.test/entry-{i}"), ..entry.clone() }).collect();
Catalog::decode(&boundary.encode().unwrap()).unwrap();
boundary.entries.push(Entry { id: "user.test/one-more".into(), ..entry });
assert!(Catalog::decode(&serde_json::to_vec(&boundary).unwrap()).is_err());
for opacity in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
let mut entry = catalog().entries.remove(0);
if let Definition::Appearance(look) = &mut entry.definition {
look.opacity = opacity;
}
assert!(entry.content_revision().is_err());
}
for density in [0.0, -1.0, 1000.1, f64::NAN, f64::INFINITY] {
assert!(
Entry::new(
"user.test",
Definition::Material(ScalarMaterial { name: "Timber".into(), density_g_cm3: density, appearance: None }),
Provenance::default()
)
.is_err()
);
}
for name in ["".into(), "x".repeat(129), "bad\nname".into(), " leading".into()] {
assert!(Entry::new("user.test", Definition::Appearance(ScalarLook { name, color: [1, 2, 3], opacity: 1.0 }), Provenance::default()).is_err());
}
assert!(Catalog::decode(&vec![b' '; MAX_BYTES + 1]).is_err());
let source = Provenance { source_url: Some("x".repeat(2049)), ..Default::default() };
assert!(Entry::new("user.test", catalog().entries[0].definition.clone(), source).is_err());
}
#[test]
fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct() {
let mut doc = Document::default();
let look = ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }.to_look();
doc.appearances.bodies.insert("Body1".into(), look.clone());
doc.appearances.bodies.insert("Body2".into(), look.clone());
doc.user_materials.push(UserMaterial { name: "Timber density".into(), density: 0.7, appearance: Some(look) });
let library = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap();
assert_eq!(library.entries.len(), 2);
assert!(library.entries.iter().any(|e| matches!(&e.definition,Definition::Material(m) if m.density_g_cm3==0.7)));
assert_eq!(doc.user_materials[0].density, 0.7);
assert_eq!(library, Catalog::decode(&library.encode().unwrap()).unwrap());
assert!(Catalog::from_document(&Document::default(), "user.test", "Test", Provenance { license: String::new(), ..Default::default() }).is_err());
}
+1
View File
@@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod catalog;
mod design;
mod design_metadata;
mod dxf;
+59
View File
@@ -129,6 +129,29 @@ pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result<
write_steps(&resolve_links(path)?, bytes, backup, None)
}
/// Publish a complete new file without replacing an existing destination, even if another
/// writer creates it after this call starts. Unsupported filesystems return an error.
pub fn create_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if cfg!(target_arch = "wasm32") {
let name = path.to_string_lossy();
if crate::vfs::exists(&name) {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "destination already exists"));
}
return crate::vfs::write(&name, bytes);
}
let tmp = temp_beside(path, "create");
// Only remove a temporary file that this invocation successfully created.
let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
let result = file.write_all(bytes).and_then(|_| file.sync_all());
drop(file);
let result = result.and_then(|_| fs::hard_link(&tmp, path));
let _ = fs::remove_file(&tmp);
if result.is_ok() {
sync_dir(path);
}
result
}
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`
/// (next to the file a link at `path` points to, where a save puts them).
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
@@ -150,6 +173,42 @@ pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
mod tests {
use super::*;
#[test]
fn create_does_not_replace_an_existing_destination() {
let d = dir("create");
let p = d.join("render.png");
create_atomic(&p, b"first complete image").unwrap();
assert_eq!(create_atomic(&p, b"replacement").unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&p).unwrap(), b"first complete image");
assert!(stale_temps(&p).is_empty());
fs::remove_dir_all(d).unwrap();
}
#[test]
fn concurrent_creators_publish_exactly_one_whole_file() {
let d = dir("create-race");
let p = d.join("render.png");
let gate = std::sync::Arc::new(std::sync::Barrier::new(2));
let workers: Vec<_> = [b"first image".as_slice(), b"second image".as_slice()]
.into_iter()
.map(|bytes| {
let gate = gate.clone();
let p = p.clone();
std::thread::spawn(move || {
gate.wait();
create_atomic(&p, bytes)
})
})
.collect();
let results: Vec<_> = workers.into_iter().map(|w| w.join().unwrap()).collect();
assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
assert_eq!(results.iter().filter_map(|r| r.as_ref().err()).next().unwrap().kind(), std::io::ErrorKind::AlreadyExists);
let bytes = fs::read(&p).unwrap();
assert!(bytes == b"first image" || bytes == b"second image");
assert!(stale_temps(&p).is_empty());
fs::remove_dir_all(d).unwrap();
}
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-safe-write-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
+4 -1
View File
@@ -93,6 +93,7 @@ pub fn hex(c: [u8; 3]) -> String {
#[derive(Clone, Debug)]
pub struct Ap {
pub local: crate::dialogs_library::LocalLibrary,
/// Apply to faces instead of bodies.
pub faces: bool,
pub color: [u8; 3],
@@ -136,6 +137,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
});
(
Kind::Appearance(Ap {
local: Default::default(),
faces,
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
@@ -197,7 +199,7 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
}
fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
} else {
@@ -382,6 +384,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
}
});
ui.end_row();
crate::dialogs_library::rows(app, ui, k, inputs);
// In this design.
let used = in_design(app);
if !used.is_empty() {
+187
View File
@@ -0,0 +1,187 @@
//! The first local appearance-library consumer, inside the existing Appearance panel.
#[cfg(not(target_arch = "wasm32"))]
use crate::dialogs_appearance::selected_params;
use crate::{SolveApp, dialogs::row_label, dialogs_appearance::Ap, selection::SelInput, theme::Tokens};
use egui::RichText;
use serde_json::json;
use solidcraft_engine::io::catalog::Catalog;
#[cfg(not(target_arch = "wasm32"))]
use solidcraft_engine::io::catalog::Definition;
#[derive(Clone, Debug, Default)]
pub struct LocalLibrary {
pub search: String,
pub path: String,
pub catalog: Option<Catalog>,
pub selected: Option<String>,
pub overwrite: bool,
}
fn display_order(mut catalog: Catalog) -> Catalog {
catalog.entries.sort_by(|a, b| a.definition.name().cmp(b.definition.name()).then_with(|| a.id.cmp(&b.id)));
catalog
}
#[cfg(not(target_arch = "wasm32"))]
fn load(app: &mut SolveApp, library: &mut LocalLibrary) -> Result<String, String> {
let result = app.run("library.inspect", json!({"path":library.path}))?;
let catalog: Catalog = serde_json::from_value(result.get("catalog").cloned().ok_or("Library response is missing")?).map_err(|e| e.to_string())?;
let note = format!("Opened {} ({} entries)", catalog.name, catalog.entries.len());
library.catalog = Some(display_order(catalog));
library.selected = None;
library.search.clear();
Ok(note)
}
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInput]) {
row_label(ui, "Local library");
ui.vertical(|ui| {
#[cfg(not(target_arch = "wasm32"))]
import_rows(app, ui, k, inputs);
#[cfg(target_arch = "wasm32")]
{
let _ = inputs;
ui.label("Open local libraries in the desktop app. You can export this design's library here.");
}
ui.checkbox(&mut k.local.overwrite, crate::i18n::tr_label("Replace existing library file"));
let save = ui.button(crate::i18n::tr_label("Export design library..."));
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
let chosen = match app.services.pick_save.as_ref() {
Some(pick) => pick("my-library.solidcraft-library.json", &["json"]),
None => (!k.local.path.is_empty()).then(|| k.local.path.clone()),
};
if let Some(path) = chosen {
k.note = Some(match app.run("library.export", json!({"path":path,"overwrite":k.local.overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
format!("Saved {count} reusable definitions. The design is unchanged.")
}
Err(error) => error,
});
}
}
ui.label(
RichText::new("Apply your appearance first. Exports assigned appearances and materials created in this design.")
.small()
.color(Tokens::get().text_dim),
);
});
ui.end_row();
}
#[cfg(not(target_arch = "wasm32"))]
fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInput]) {
let path = ui.add(egui::TextEdit::singleline(&mut k.local.path).hint_text("Library file").desired_width(200.0));
crate::scenario::publish_handle("library:path", path.rect.center());
if path.changed() {
k.local.catalog = None;
k.local.selected = None;
}
ui.horizontal(|ui| {
if ui.button(crate::i18n::tr_label("Open library...")).clicked()
&& let Some(path) = app.services.pick_open.as_ref().and_then(|pick| pick())
{
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
}
let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library")));
crate::scenario::publish_handle("library:load", load_button.rect.center());
if load_button.clicked() {
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
}
});
if let Some(catalog) = &k.local.catalog {
ui.label(RichText::new(&catalog.name).strong());
let search = ui.add(egui::TextEdit::singleline(&mut k.local.search).hint_text("Search this library").desired_width(200.0));
crate::scenario::publish_handle("library:search", search.rect.center());
if search.changed() {
k.local.selected = None;
}
let query = k.local.search.trim().to_lowercase();
let appearances: Vec<_> = catalog
.entries
.iter()
.filter(|entry| matches!(&entry.definition, Definition::Appearance(_)) && entry.definition.name().to_lowercase().contains(&query))
.collect();
if appearances.is_empty() {
ui.label("No matching appearance presets.");
}
let row_height = ui.spacing().interact_size.y;
egui::ScrollArea::vertical().id_salt("local_library_entries").max_height(120.0).show_rows(ui, row_height, appearances.len(), |ui, range| {
for index in range {
let Some(entry) = appearances.get(index) else { continue };
if let Definition::Appearance(look) = &entry.definition {
ui.horizontal(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
let [r, g, b] = look.color;
ui.colored_label(egui::Color32::from_rgb(r, g, b), "■");
let button = ui.selectable_label(k.local.selected.as_deref() == Some(entry.id.as_str()), &look.name).on_hover_text(format!(
"Opacity {:.0}% · {} · {}",
look.opacity * 100.0,
entry.provenance.license,
entry.provenance.source_url.as_deref().unwrap_or("Local definition")
));
crate::scenario::publish_handle(&format!("library:entry:{index}"), button.rect.center());
if button.clicked() {
k.local.selected = Some(entry.id.clone());
}
});
}
}
});
}
let entry = k
.local
.catalog
.as_ref()
.and_then(|catalog| {
catalog
.entries
.iter()
.find(|entry| Some(&entry.id) == k.local.selected.as_ref() && matches!(&entry.definition, Definition::Appearance(_)))
})
.cloned();
let picked = inputs.first().map(|input| input.items.as_slice()).unwrap_or(&[]);
let selected = selected_params(k, picked);
let apply = ui.add_enabled(entry.is_some() && selected.is_some(), egui::Button::new(crate::i18n::tr_label("Apply library appearance")));
crate::scenario::publish_handle("library:apply", apply.rect.center());
if apply.clicked()
&& let (Some(entry), Some(selected)) = (entry, selected)
{
let mut targets = serde_json::Map::new();
for key in ["bodies", "faces", "components"] {
if let Some(value) = selected.get(key) {
targets.insert(key.into(), value.clone());
}
}
k.note = Some(match app.run("library.apply", json!({"path":k.local.path,"entry":entry.id,"revision":entry.revision,"targets":targets})) {
Ok(_) => {
if let Definition::Appearance(look) = entry.definition {
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
k.applied = selected_params(k, picked);
format!("{} copied into this design", look.name)
} else {
"This panel applies appearance presets only".into()
}
}
Err(error) => error,
});
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "dialogs_library_tests.rs"]
mod tests;
@@ -0,0 +1,41 @@
use super::*;
use crate::{dialogs::Kind, scenario::Harness};
use solidcraft_engine::{Sel, Session};
#[test]
fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
let path = h.dir.join("source-library.json").to_string_lossy().into_owned();
let mut source = Session::default();
source.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
source.execute("library.export", &json!({"path":path})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20]})).unwrap();
h.app.session.mark_saved();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
if let Some(dialog) = &mut h.app.dialog
&& let Kind::Appearance(state) = &mut dialog.kind
{
state.local.path = path;
}
h.frames(3);
let undo = h.app.session.undo.len();
h.step(&json!({"widget":"Load library"})).unwrap();
h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap();
h.step(&json!({"widget":"Apply library appearance"})).unwrap();
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert_eq!(h.app.session.undo.len(), undo + 1);
let revision = h.app.session.revision;
h.step(&json!({"widget":"Export design library..."})).unwrap();
let exported = h.dir.join("my-library.solidcraft-library.json");
let catalog = Catalog::read(&exported.to_string_lossy()).unwrap();
assert_eq!(catalog.entries.len(), 1);
assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour");
assert_eq!(h.app.session.revision, revision);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
}
+1
View File
@@ -19,6 +19,7 @@ pub mod delete;
pub mod dialogs;
pub mod dialogs_appearance;
pub mod dialogs_assembly;
pub mod dialogs_library;
pub mod dialogs_motion;
pub mod dialogs_move;
pub mod dialogs_parts;
+1
View File
@@ -22,6 +22,7 @@
- [Contributing](dev/contributing.md)
- [Architecture](dev/architecture.md)
- [Extensible library proposal](dev/libraries.md)
- [Local scalar libraries](dev/local-libraries.md)
- [The command engine](dev/commands.md)
- [The kernel boundary and truck](dev/kernel.md)
- [Persistent naming](dev/naming.md)
+5
View File
@@ -1,5 +1,10 @@
# Extensible libraries: materials, appearances and components
The first [local scalar-library consumer](local-libraries.md) implements only copied
colour/opacity and density/look definitions. Its separate v1 catalog is not the complete
resource-pack/provenance/update system proposed below. Texture/environment descriptors
remain coordinated with the maintainer's renderer work.
Proposal for [#785](https://github.com/bherbruck/solidcraft/issues/785),
[#786](https://github.com/bherbruck/solidcraft/issues/786) and
[#787](https://github.com/bherbruck/solidcraft/issues/787). This page describes planned
+125
View File
@@ -0,0 +1,125 @@
# Local scalar libraries
The first local-library slice (#846) reuses the existing appearance and physical
material commands. It is a small consumer of the broader [library proposal](libraries.md),
not the complete resource-pack implementation. It adds no texture mapping, wood grain,
PBR shader, HDRI lighting, online discovery or document-format version.
## Desktop workflow
1. Apply a named colour/opacity look using **Appearance**, or define a physical material
using the existing material commands. An appearance does not change physical density.
2. In Appearance → **Local library**, choose **Export design library...**. The snapshot
contains explicit body/face/component appearance overrides and design-local physical
materials, including their default looks. It does not export imported kernel paint,
unassigned built-in catalog entries or an unapplied colour-picker draft.
3. Open that library in another design, search its appearance presets, select one and
choose **Apply library appearance**. The normal body/face/component targets are reused.
OK after this immediate application does not add a second undo step.
4. Save the design. Its selected scalar definitions are ordinary copied design data;
the source library can be moved or removed without changing the saved result.
Export does not edit the design, its dirty state or history. An existing file is refused
unless **Replace existing library file** is explicitly selected. Export is a snapshot of
the design definitions, not an automatic merge with entries in an existing catalog.
Editing a copied look/material uses the normal commands and does not edit its source.
The desktop panel applies appearances. Physical-material import/assignment is available
through `library.apply`; imported definitions also appear in the existing material list.
The browser can export a library through the existing download service. Interactive local
library opening is a desktop feature in this first slice. Browser engine commands can
consume a bounded catalog already supplied through VFS; wasm compilation does not prove
an interactive browser importer or download workflow.
## Commands
All programmatic calls use JSON and never open a file picker:
```json
{"command":"library.export","params":{"path":"my-library.solidcraft-library.json"}}
{"command":"library.inspect","params":{"path":"my-library.solidcraft-library.json"}}
```
Inspection/export returns a `catalog` with qualified entry IDs and exact revisions. Use
the returned values when applying; do not replace an unknown revision with a guessed one:
```json
{"command":"library.apply","params":{
"path":"my-library.solidcraft-library.json",
"entry":"user.local/appearance-<returned-id>",
"revision":"<returned-64-character-revision>",
"targets":{"bodies":["Body1"]}
}}
```
Appearance targets can be `bodies`, `components` or `faces: [{body, point: [x,y,z]}]`.
Points are world-space millimetres. At most 1,024 total targets are accepted. Missing
objects and invalid targets fail; the importer does not invent a nearby replacement.
A material entry can be copied without targets or assigned to body targets. The optional
`name` explicitly selects a new physical-material name on import. An identical existing
definition is reused; a conflicting definition or built-in name is refused. No density,
look or configuration cell is silently overwritten. Creation and assignment share one
outer undo transaction through the existing material commands. Mass remains the existing
volume × density calculation; a generic illustrative density is not an engineering grade.
`library.export` also accepts `id` (default `user.local`), `name` (default `My library`),
`provenance` and `overwrite` (default false). Only explicit assignments/design-local
definitions are inspected, without evaluating geometry. Duplicate exact definitions are
deduplicated; different values with the same display name remain distinct entries.
## Scalar catalog v1
The independent JSON format marker is `solidcraft-library/1`. It has `id`, `name` and an
`entries` array. Each entry has `id`, `revision`, `provenance` and a typed `definition`:
- `appearance`: `value: {name, color: [r,g,b], opacity}`; sRGB bytes and finite opacity 0–1.
- `material`: `value: {name, density_g_cm3, appearance?}`; finite density above 0 and at most
1,000 g/cm³, with an optional appearance value of the same scalar shape.
Every object rejects unknown fields. Unsupported kinds (including texture/environment
payloads) and versions fail explicitly, rather than dropping fields or pretending that a
colour preview is a textured material. Catalog IDs are 1–64 ASCII bytes, beginning with a
lowercase letter and continuing with lowercase letters, digits, `.`, `_` or `-`. An entry
ID is `<catalog-id>/<local-id>` with the same local-ID rules; paths and labels are not IDs.
An entry revision is lowercase SHA-256 of compact v1 serde JSON `[provenance, definition]`.
Derived struct field order, omission of absent optional fields, UTF-8 escaping and f64
round-trip number formatting are part of this writer contract; this is not arbitrary JSON
canonicalization. Prefer `library.export` or the Rust `Entry::new`/`content_revision` API
when authoring revisions. A future incompatible writer changes the catalog version. The
default export IDs contain a kind and 16 hex revision characters; collisions are rejected,
never used to overwrite a different definition. Explicitly authored stable local IDs can
retain their identity across revisions. Application requires the selected exact revision.
Changing another entry does not change the selected definition's identity.
Provenance contains a required `license`, optional `author` and optional `source_url`.
It is supplied metadata, not verified permission or a publisher signature. URLs are never
fetched. The default `LicenseRef-User-Provided` does not claim that user data is openly
licensed. No external art/data is bundled in this slice. Catalog provenance/revisions stay
in the catalog: the first consumer copies scalar values into existing `Look`/`UserMaterial`
records, without a persistent catalog-origin/update receipt. Full resource embedding,
design-owned provenance, scopes and explicit update links remain proposed follow-ups.
## Bounds and failure behavior
The reader accepts at most 4 MiB encoded JSON and 1,024 entries. Entry decoding stops at
the count cap with fallible vector growth. Names are trimmed, nonempty and at most 128
UTF-8 bytes; provenance license/author/source URL limits are 128/256/2,048 bytes. Control
characters, invalid identifiers, duplicate identities, bad revisions and nonfinite scalar
values fail. Export inspects at most 100,000 source references, deduplicating into at most
1,024 entries. These are encoded/work limits, not a process-memory or latency guarantee.
Native reads are bounded regular-file reads; browser reads check VFS size before cloning.
Native no-clobber writes reuse atomic new-file publication; explicit replacement is atomic
without reading/backing up an arbitrarily large old target. All library command paths
obey the active MCP/control file scope before reading or publishing, including overwrite. Browser VFS publication is
synchronous and reuses the host's download service. No automatic discovery, asset fetch,
relative-resource resolution, executable recipe or plugin is introduced.
Failed application preserves the document and undo/redo history. The source file is read
and checked before copying; a stale selected revision needs an explicit reload/reselection.
The copied design remains independent after successful assignment, undo/redo and cold
reopening. This library has no dependency on the pending render stack and does not change
the document format. User-visible evidence and actual native/wasm results are recorded in the PR;
geometry/robustness checks remain separate and no reference baseline is weakened.
+9 -1
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **347 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **350 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (99)
@@ -434,6 +434,14 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|---|---|---|
| Delete Face | `face.delete` | SOLID › MODIFY |
## `library.*` (3)
| Command | Id | Toolbar |
|---|---|---|
| Inspect Local Library | `library.inspect` | – |
| Export Design Library | `library.export` | – |
| Apply Local Library Entry | `library.apply` | – |
## `material.*` (7)
| Command | Id | Toolbar |