use serde::{Deserialize, Serialize};
use crate::shape::{Group, Lathe, Prim, Shape};
use crate::texture::TextureRecipe;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Model {
#[serde(default = "unnamed")]
pub name: String,
pub nodes: Vec<Node>,
#[serde(default)]
pub materials: Vec<PartMaterial>,
}
fn unnamed() -> String {
"model".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub prim: String,
#[serde(default = "add_mode")]
pub mode: String,
#[serde(default)]
pub part: u32,
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
#[serde(default)]
pub z: f32,
#[serde(default)]
pub rot: f32,
#[serde(default = "axis_y")]
pub axis: String,
#[serde(default = "half")]
pub r: f32,
#[serde(default)]
pub r2: f32,
#[serde(default = "one")]
pub h: f32,
#[serde(default = "one")]
pub w: f32,
#[serde(default = "one")]
pub d: f32,
#[serde(default)]
pub round: f32,
#[serde(default = "quarter")]
pub k: f32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub profile: Vec<f32>,
}
fn add_mode() -> String {
"add".to_string()
}
fn axis_y() -> String {
"y".to_string()
}
fn half() -> f32 {
0.5
}
fn one() -> f32 {
1.0
}
fn quarter() -> f32 {
0.25
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PartMaterial {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texture: Option<TextureRecipe>,
#[serde(default = "white4")]
pub color: [f32; 4],
#[serde(default)]
pub emissive: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution: Option<u32>,
#[serde(default = "auto_uv")]
pub uv: String,
#[serde(default = "half")]
pub uv_scale: f32,
#[serde(default = "unnamed_part")]
pub name: String,
}
fn white4() -> [f32; 4] {
[1.0, 1.0, 1.0, 1.0]
}
fn auto_uv() -> String {
"auto".to_string()
}
fn unnamed_part() -> String {
"part".to_string()
}
impl Default for PartMaterial {
fn default() -> Self {
PartMaterial {
texture: None,
color: white4(),
emissive: 0.0,
resolution: None,
uv: auto_uv(),
uv_scale: half(),
name: unnamed_part(),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedPart {
pub name: String,
pub shape: Shape,
pub material: PartMaterial,
}
impl Model {
pub fn validate(&self) -> Result<(), String> {
if self.nodes.is_empty() {
return Err("a model needs at least one carving step".into());
}
for (i, n) in self.nodes.iter().enumerate() {
if !matches!(
n.prim.as_str(),
"sphere"
| "box"
| "cube"
| "cylinder"
| "cyl"
| "capsule"
| "cone"
| "torus"
| "lathe"
) {
return Err(format!("step {i}: unknown prim '{}'", n.prim));
}
if !matches!(
n.mode.as_str(),
"add" | "union" | "blend" | "cut" | "intersect"
) {
return Err(format!("step {i}: unknown mode '{}'", n.mode));
}
if n.prim == "lathe" && n.profile.len() < 6 {
return Err(format!("step {i}: a lathe profile needs 3+ `r y` pairs"));
}
}
for m in &self.materials {
if let Some(t) = &m.texture {
t.validate()?;
}
}
self.resolve()?;
Ok(())
}
pub fn resolve(&self) -> Result<Vec<ResolvedPart>, String> {
let mut parts: Vec<(u32, Option<Shape>)> = Vec::new();
for n in &self.nodes {
let leaf = node_shape(n);
let slot = match parts.iter_mut().find(|(p, _)| *p == n.part) {
Some(s) => s,
None => {
parts.push((n.part, None));
parts.last_mut().expect("just pushed")
}
};
slot.1 = Some(match slot.1.take() {
None => leaf,
Some(acc) => {
let op = match n.mode.as_str() {
"blend" => "blend",
"cut" => "cut",
"intersect" => "intersect",
_ => "union",
};
Shape::Group(Group {
op: op.to_string(),
k: n.k,
at: [0.0; 3],
rot: 0.0,
parts: vec![acc, leaf],
})
}
});
}
let mut out = Vec::new();
for (part, shape) in parts {
let Some(shape) = shape else { continue };
let material = self
.materials
.get(part as usize)
.cloned()
.unwrap_or_default();
let name = if material.name == "part" && !self.name.is_empty() {
if self.materials.len() > 1 {
format!("{}-{}", self.name, part)
} else {
self.name.clone()
}
} else {
material.name.clone()
};
out.push(ResolvedPart {
name,
shape,
material,
});
}
if out.is_empty() {
return Err("no parts resolved".into());
}
Ok(out)
}
}
fn node_shape(n: &Node) -> Shape {
let at = [n.x, n.y, n.z];
if n.prim == "lathe" {
let pts: Vec<[f32; 2]> = n.profile.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
return Shape::Lathe(Lathe { lathe: pts, at });
}
let prim = match n.prim.as_str() {
"cube" => "box",
"cyl" => "cylinder",
other => other,
};
Shape::Prim(Prim {
prim: prim.to_string(),
at,
rot: n.rot,
r: n.r,
size: (prim == "box").then_some([n.w, n.h, n.d]),
h: n.h,
r2: n.r2,
rounded: n.round,
axis: n.axis.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_flat_op_sequence_folds_into_carved_parts() {
let m: Model = serde_json::from_str(
r#"{ "name": "bell",
"nodes": [
{ "prim": "sphere", "r": 1.0, "y": 1.0 },
{ "prim": "box", "mode": "cut", "w": 4, "h": 2, "d": 4, "y": 0.0 },
{ "prim": "cylinder", "mode": "blend", "k": 0.2, "part": 1,
"r": 0.1, "h": 2.0, "y": 1.0 }
],
"materials": [
{ "name": "shell", "color": [0.8,0.7,0.3,1] },
{ "name": "clapper" }
] }"#,
)
.unwrap();
m.validate().unwrap();
let parts = m.resolve().unwrap();
assert_eq!(parts.len(), 2, "two material groups → two parts");
assert_eq!(parts[0].name, "shell");
assert_eq!(parts[0].material.color[0], 0.8);
match &parts[0].shape {
Shape::Group(g) => {
assert_eq!(g.op, "cut");
assert_eq!(g.parts.len(), 2);
}
other => panic!("expected a cut group, got {other:?}"),
}
assert!(matches!(parts[1].shape, Shape::Prim(_)));
}
#[test]
fn defaults_make_the_minimal_model_legal_and_bad_words_fail() {
let m: Model = serde_json::from_str(r#"{ "nodes": [ { "prim": "sphere" } ] }"#).unwrap();
m.validate().unwrap();
let parts = m.resolve().unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].material.uv, "auto", "PBR defaults are present");
let bad: Model = serde_json::from_str(r#"{ "nodes": [ { "prim": "blob" } ] }"#).unwrap();
assert!(bad.validate().is_err());
let bad: Model =
serde_json::from_str(r#"{ "nodes": [ { "prim": "sphere", "mode": "smoosh" } ] }"#)
.unwrap();
assert!(bad.validate().is_err());
let bad: Model =
serde_json::from_str(r#"{ "nodes": [ { "prim": "lathe", "profile": [0,0] } ] }"#)
.unwrap();
assert!(bad.validate().is_err());
}
}