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:
1 parent
8ea1fd643f
commit
d399effbf7
21 files changed
+1285
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "壁の位置",
|
||||
|
||||
@@ -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": "벽 위치",
|
||||
|
||||
@@ -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": "壁位置",
|
||||
|
||||
@@ -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", ¶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;
|
||||
@@ -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", ¶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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user