From d399effbf72e11b3383086e88c5228b594d82b39 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:21:35 +0200 Subject: [PATCH] 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. --- ROADMAP.md | 6 + assets/locales/de.json | 3 + assets/locales/en.json | 3 + assets/locales/ja.json | 3 + assets/locales/ko.json | 3 + assets/locales/zh.json | 3 + crates/engine/src/cmd/libraries.rs | 199 ++++++++++++++ crates/engine/src/cmd/libraries_tests.rs | 220 +++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/io/src/catalog.rs | 285 ++++++++++++++++++++ crates/io/src/catalog_tests.rs | 125 +++++++++ crates/io/src/lib.rs | 1 + crates/io/src/safe_write.rs | 59 ++++ crates/ui-egui/src/dialogs_appearance.rs | 5 +- crates/ui-egui/src/dialogs_library.rs | 187 +++++++++++++ crates/ui-egui/src/dialogs_library_tests.rs | 41 +++ crates/ui-egui/src/lib.rs | 1 + docs/book/src/SUMMARY.md | 1 + docs/book/src/dev/libraries.md | 5 + docs/book/src/dev/local-libraries.md | 125 +++++++++ docs/features.md | 10 +- 21 files changed, 1285 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/libraries.rs create mode 100644 crates/engine/src/cmd/libraries_tests.rs create mode 100644 crates/io/src/catalog.rs create mode 100644 crates/io/src/catalog_tests.rs create mode 100644 crates/ui-egui/src/dialogs_library.rs create mode 100644 crates/ui-egui/src/dialogs_library_tests.rs create mode 100644 docs/book/src/dev/local-libraries.md diff --git a/ROADMAP.md b/ROADMAP.md index f3806a18..ff48168e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/assets/locales/de.json b/assets/locales/de.json index 990c85cf..383b5f09 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index d8c3fc3e..05a2638b 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 393609d9..6a866338 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "壁の位置", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 71bb47cf..00ceef15 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "벽 위치", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index eab47bf9..c18e737c 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "壁位置", diff --git a/crates/engine/src/cmd/libraries.rs b/crates/engine/src/cmd/libraries.rs new file mode 100644 index 00000000..585d5f10 --- /dev/null +++ b/crates/engine/src/cmd/libraries.rs @@ -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, + #[serde(default)] + faces: Vec, + #[serde(default)] + components: Vec, +} +#[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, +} +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 { + 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 { + 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 { + 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 { + 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", ¶ms)?; + // 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; diff --git a/crates/engine/src/cmd/libraries_tests.rs b/crates/engine/src/cmd/libraries_tests.rs new file mode 100644 index 00000000..c18acfdb --- /dev/null +++ b/crates/engine/src/cmd/libraries_tests.rs @@ -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 { + 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", ¶ms).unwrap(); + assert_eq!(s.doc.user_materials.len(), 2); + let undo = s.undo.len(); + let revision = s.revision; + let again = s.execute("library.apply", ¶ms).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()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a01444c5..c914455f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/io/src/catalog.rs b/crates/io/src/catalog.rs new file mode 100644 index 00000000..9e77fd09 --- /dev/null +++ b/crates/io/src/catalog.rs @@ -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) -> 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, + /// Information only: never fetched or treated as a local resource path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_url: Option, +} +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, +} +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 { + 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 { + 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, +} + +fn bounded_entries<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result, D::Error> { + struct Entries; + impl<'de> serde::de::Visitor<'de> for Entries { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("at most 1024 scalar library entries") + } + fn visit_seq>(self, mut sequence: A) -> std::result::Result { + let mut entries = Vec::new(); + loop { + if entries.len() == MAX_ENTRIES { + if sequence.next_element::()?.is_some() { + return Err(serde::de::Error::custom("scalar library exceeds 1024 entries")); + } + break; + } + let Some(entry) = sequence.next_element::()? 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 { + 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> { + 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 { + // 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 { + 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; diff --git a/crates/io/src/catalog_tests.rs b/crates/io/src/catalog_tests.rs new file mode 100644 index 00000000..7af43c12 --- /dev/null +++ b/crates/io/src/catalog_tests.rs @@ -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()); +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c4d62eb9..1ecc1fbb 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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; diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs index dc294da3..e8c4ec8d 100644 --- a/crates/io/src/safe_write.rs +++ b/crates/io/src/safe_write.rs @@ -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 { @@ -150,6 +173,42 @@ pub fn stale_temps(path: &Path) -> Vec { 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); diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index c1ec64d7..f1b17e25 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -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)> { }); ( 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 { +pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option { 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() { diff --git a/crates/ui-egui/src/dialogs_library.rs b/crates/ui-egui/src/dialogs_library.rs new file mode 100644 index 00000000..ab16f203 --- /dev/null +++ b/crates/ui-egui/src/dialogs_library.rs @@ -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, + pub selected: Option, + 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 { + 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::(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; diff --git a/crates/ui-egui/src/dialogs_library_tests.rs b/crates/ui-egui/src/dialogs_library_tests.rs new file mode 100644 index 00000000..ad299947 --- /dev/null +++ b/crates/ui-egui/src/dialogs_library_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4a378f43..c6115a29 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 3ed2352d..beec7a80 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -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) diff --git a/docs/book/src/dev/libraries.md b/docs/book/src/dev/libraries.md index 5f61101f..6b9e4be1 100644 --- a/docs/book/src/dev/libraries.md +++ b/docs/book/src/dev/libraries.md @@ -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 diff --git a/docs/book/src/dev/local-libraries.md b/docs/book/src/dev/local-libraries.md new file mode 100644 index 00000000..65f0f4e8 --- /dev/null +++ b/docs/book/src/dev/local-libraries.md @@ -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-", + "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 `/` 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. diff --git a/docs/features.md b/docs/features.md index 19672721..85ac59b6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 ''`, 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 ''`, 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 |