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
@@ -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;
|
||||
Reference in new issue
Block a user