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

+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());