Skip to main content

thread_manifest/
model.rs

1//! # Model — the agent-native 3D format of the Thread
2//!
3//! A model is a **flat sequence of carving operations** plus the PBR
4//! materials its parts wear. Not a scene graph, not a mesh: the *steps* —
5//! "start with a box, blend a sphere, cut a cylinder" — which is how modeling
6//! actually reads, and (crucially) the only shape of program a
7//! [Weft](../../weft) value can express: Weft has no recursive types, so a
8//! tree cannot cross the wire, but a **list of uniform records** can. That
9//! constraint is a gift: it makes models generable by loops
10//! (`Iota`/`Map`/`Fold`), diffable line by line, and streamable.
11//!
12//! ```jsonc
13//! { "name": "amphora",
14//!   "nodes": [
15//!     { "prim": "lathe", "profile": [0,0, 0.32,0, 0.5,0.9, 0.3,1.66, 0,1.66] },
16//!     { "prim": "torus", "mode": "blend", "k": 0.06, "y": 1.5, "r": 0.36, "r2": 0.05 }
17//!   ],
18//!   "materials": [ { "kind": "fbm", "colors": [0.5,0.27,0.16, 0.68,0.42,0.26] } ] }
19//! ```
20//!
21//! **PBR is the default, not an upgrade.** Every part carries a material
22//! recipe that bakes the full set — base color, tangent-space normal,
23//! metallic, roughness (or `smoothness`, the Unity spelling), and ambient
24//! occlusion — so an exported `.glb` is complete without an artist ever
25//! opening a texture editor.
26
27use serde::{Deserialize, Serialize};
28
29use crate::shape::{Group, Lathe, Prim, Shape};
30use crate::texture::TextureRecipe;
31
32/// A complete model: named, in parts, PBR-ready.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct Model {
35    #[serde(default = "unnamed")]
36    pub name: String,
37    /// The carving steps, in order.
38    pub nodes: Vec<Node>,
39    /// One entry per part (`node.part` indexes here). An empty list means a
40    /// single default-stone part — a model is never material-less.
41    #[serde(default)]
42    pub materials: Vec<PartMaterial>,
43}
44
45fn unnamed() -> String {
46    "model".to_string()
47}
48
49/// One carving step. Uniform on purpose: every field exists for every node so
50/// the whole list is one Weft record type (and one JSON shape to learn).
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct Node {
53    /// `sphere` · `box` · `cylinder` · `capsule` · `cone` · `torus` · `lathe`.
54    /// (`cyl` and `cube` are accepted spellings.)
55    pub prim: String,
56    /// How this step combines with what's carved so far: `add` (union),
57    /// `blend` (smooth union over `k` metres), `cut`, `intersect`. The first
58    /// step of a part is the seed whatever its mode.
59    #[serde(default = "add_mode")]
60    pub mode: String,
61    /// Which part (material group) this step belongs to.
62    #[serde(default)]
63    pub part: u32,
64    #[serde(default)]
65    pub x: f32,
66    #[serde(default)]
67    pub y: f32,
68    #[serde(default)]
69    pub z: f32,
70    /// Y-rotation in degrees.
71    #[serde(default)]
72    pub rot: f32,
73    /// Long axis for the rotational prims: `y` (default), `x`, `z`.
74    #[serde(default = "axis_y")]
75    pub axis: String,
76    /// Radius (sphere/cylinder/capsule/cone/torus-major).
77    #[serde(default = "half")]
78    pub r: f32,
79    /// Secondary radius (cone top / torus minor).
80    #[serde(default)]
81    pub r2: f32,
82    /// Height (cylinder/capsule/cone) and box Y.
83    #[serde(default = "one")]
84    pub h: f32,
85    /// Box X.
86    #[serde(default = "one")]
87    pub w: f32,
88    /// Box Z.
89    #[serde(default = "one")]
90    pub d: f32,
91    /// Box corner rounding.
92    #[serde(default)]
93    pub round: f32,
94    /// Blend radius for `mode: "blend"`.
95    #[serde(default = "quarter")]
96    pub k: f32,
97    /// Lathe profile as flat `r, y` pairs (`prim: "lathe"` only).
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub profile: Vec<f32>,
100}
101
102fn add_mode() -> String {
103    "add".to_string()
104}
105fn axis_y() -> String {
106    "y".to_string()
107}
108fn half() -> f32 {
109    0.5
110}
111fn one() -> f32 {
112    1.0
113}
114fn quarter() -> f32 {
115    0.25
116}
117
118/// A part's PBR material: a procedural recipe plus the knobs a renderer
119/// applies on top of the baked maps.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct PartMaterial {
122    /// The bakeable recipe (albedo ramp, normal height, roughness/metallic
123    /// bands, AO). Absent → a plain tinted surface.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub texture: Option<TextureRecipe>,
126    /// Multiplied over the baked base color — one recipe, many tints.
127    #[serde(default = "white4")]
128    pub color: [f32; 4],
129    /// Glow strength (0 = lit normally).
130    #[serde(default)]
131    pub emissive: f32,
132    /// Meshing density for this part's carve (default 40, cap 96).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub resolution: Option<u32>,
135    /// UV projection: `auto` (default — cylindrical for lathes and rotational
136    /// prims, spherical for spheres, box otherwise), `box`, `cylindrical`,
137    /// `spherical`.
138    #[serde(default = "auto_uv")]
139    pub uv: String,
140    /// Texture repeats per metre (UV scale). Default 0.5.
141    #[serde(default = "half")]
142    pub uv_scale: f32,
143    #[serde(default = "unnamed_part")]
144    pub name: String,
145}
146
147fn white4() -> [f32; 4] {
148    [1.0, 1.0, 1.0, 1.0]
149}
150fn auto_uv() -> String {
151    "auto".to_string()
152}
153fn unnamed_part() -> String {
154    "part".to_string()
155}
156
157impl Default for PartMaterial {
158    fn default() -> Self {
159        PartMaterial {
160            texture: None,
161            color: white4(),
162            emissive: 0.0,
163            resolution: None,
164            uv: auto_uv(),
165            uv_scale: half(),
166            name: unnamed_part(),
167        }
168    }
169}
170
171/// One resolved part: the shape its steps carved, and how it looks.
172#[derive(Debug, Clone)]
173pub struct ResolvedPart {
174    pub name: String,
175    pub shape: Shape,
176    pub material: PartMaterial,
177}
178
179impl Model {
180    /// Validate the whole model (known words, sane steps, materials present).
181    pub fn validate(&self) -> Result<(), String> {
182        if self.nodes.is_empty() {
183            return Err("a model needs at least one carving step".into());
184        }
185        for (i, n) in self.nodes.iter().enumerate() {
186            if !matches!(
187                n.prim.as_str(),
188                "sphere"
189                    | "box"
190                    | "cube"
191                    | "cylinder"
192                    | "cyl"
193                    | "capsule"
194                    | "cone"
195                    | "torus"
196                    | "lathe"
197            ) {
198                return Err(format!("step {i}: unknown prim '{}'", n.prim));
199            }
200            if !matches!(
201                n.mode.as_str(),
202                "add" | "union" | "blend" | "cut" | "intersect"
203            ) {
204                return Err(format!("step {i}: unknown mode '{}'", n.mode));
205            }
206            if n.prim == "lathe" && n.profile.len() < 6 {
207                return Err(format!("step {i}: a lathe profile needs 3+ `r y` pairs"));
208            }
209        }
210        for m in &self.materials {
211            if let Some(t) = &m.texture {
212                t.validate()?;
213            }
214        }
215        self.resolve()?;
216        Ok(())
217    }
218
219    /// Fold the flat steps into one [`Shape`] per part, in part order.
220    pub fn resolve(&self) -> Result<Vec<ResolvedPart>, String> {
221        let mut parts: Vec<(u32, Option<Shape>)> = Vec::new();
222        for n in &self.nodes {
223            let leaf = node_shape(n);
224            let slot = match parts.iter_mut().find(|(p, _)| *p == n.part) {
225                Some(s) => s,
226                None => {
227                    parts.push((n.part, None));
228                    parts.last_mut().expect("just pushed")
229                }
230            };
231            slot.1 = Some(match slot.1.take() {
232                // The first step of a part is the seed, whatever its mode.
233                None => leaf,
234                Some(acc) => {
235                    let op = match n.mode.as_str() {
236                        "blend" => "blend",
237                        "cut" => "cut",
238                        "intersect" => "intersect",
239                        _ => "union",
240                    };
241                    Shape::Group(Group {
242                        op: op.to_string(),
243                        k: n.k,
244                        at: [0.0; 3],
245                        rot: 0.0,
246                        parts: vec![acc, leaf],
247                    })
248                }
249            });
250        }
251        let mut out = Vec::new();
252        for (part, shape) in parts {
253            let Some(shape) = shape else { continue };
254            let material = self
255                .materials
256                .get(part as usize)
257                .cloned()
258                .unwrap_or_default();
259            let name = if material.name == "part" && !self.name.is_empty() {
260                if self.materials.len() > 1 {
261                    format!("{}-{}", self.name, part)
262                } else {
263                    self.name.clone()
264                }
265            } else {
266                material.name.clone()
267            };
268            out.push(ResolvedPart {
269                name,
270                shape,
271                material,
272            });
273        }
274        if out.is_empty() {
275            return Err("no parts resolved".into());
276        }
277        Ok(out)
278    }
279}
280
281/// One step's own volume, positioned.
282fn node_shape(n: &Node) -> Shape {
283    let at = [n.x, n.y, n.z];
284    if n.prim == "lathe" {
285        let pts: Vec<[f32; 2]> = n.profile.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
286        return Shape::Lathe(Lathe { lathe: pts, at });
287    }
288    let prim = match n.prim.as_str() {
289        "cube" => "box",
290        "cyl" => "cylinder",
291        other => other,
292    };
293    Shape::Prim(Prim {
294        prim: prim.to_string(),
295        at,
296        rot: n.rot,
297        r: n.r,
298        size: (prim == "box").then_some([n.w, n.h, n.d]),
299        h: n.h,
300        r2: n.r2,
301        rounded: n.round,
302        axis: n.axis.clone(),
303    })
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn a_flat_op_sequence_folds_into_carved_parts() {
312        let m: Model = serde_json::from_str(
313            r#"{ "name": "bell",
314                 "nodes": [
315                   { "prim": "sphere", "r": 1.0, "y": 1.0 },
316                   { "prim": "box", "mode": "cut", "w": 4, "h": 2, "d": 4, "y": 0.0 },
317                   { "prim": "cylinder", "mode": "blend", "k": 0.2, "part": 1,
318                     "r": 0.1, "h": 2.0, "y": 1.0 }
319                 ],
320                 "materials": [
321                   { "name": "shell", "color": [0.8,0.7,0.3,1] },
322                   { "name": "clapper" }
323                 ] }"#,
324        )
325        .unwrap();
326        m.validate().unwrap();
327        let parts = m.resolve().unwrap();
328        assert_eq!(parts.len(), 2, "two material groups → two parts");
329        assert_eq!(parts[0].name, "shell");
330        assert_eq!(parts[0].material.color[0], 0.8);
331        // Part 0 is a cut group over the seed sphere.
332        match &parts[0].shape {
333            Shape::Group(g) => {
334                assert_eq!(g.op, "cut");
335                assert_eq!(g.parts.len(), 2);
336            }
337            other => panic!("expected a cut group, got {other:?}"),
338        }
339        // A single-step part is just its primitive (no wrapper).
340        assert!(matches!(parts[1].shape, Shape::Prim(_)));
341    }
342
343    #[test]
344    fn defaults_make_the_minimal_model_legal_and_bad_words_fail() {
345        let m: Model = serde_json::from_str(r#"{ "nodes": [ { "prim": "sphere" } ] }"#).unwrap();
346        m.validate().unwrap();
347        let parts = m.resolve().unwrap();
348        assert_eq!(parts.len(), 1);
349        assert_eq!(parts[0].material.uv, "auto", "PBR defaults are present");
350
351        let bad: Model = serde_json::from_str(r#"{ "nodes": [ { "prim": "blob" } ] }"#).unwrap();
352        assert!(bad.validate().is_err());
353        let bad: Model =
354            serde_json::from_str(r#"{ "nodes": [ { "prim": "sphere", "mode": "smoosh" } ] }"#)
355                .unwrap();
356        assert!(bad.validate().is_err());
357        let bad: Model =
358            serde_json::from_str(r#"{ "nodes": [ { "prim": "lathe", "profile": [0,0] } ] }"#)
359                .unwrap();
360        assert!(bad.validate().is_err());
361    }
362}