Skip to main content

thread_manifest/
shape.rs

1//! # Shape — procedural geometry as manifest data
2//!
3//! The Thread ships *recipes*, not vertex soup: a [`Shape`] is a small
4//! composable tree of signed-distance parts — primitives, smooth blends,
5//! carves, lathed profiles — that a browser meshes locally at load time
6//! (never in the frame loop). It is the modeling language for agents: every
7//! node is a word, every parameter a number, and the whole thing serializes
8//! as ordinary manifest JSON, so a "model" is diffable, promptable, and tiny
9//! on the wire.
10//!
11//! The mesher lives in the `chisel` crate; this module is only the shared
12//! vocabulary (types + validation + conservative bounds).
13
14use serde::{Deserialize, Serialize};
15
16/// One node of a shape tree. Untagged: the discriminating field names the
17/// variant (`prim` / `op` / `lathe`), which keeps authored JSON minimal.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(untagged)]
20pub enum Shape {
21    /// A primitive volume.
22    Prim(Prim),
23    /// A boolean/blend over children.
24    Group(Group),
25    /// A 2D profile revolved around the local Y axis.
26    Lathe(Lathe),
27}
28
29/// A primitive: `sphere` (r) · `box` (size, rounded) · `cylinder` (r, h) ·
30/// `capsule` (r, h) · `cone` (r → r2 over h) · `torus` (r major, r2 minor).
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Prim {
33    pub prim: String,
34    /// Centre offset in the parent's frame.
35    #[serde(default)]
36    pub at: [f32; 3],
37    /// Y-rotation, degrees.
38    #[serde(default)]
39    pub rot: f32,
40    /// Primary radius (sphere/cylinder/capsule/cone/torus major).
41    #[serde(default = "half")]
42    pub r: f32,
43    /// Box full extents.
44    #[serde(default)]
45    pub size: Option<[f32; 3]>,
46    /// Height (cylinder/capsule/cone) — full, not half.
47    #[serde(default = "one")]
48    pub h: f32,
49    /// Secondary radius (cone top; torus minor). Cone default 0 = a point.
50    #[serde(default)]
51    pub r2: f32,
52    /// Corner rounding (box).
53    #[serde(default)]
54    pub rounded: f32,
55    /// Long axis for cylinder/capsule/cone/torus: `"y"` (default), `"x"`, `"z"`.
56    #[serde(default = "axis_y", skip_serializing_if = "is_axis_y")]
57    pub axis: String,
58}
59
60fn axis_y() -> String {
61    "y".into()
62}
63fn is_axis_y(a: &String) -> bool {
64    a == "y"
65}
66
67/// A combining node: `union` · `blend` (smooth union, `k` metres of meld) ·
68/// `cut` (first part minus the rest) · `intersect`.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct Group {
71    pub op: String,
72    /// Blend radius for `blend` (ignored elsewhere).
73    #[serde(default = "quarter")]
74    pub k: f32,
75    #[serde(default)]
76    pub at: [f32; 3],
77    #[serde(default)]
78    pub rot: f32,
79    pub parts: Vec<Shape>,
80}
81
82/// A polyline profile in the (radius, height) plane, revolved around local Y.
83/// The profile is closed automatically. Vases, columns, domes, rims.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct Lathe {
86    /// `[radius, height]` points, in order. At least 3.
87    pub lathe: Vec<[f32; 2]>,
88    #[serde(default)]
89    pub at: [f32; 3],
90}
91
92fn half() -> f32 {
93    0.5
94}
95fn one() -> f32 {
96    1.0
97}
98fn quarter() -> f32 {
99    0.25
100}
101
102/// Map a Y-long extent to the prim's declared axis.
103fn swizzle_extent(e: [f32; 3], axis: &str) -> [f32; 3] {
104    match axis {
105        "x" => [e[1], e[0], e[2]],
106        "z" => [e[0], e[2], e[1]],
107        _ => e,
108    }
109}
110
111/// The primitive names this spec version knows.
112pub const PRIMS: &[&str] = &["sphere", "box", "cylinder", "capsule", "cone", "torus"];
113/// The combining ops this spec version knows.
114pub const OPS: &[&str] = &["union", "blend", "cut", "intersect"];
115
116impl Shape {
117    /// Validate the tree: known words, sane parameters, bounded depth.
118    pub fn validate(&self) -> Result<(), String> {
119        self.validate_at(0)
120    }
121
122    fn validate_at(&self, depth: usize) -> Result<(), String> {
123        if depth > 24 {
124            return Err("shape tree deeper than 24".into());
125        }
126        match self {
127            Shape::Prim(p) => {
128                if !PRIMS.contains(&p.prim.as_str()) {
129                    return Err(format!("unknown prim '{}' — one of {PRIMS:?}", p.prim));
130                }
131                if p.prim == "box" && p.size.is_none() {
132                    return Err("box needs a `size`".into());
133                }
134                Ok(())
135            }
136            Shape::Group(g) => {
137                if !OPS.contains(&g.op.as_str()) {
138                    return Err(format!("unknown op '{}' — one of {OPS:?}", g.op));
139                }
140                if g.parts.is_empty() {
141                    return Err(format!("'{}' needs at least one part", g.op));
142                }
143                for part in &g.parts {
144                    part.validate_at(depth + 1)?;
145                }
146                Ok(())
147            }
148            Shape::Lathe(l) => {
149                if l.lathe.len() < 3 {
150                    return Err("lathe needs at least 3 profile points".into());
151                }
152                Ok(())
153            }
154        }
155    }
156
157    /// Conservative axis-aligned bounds of the surface, in the shape's own
158    /// frame (the mesher's sampling volume).
159    pub fn bounds(&self) -> ([f32; 3], [f32; 3]) {
160        match self {
161            Shape::Prim(p) => {
162                let e = match p.prim.as_str() {
163                    "sphere" => [p.r; 3],
164                    "box" => {
165                        let s = p.size.unwrap_or([1.0; 3]);
166                        [
167                            s[0] / 2.0 + p.rounded,
168                            s[1] / 2.0 + p.rounded,
169                            s[2] / 2.0 + p.rounded,
170                        ]
171                    }
172                    "cylinder" | "cone" => {
173                        let r = p.r.max(p.r2);
174                        swizzle_extent([r, p.h / 2.0, r], &p.axis)
175                    }
176                    "capsule" => swizzle_extent([p.r, p.h / 2.0 + p.r, p.r], &p.axis),
177                    "torus" => swizzle_extent([p.r + p.r2, p.r2, p.r + p.r2], &p.axis),
178                    _ => [p.r; 3],
179                };
180                // Y-rotation can grow the xz footprint up to the diagonal.
181                let d = (e[0] * e[0] + e[2] * e[2]).sqrt();
182                let e = if p.rot != 0.0 { [d, e[1], d] } else { e };
183                (
184                    [p.at[0] - e[0], p.at[1] - e[1], p.at[2] - e[2]],
185                    [p.at[0] + e[0], p.at[1] + e[1], p.at[2] + e[2]],
186                )
187            }
188            Shape::Group(g) => {
189                let mut it = g.parts.iter().map(|s| s.bounds());
190                let (mut min, mut max) = it.next().unwrap_or(([0.0; 3], [0.0; 3]));
191                match g.op.as_str() {
192                    "cut" => {} // the carve only removes — the first part bounds it
193                    "intersect" => {
194                        for (bmin, bmax) in it {
195                            for i in 0..3 {
196                                min[i] = min[i].max(bmin[i]);
197                                max[i] = max[i].min(bmax[i]);
198                            }
199                        }
200                    }
201                    _ => {
202                        for (bmin, bmax) in it {
203                            for i in 0..3 {
204                                min[i] = min[i].min(bmin[i]);
205                                max[i] = max[i].max(bmax[i]);
206                            }
207                        }
208                        if g.op == "blend" {
209                            for i in 0..3 {
210                                min[i] -= g.k;
211                                max[i] += g.k;
212                            }
213                        }
214                    }
215                }
216                let d = ((max[0] - min[0]).max(max[2] - min[2])) / 2.0;
217                let (cx, cz) = ((min[0] + max[0]) / 2.0, (min[2] + max[2]) / 2.0);
218                let (mut min, mut max) = if g.rot != 0.0 {
219                    // Rotated group: widen xz to the rotation-safe square.
220                    ([cx - d, min[1], cz - d], [cx + d, max[1], cz + d])
221                } else {
222                    (min, max)
223                };
224                for i in 0..3 {
225                    min[i] += g.at[i];
226                    max[i] += g.at[i];
227                }
228                (min, max)
229            }
230            Shape::Lathe(l) => {
231                let rmax = l.lathe.iter().map(|p| p[0].abs()).fold(0.0f32, f32::max);
232                let (ymin, ymax) = l
233                    .lathe
234                    .iter()
235                    .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), p| {
236                        (a.min(p[1]), b.max(p[1]))
237                    });
238                (
239                    [l.at[0] - rmax, l.at[1] + ymin, l.at[2] - rmax],
240                    [l.at[0] + rmax, l.at[1] + ymax, l.at[2] + rmax],
241                )
242            }
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn parses_the_minimal_forms_and_validates() {
253        let s: Shape = serde_json::from_str(
254            r#"{ "op": "blend", "k": 0.3, "parts": [
255                 { "prim": "sphere", "r": 1.0, "at": [0, 0.6, 0] },
256                 { "prim": "box", "size": [2, 0.4, 2] },
257                 { "lathe": [[0,0],[0.5,0],[0.6,0.8],[0.2,1.2]] }
258               ] }"#,
259        )
260        .unwrap();
261        s.validate().unwrap();
262        let (min, max) = s.bounds();
263        assert!(
264            min[0] < -1.0 && max[1] > 1.0,
265            "bounds cover the parts: {min:?} {max:?}"
266        );
267        // Round-trips as the same tree.
268        let json = serde_json::to_string(&s).unwrap();
269        let back: Shape = serde_json::from_str(&json).unwrap();
270        assert_eq!(back, s);
271    }
272
273    #[test]
274    fn bad_trees_fail_loudly() {
275        let bad: Shape = serde_json::from_str(r#"{ "prim": "blob", "r": 1.0 }"#).unwrap();
276        assert!(bad.validate().is_err());
277        let bad: Shape =
278            serde_json::from_str(r#"{ "op": "melt", "parts": [{ "prim": "sphere" }] }"#).unwrap();
279        assert!(bad.validate().is_err());
280        let bad: Shape = serde_json::from_str(r#"{ "prim": "box" }"#).unwrap();
281        assert!(bad.validate().is_err(), "box without size");
282    }
283}