Local appearance and material libraries (#863)

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

No files matched your search

+4 -1
View File
@@ -93,6 +93,7 @@ pub fn hex(c: [u8; 3]) -> String {
#[derive(Clone, Debug)]
pub struct Ap {
pub local: crate::dialogs_library::LocalLibrary,
/// Apply to faces instead of bodies.
pub faces: bool,
pub color: [u8; 3],
@@ -136,6 +137,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
});
(
Kind::Appearance(Ap {
local: Default::default(),
faces,
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
@@ -197,7 +199,7 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
}
fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
} else {
@@ -382,6 +384,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
}
});
ui.end_row();
crate::dialogs_library::rows(app, ui, k, inputs);
// In this design.
let used = in_design(app);
if !used.is_empty() {
+187
View File
@@ -0,0 +1,187 @@
//! The first local appearance-library consumer, inside the existing Appearance panel.
#[cfg(not(target_arch = "wasm32"))]
use crate::dialogs_appearance::selected_params;
use crate::{SolveApp, dialogs::row_label, dialogs_appearance::Ap, selection::SelInput, theme::Tokens};
use egui::RichText;
use serde_json::json;
use solidcraft_engine::io::catalog::Catalog;
#[cfg(not(target_arch = "wasm32"))]
use solidcraft_engine::io::catalog::Definition;
#[derive(Clone, Debug, Default)]
pub struct LocalLibrary {
pub search: String,
pub path: String,
pub catalog: Option<Catalog>,
pub selected: Option<String>,
pub overwrite: bool,
}
fn display_order(mut catalog: Catalog) -> Catalog {
catalog.entries.sort_by(|a, b| a.definition.name().cmp(b.definition.name()).then_with(|| a.id.cmp(&b.id)));
catalog
}
#[cfg(not(target_arch = "wasm32"))]
fn load(app: &mut SolveApp, library: &mut LocalLibrary) -> Result<String, String> {
let result = app.run("library.inspect", json!({"path":library.path}))?;
let catalog: Catalog = serde_json::from_value(result.get("catalog").cloned().ok_or("Library response is missing")?).map_err(|e| e.to_string())?;
let note = format!("Opened {} ({} entries)", catalog.name, catalog.entries.len());
library.catalog = Some(display_order(catalog));
library.selected = None;
library.search.clear();
Ok(note)
}
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInput]) {
row_label(ui, "Local library");
ui.vertical(|ui| {
#[cfg(not(target_arch = "wasm32"))]
import_rows(app, ui, k, inputs);
#[cfg(target_arch = "wasm32")]
{
let _ = inputs;
ui.label("Open local libraries in the desktop app. You can export this design's library here.");
}
ui.checkbox(&mut k.local.overwrite, crate::i18n::tr_label("Replace existing library file"));
let save = ui.button(crate::i18n::tr_label("Export design library..."));
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
let chosen = match app.services.pick_save.as_ref() {
Some(pick) => pick("my-library.solidcraft-library.json", &["json"]),
None => (!k.local.path.is_empty()).then(|| k.local.path.clone()),
};
if let Some(path) = chosen {
k.note = Some(match app.run("library.export", json!({"path":path,"overwrite":k.local.overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
format!("Saved {count} reusable definitions. The design is unchanged.")
}
Err(error) => error,
});
}
}
ui.label(
RichText::new("Apply your appearance first. Exports assigned appearances and materials created in this design.")
.small()
.color(Tokens::get().text_dim),
);
});
ui.end_row();
}
#[cfg(not(target_arch = "wasm32"))]
fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInput]) {
let path = ui.add(egui::TextEdit::singleline(&mut k.local.path).hint_text("Library file").desired_width(200.0));
crate::scenario::publish_handle("library:path", path.rect.center());
if path.changed() {
k.local.catalog = None;
k.local.selected = None;
}
ui.horizontal(|ui| {
if ui.button(crate::i18n::tr_label("Open library...")).clicked()
&& let Some(path) = app.services.pick_open.as_ref().and_then(|pick| pick())
{
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
}
let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library")));
crate::scenario::publish_handle("library:load", load_button.rect.center());
if load_button.clicked() {
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
}
});
if let Some(catalog) = &k.local.catalog {
ui.label(RichText::new(&catalog.name).strong());
let search = ui.add(egui::TextEdit::singleline(&mut k.local.search).hint_text("Search this library").desired_width(200.0));
crate::scenario::publish_handle("library:search", search.rect.center());
if search.changed() {
k.local.selected = None;
}
let query = k.local.search.trim().to_lowercase();
let appearances: Vec<_> = catalog
.entries
.iter()
.filter(|entry| matches!(&entry.definition, Definition::Appearance(_)) && entry.definition.name().to_lowercase().contains(&query))
.collect();
if appearances.is_empty() {
ui.label("No matching appearance presets.");
}
let row_height = ui.spacing().interact_size.y;
egui::ScrollArea::vertical().id_salt("local_library_entries").max_height(120.0).show_rows(ui, row_height, appearances.len(), |ui, range| {
for index in range {
let Some(entry) = appearances.get(index) else { continue };
if let Definition::Appearance(look) = &entry.definition {
ui.horizontal(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
let [r, g, b] = look.color;
ui.colored_label(egui::Color32::from_rgb(r, g, b), "■");
let button = ui.selectable_label(k.local.selected.as_deref() == Some(entry.id.as_str()), &look.name).on_hover_text(format!(
"Opacity {:.0}% · {} · {}",
look.opacity * 100.0,
entry.provenance.license,
entry.provenance.source_url.as_deref().unwrap_or("Local definition")
));
crate::scenario::publish_handle(&format!("library:entry:{index}"), button.rect.center());
if button.clicked() {
k.local.selected = Some(entry.id.clone());
}
});
}
}
});
}
let entry = k
.local
.catalog
.as_ref()
.and_then(|catalog| {
catalog
.entries
.iter()
.find(|entry| Some(&entry.id) == k.local.selected.as_ref() && matches!(&entry.definition, Definition::Appearance(_)))
})
.cloned();
let picked = inputs.first().map(|input| input.items.as_slice()).unwrap_or(&[]);
let selected = selected_params(k, picked);
let apply = ui.add_enabled(entry.is_some() && selected.is_some(), egui::Button::new(crate::i18n::tr_label("Apply library appearance")));
crate::scenario::publish_handle("library:apply", apply.rect.center());
if apply.clicked()
&& let (Some(entry), Some(selected)) = (entry, selected)
{
let mut targets = serde_json::Map::new();
for key in ["bodies", "faces", "components"] {
if let Some(value) = selected.get(key) {
targets.insert(key.into(), value.clone());
}
}
k.note = Some(match app.run("library.apply", json!({"path":k.local.path,"entry":entry.id,"revision":entry.revision,"targets":targets})) {
Ok(_) => {
if let Definition::Appearance(look) = entry.definition {
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
k.applied = selected_params(k, picked);
format!("{} copied into this design", look.name)
} else {
"This panel applies appearance presets only".into()
}
}
Err(error) => error,
});
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "dialogs_library_tests.rs"]
mod tests;
@@ -0,0 +1,41 @@
use super::*;
use crate::{dialogs::Kind, scenario::Harness};
use solidcraft_engine::{Sel, Session};
#[test]
fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
let path = h.dir.join("source-library.json").to_string_lossy().into_owned();
let mut source = Session::default();
source.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
source.execute("library.export", &json!({"path":path})).unwrap();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20]})).unwrap();
h.app.session.mark_saved();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
if let Some(dialog) = &mut h.app.dialog
&& let Kind::Appearance(state) = &mut dialog.kind
{
state.local.path = path;
}
h.frames(3);
let undo = h.app.session.undo.len();
h.step(&json!({"widget":"Load library"})).unwrap();
h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap();
h.step(&json!({"widget":"Apply library appearance"})).unwrap();
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour");
assert_eq!(h.app.session.undo.len(), undo + 1);
let revision = h.app.session.revision;
h.step(&json!({"widget":"Export design library..."})).unwrap();
let exported = h.dir.join("my-library.solidcraft-library.json");
let catalog = Catalog::read(&exported.to_string_lossy()).unwrap();
assert_eq!(catalog.entries.len(), 1);
assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour");
assert_eq!(h.app.session.revision, revision);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
}
+1
View File
@@ -19,6 +19,7 @@ pub mod delete;
pub mod dialogs;
pub mod dialogs_appearance;
pub mod dialogs_assembly;
pub mod dialogs_library;
pub mod dialogs_motion;
pub mod dialogs_move;
pub mod dialogs_parts;