Files
OneCAD/crates/ui-egui/src/material_card_review.rs
T
seluneand0xlillith a47e82ba94 ui: review candidate physical material cards without assignment (#1344)
Adds a read-only review window to Validate Physical Material Data: paste a schema-1 card, see its known properties in SI units, unknown ones marked, with stated conditions and source claims. Nothing is assigned or saved.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
2026-10-15 13:55:30 -04:00

323 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Candidate review through the existing validator, independent of assigned-card persistence.
use egui::RichText;
use serde_json::{Value, json};
use solidcraft_engine::doc::physical_material::{Family, MaterialCard, PropertyData, admit_card};
use crate::{SolveApp, i18n::tr, theme::Tokens};
const MAX_BYTES: usize = 256 * 1024;
const KINDS: [&str; 9] = [
"density",
"elasticity",
"tensile_strength",
"compressive_strength",
"yield_strength",
"shear_strength",
"thermal_conductivity",
"thermal_expansion",
"specific_heat",
];
#[derive(Default)]
pub struct ReviewWindow {
input: String,
reviewed: Option<(MaterialCard, Value)>,
error: Option<String>,
}
impl ReviewWindow {
fn review(&mut self, app: &mut SolveApp) {
self.reviewed = None;
let result = (|| {
if self.input.len() > MAX_BYTES {
return Err(tr("ui.material_review.limit").to_string());
}
let value: Value = serde_json::from_str(&self.input).map_err(|e| e.to_string())?;
let report = app.run("material.properties.validate", json!({"card":value}))?;
let card = admit_card(&value).map_err(|e| e.to_string())?;
Ok((card, report))
})();
match result {
Ok(reviewed) => {
self.reviewed = Some(reviewed);
self.error = None;
}
Err(error) => self.error = Some(error),
}
}
}
/// Open only through ui.start; executing the validator never opens a window.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut window) = app.material_card_review.take() else { return };
let mut open = true;
let mut close = false;
crate::frame::window(ctx, tr("ui.material_review.title"), crate::frame::Width::Wide)
.id(egui::Id::new("sc_material_card_review"))
.open(&mut open)
.show(ctx, |ui| {
ui.label(tr("ui.material_review.scope"));
egui::ScrollArea::vertical().max_height((ctx.content_rect().height() - 180.0).max(100.0)).show(ui, |ui| {
ui.label(tr("ui.material_review.input"));
let response = ui.add(
egui::TextEdit::multiline(&mut window.input)
.id(egui::Id::new("sc_material_card_input"))
.desired_width(f32::INFINITY)
.desired_rows(5)
.char_limit(MAX_BYTES),
);
if response.changed() {
window.reviewed = None;
window.error = None;
}
if ui.button(tr("ui.material_review.review")).clicked() {
window.review(app);
}
if let Some(error) = &window.error {
ui.colored_label(Tokens::get().error, error);
}
if let Some((card, report)) = &window.reviewed {
show_card(ui, card, report);
}
});
ui.separator();
close = ui.button(tr("ui.close")).clicked();
});
if open && !close {
app.material_card_review = Some(window);
}
}
fn value(ui: &mut egui::Ui, field: &str, value: Option<String>, unit: &str) {
ui.horizontal_wrapped(|ui| {
ui.monospace(field);
ui.label(value.unwrap_or_else(|| tr("ui.material_review.unknown").to_string()));
ui.label(unit);
});
}
fn numbers(ui: &mut egui::Ui, field: &str, values: &[f64], unit: &str) {
value(ui, field, Some(values.iter().map(|v| format!("{v:e}")).collect::<Vec<_>>().join(", ")), unit);
}
fn show_card(ui: &mut egui::Ui, card: &MaterialCard, report: &Value) {
ui.separator();
ui.heading(&card.designation);
// Schema identifiers are inert metadata, not translated source claims or assigned body axes.
let family = serde_json::to_value(card.family).unwrap_or(Value::Null);
value(ui, "family", family.as_str().map(str::to_string), "");
ui.strong(tr("ui.material_review.properties"));
for kind in KINDS {
let label = format!("ui.material_review.{kind}");
let Some(property) = card.properties.iter().find(|p| p.data.kind() == kind) else {
ui.label(format!("{}: {}", tr(&label), tr("ui.material_review.unknown")));
continue;
};
egui::CollapsingHeader::new(tr(&label)).id_salt(kind).default_open(true).show(ui, |ui| {
show_data(ui, &property.data);
if let Some(axes) = serde_json::to_value(&property.data)
.ok()
.and_then(|d| d.get("axes").or_else(|| d.get("model").and_then(|m| m.get("axes"))).and_then(Value::as_str).map(str::to_string))
{
let axes = if axes == "wood_lrt" { "wood_lrt (1=L, 2=R, 3=T)".to_string() } else { axes };
value(ui, "axes", Some(axes), "");
ui.label(tr("ui.material_review.axes"));
}
ui.strong(tr("ui.material_review.conditions"));
let c = &property.conditions;
value(ui, "temperature_k", c.temperature_k.map(|v| v.to_string()), "K");
value(ui, "state", c.state.clone(), "");
if card.family == Family::Wood || c.moisture_dry_basis_kg_kg.is_some() {
value(ui, "moisture_dry_basis_kg_kg", c.moisture_dry_basis_kg_kg.map(|v| v.to_string()), tr("ui.material_review.moisture_unit"));
}
if card.family == Family::Polymer || c.strain_rate_per_s.is_some() {
value(ui, "strain_rate_per_s", c.strain_rate_per_s.map(|v| v.to_string()), "s⁻¹");
}
ui.strong(tr("ui.material_review.source"));
if let Some(source) = card.sources.iter().find(|s| s.id == property.source_id) {
ui.label(&source.title);
value(ui, "source_id", Some(source.id.clone()), "");
value(ui, "kind", serde_json::to_value(source.kind).ok().and_then(|v| v.as_str().map(str::to_string)), "");
value(ui, "license", source.license.clone(), "");
value(ui, "reference", source.reference.clone(), "");
}
});
}
if let Some(moduli) = report.get("validation").and_then(|v| v.get("isotropic_moduli")).filter(|v| v.is_object()) {
ui.strong(tr("ui.material_review.derived"));
for field in ["shear_pa", "bulk_pa"] {
value(ui, field, moduli.get(field).and_then(Value::as_f64).map(|v| format!("{v:e}")), "Pa");
}
}
ui.label(RichText::new(tr("ui.material_review.claims")).small().color(Tokens::get().text_dim));
}
fn show_data(ui: &mut egui::Ui, data: &PropertyData) {
match data {
PropertyData::Density { kg_m3 } => numbers(ui, "density", &[*kg_m3], "kg/m³"),
PropertyData::Elasticity { model } => match model {
solidcraft_engine::doc::physical_material::Elasticity::Isotropic { young_modulus_pa, poisson_ratio } => {
numbers(ui, "E", &[*young_modulus_pa], "Pa");
numbers(ui, "ν", &[*poisson_ratio], "");
}
solidcraft_engine::doc::physical_material::Elasticity::Orthotropic { young_moduli_pa, shear_moduli_pa, poisson_ratios, .. } => {
numbers(ui, "E1, E2, E3", young_moduli_pa, "Pa");
numbers(ui, "G12, G23, G31", shear_moduli_pa, "Pa");
numbers(ui, "ν12, ν23, ν31", poisson_ratios, "");
}
},
PropertyData::TensileStrength { pa } | PropertyData::CompressiveStrength { pa } | PropertyData::YieldStrength { pa } => {
numbers(ui, "σ", &[*pa], "Pa");
}
PropertyData::ShearStrength { pa } => numbers(ui, "τ", &[*pa], "Pa"),
PropertyData::ThermalConductivity { w_m_k, .. } => numbers(ui, "k1, k2, k3", w_m_k, "W/(m·K)"),
PropertyData::ThermalExpansion { per_k, .. } => numbers(ui, "α1, α2, α3", per_k, "K⁻¹"),
PropertyData::SpecificHeat { j_kg_k } => numbers(ui, "c", &[*j_kg_k], "J/(kg·K)"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::Harness;
use std::sync::Arc;
fn candidate() -> Value {
json!({"schema_version":1,"designation":"Original synthetic candidate","family":"wood",
"sources":[{"id":"example","title":"Original illustrative <b>source</b>","kind":"illustrative",
"license":"Unverified licence claim","reference":"https://invalid.example/not-fetched"}],
"properties":[{"source_id":"example","data":{"kind":"elasticity","model":{
"symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}},
"conditions":{"temperature_k":293.15,"moisture_dry_basis_kg_kg":0.,"state":"synthetic specimen"}}]})
}
#[test]
fn clicked_candidate_review_and_close_preserve_design_history_and_unknowns() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
let doc = h.app.session.doc.clone();
let before = (
h.app.session.revision,
h.app.session.model.is_stale(),
h.app.session.undo.len(),
h.app.session.redo.len(),
h.app.session.selection.clone(),
);
h.step(&json!({"start":"material.properties.validate"})).unwrap();
h.app.material_card_review.as_mut().unwrap().input = candidate().to_string();
h.frames(2);
h.step(&json!({"widget":"Review card"})).unwrap();
let (card, report) = h.app.material_card_review.as_ref().unwrap().reviewed.as_ref().unwrap();
assert_eq!(card.designation, "Original synthetic candidate");
assert_eq!(card.properties.len(), 1, "missing values were not filled with zeros");
assert_eq!(card.sources[0].title, "Original illustrative <b>source</b>");
assert_eq!(card.properties[0].conditions.moisture_dry_basis_kg_kg, Some(0.));
assert_eq!(report["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9}));
assert_eq!(report["validation"]["source_review_required"], json!(["example"]));
assert!(h.widget("Original synthetic candidate").is_some());
assert!(h.widget("Density: Unknown").is_some());
assert!(h.widget("Pa").is_some());
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("sc_material_card_input")));
h.step(&json!({"text":" "})).unwrap();
assert!(h.app.material_card_review.as_ref().unwrap().reviewed.is_none(), "editing invalidates the old report");
h.step(&json!({"widget":"Close"})).unwrap();
assert!(h.app.material_card_review.is_none());
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(
before,
(
h.app.session.revision,
h.app.session.model.is_stale(),
h.app.session.undo.len(),
h.app.session.redo.len(),
h.app.session.selection.clone()
)
);
}
#[test]
fn invalid_and_oversized_candidates_never_keep_a_previous_review() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let doc = app.session.doc.clone();
let mut window = ReviewWindow { input: candidate().to_string(), ..Default::default() };
window.review(&mut app);
assert!(window.reviewed.is_some());
for input in ["[".into(), "{\"schema_version\":2}".into(), "é".repeat(MAX_BYTES / 2 + 1)] {
window.input = input;
window.review(&mut app);
assert!(window.reviewed.is_none());
assert!(window.error.is_some());
}
assert!(window.error.as_deref().unwrap().contains("256 KiB"));
assert!(Arc::ptr_eq(&doc, &app.session.doc));
assert!(app.session.undo.is_empty() && app.session.redo.is_empty());
assert!(!app.session.is_dirty());
}
#[test]
fn every_property_variant_renders_its_declared_si_unit_and_inert_source() {
let fixtures = [
(json!({"kind":"density","kg_m3":600.}), "kg/m³"),
(
json!({"kind":"elasticity","model":{"symmetry":"orthotropic","axes":"wood_lrt",
"young_moduli_pa":[12e9,3e9,1e9],"shear_moduli_pa":[2e9,1e9,0.5e9],"poisson_ratios":[0.,0.,0.]}}),
"Pa",
),
(json!({"kind":"tensile_strength","pa":1e8}), "Pa"),
(json!({"kind":"compressive_strength","pa":1e8}), "Pa"),
(json!({"kind":"yield_strength","pa":1e8}), "Pa"),
(json!({"kind":"shear_strength","pa":1e8}), "Pa"),
(json!({"kind":"thermal_conductivity","w_m_k":[1.,2.,3.],"axes":"material123"}), "W/(m·K)"),
(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}), "K⁻¹"),
(json!({"kind":"specific_heat","j_kg_k":500.}), "J/(kg·K)"),
];
for (data, unit) in fixtures {
let mut h = Harness::new();
h.app.start("material.properties.validate");
let mut card = candidate();
card["properties"][0]["data"] = data;
h.app.material_card_review.as_mut().unwrap().input = card.to_string();
h.frames(2);
h.step(&json!({"widget":"Review card"})).unwrap();
assert!(h.app.material_card_review.as_ref().unwrap().error.is_none(), "{card}");
assert!(h.widget(unit).is_some(), "expected {unit} for {card}");
assert!(h.widget("Original illustrative <b>source</b>").is_some(), "source is displayed as literal text");
}
}
#[test]
fn executing_json_validation_does_not_open_the_inspector() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.run("material.properties.validate", json!({"card":candidate()})).unwrap();
assert!(app.material_card_review.is_none());
}
#[test]
fn reviewer_messages_exist_in_every_locale() {
let keys = [
"title",
"scope",
"input",
"review",
"limit",
"unknown",
"properties",
"conditions",
"source",
"axes",
"derived",
"claims",
"moisture_unit",
];
for catalog in crate::i18n::LOCALES {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/locales").join(format!("{}.json", catalog.id));
let messages: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
for key in keys.into_iter().chain(KINDS) {
let key = format!("ui.material_review.{key}");
assert!(messages.get(&key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()), "{}: {key}", catalog.id);
}
}
}
}