facet-wrapped/src/model.rs
//! **`model3d`** — the glTF model viewer (2026-09-29, the last "no data seam yet" crate).
//!
//! # The binary transport
//!
//! The component takes a binary glTF (`.glb`). Every facett wrapper entry takes its
//! payload through ONE door, `facet_mount_with_data(canvas, component, data_json)` /
//! `facet_update_data(component, data_json)`, and the Streamlit side sends component
//! args as JSON. So the mesh travels as **standard base64 inside the JSON payload**
//! (`glb_base64`): no second export, no second loader path, no second host protocol —
//! the price is 4/3 on the wire, which for the models this pane shows (tens of KB; the
//! bundled avocado is 19 KB → 25 KB of text) is below every other cost of a mount.
//!
//! A true byte channel (a `facet_mount_with_bytes(canvas, component, meta_json,
//! Uint8Array)` export beside the JSON one) is the next step only if a real host needs
//! multi-MB meshes: it would need its own loader message (`facet:render` carries JSON
//! args) and its own Streamlit arg encoding, i.e. a second protocol to keep in step. It
//! is not built.
use base64::Engine as _;
use facett_core::Facet;
use serde_json::Value;
use crate::registry::{Entry, Registry, UI};
/// Register the model viewer.
pub(crate) fn register(r: &mut Registry) {
r.register(
Entry::new("model3d", "A glTF model viewer: a binary .glb mesh, spun, status-tinted by group.", || {
Box::new(facett_model3d::Model3D::avocado("model3d"))
})
.in_group(UI)
.with_data(model3d),
);
}
/// **`model3d`** — [`facett_model3d::Model3D::from_glb`].
///
/// ```json
/// { "title": "avocado", "glb_base64": "Z2xURgIAAAA…", "status": 0.3, "status_group": "Avocado" }
/// ```
/// `glb_base64` is a standard-alphabet base64 `.glb`. Not base64, or not a glTF the
/// component can load, is a refusal (never the bundled fixture). `status` (0..1) tints
/// the `status_group` mesh group (default: the component's own choice).
#[must_use]
pub fn model3d(v: &Value) -> Option<Box<dyn Facet>> {
let b64 = v.get("glb_base64").and_then(Value::as_str).filter(|s| !s.trim().is_empty())?;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?;
let title = v.get("title").and_then(Value::as_str).filter(|t| !t.is_empty()).unwrap_or("model");
let mut m = facett_model3d::Model3D::from_glb(title, &bytes).ok()?;
if let Some(g) = v.get("status_group").and_then(Value::as_str).filter(|g| !g.is_empty()) {
m = m.with_status_group(g);
}
if let Some(s) = v.get("status").and_then(Value::as_f64) {
m = m.with_status(s as f32);
}
Some(Box::new(m))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_base64_glb_mounts_and_anything_else_is_refused() {
let b64 = base64::engine::general_purpose::STANDARD.encode(facett_model3d::AVOCADO_GLB);
let f = model3d(&json!({ "title": "fruit", "glb_base64": b64, "status": 0.7, "status_group": "Avocado" }))
.expect("the avocado mounts from its base64");
let st = f.state_json();
assert_eq!(f.title(), "fruit");
assert!(st.to_string().contains("Avocado"), "{st}");
assert!(model3d(&json!({ "glb_base64": "not base64 !!" })).is_none(), "not base64 → refused");
let junk = base64::engine::general_purpose::STANDARD.encode(b"definitely not a glb");
assert!(model3d(&json!({ "glb_base64": junk })).is_none(), "not a glTF → refused");
assert!(model3d(&json!({})).is_none());
}
}