1use serde::{Deserialize, Serialize};
28
29use crate::shape::{Group, Lathe, Prim, Shape};
30use crate::texture::TextureRecipe;
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct Model {
35 #[serde(default = "unnamed")]
36 pub name: String,
37 pub nodes: Vec<Node>,
39 #[serde(default)]
42 pub materials: Vec<PartMaterial>,
43}
44
45fn unnamed() -> String {
46 "model".to_string()
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct Node {
53 pub prim: String,
56 #[serde(default = "add_mode")]
60 pub mode: String,
61 #[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 #[serde(default)]
72 pub rot: f32,
73 #[serde(default = "axis_y")]
75 pub axis: String,
76 #[serde(default = "half")]
78 pub r: f32,
79 #[serde(default)]
81 pub r2: f32,
82 #[serde(default = "one")]
84 pub h: f32,
85 #[serde(default = "one")]
87 pub w: f32,
88 #[serde(default = "one")]
90 pub d: f32,
91 #[serde(default)]
93 pub round: f32,
94 #[serde(default = "quarter")]
96 pub k: f32,
97 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct PartMaterial {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub texture: Option<TextureRecipe>,
126 #[serde(default = "white4")]
128 pub color: [f32; 4],
129 #[serde(default)]
131 pub emissive: f32,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub resolution: Option<u32>,
135 #[serde(default = "auto_uv")]
139 pub uv: String,
140 #[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#[derive(Debug, Clone)]
173pub struct ResolvedPart {
174 pub name: String,
175 pub shape: Shape,
176 pub material: PartMaterial,
177}
178
179impl Model {
180 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 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 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
281fn 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 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 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}