Skip to main content

thread_manifest/
texture.rs

1//! # Texture — procedural PBR materials as manifest data
2//!
3//! The texturing side of the carving language: a [`TextureRecipe`] is one
4//! small block that a browser bakes locally into the full PBR set — albedo,
5//! tangent-space normal map, and the packed occlusion-roughness-metallic
6//! map — from a deterministic, seamlessly tiling pattern. No image files, no
7//! UV unwrapping session, no upload step: the material is a sentence.
8//!
9//! The baker lives in the `chisel` crate; this module is the shared
10//! vocabulary (types + validation).
11
12use serde::{Deserialize, Serialize};
13
14/// The pattern kinds this spec version knows.
15pub const KINDS: &[&str] = &[
16    "fbm", "voronoi", "bricks", "checker", "wood", "veins", "flat",
17];
18
19/// Texture side-length cap — a manifest cannot make a browser bake 4K maps.
20pub const MAX_SIZE: u32 = 512;
21
22/// One procedural material: a pattern drives everything. The pattern's value
23/// (0–1 per texel) indexes the color ramp, lerps roughness/metallic between
24/// their two ends, acts as a height field for the normal map, and darkens
25/// crevices for ambient occlusion.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct TextureRecipe {
28    /// Pattern: `fbm` (organic noise) · `voronoi` (stone cells + cracks) ·
29    /// `bricks` · `wood` (warped plank grain) · `veins` (marble) ·
30    /// `checker` · `flat`.
31    pub kind: String,
32    /// Pattern repeats across the tile (features per tile edge). Default 4.
33    #[serde(default = "four")]
34    pub scale: f32,
35    /// fbm octaves (detail). Default 4.
36    #[serde(default = "four_u")]
37    pub octaves: u32,
38    /// Determinism seed — same recipe, same texels, everywhere. Default 0.
39    #[serde(default)]
40    pub seed: u32,
41    /// Color ramp stops, evenly spaced over the pattern value. 1+ colors.
42    #[serde(default = "grey")]
43    pub colors: Vec<[f32; 3]>,
44    /// Roughness at pattern value 0 → 1. Default `[0.9, 0.9]`.
45    #[serde(default = "rough_default")]
46    pub roughness: [f32; 2],
47    /// Unity's spelling of the same dial, if you think that way: smoothness
48    /// = 1 − roughness. When present it WINS over `roughness` (see
49    /// [`TextureRecipe::rough_band`]) — one material vocabulary, two accents.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub smoothness: Option<[f32; 2]>,
52    /// Metallic at pattern value 0 → 1. Default `[0, 0]`.
53    #[serde(default)]
54    pub metallic: [f32; 2],
55    /// Normal-map strength — the pattern read as a height field. 0 = flat.
56    #[serde(default)]
57    pub height: f32,
58    /// Crevice darkening strength (bakes into the occlusion channel). 0 = none.
59    #[serde(default)]
60    pub ao: f32,
61    /// Baked texture side in texels. Default 256, clamped to [`MAX_SIZE`].
62    #[serde(default = "size_default")]
63    pub size: u32,
64    /// A second recipe layered OVER this one, blended by an fbm mask —
65    /// moss over granite, rust over iron, plaster over brick. One level deep.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub over: Option<Box<TextureRecipe>>,
68    /// How much of the overlay shows (0 none → 1 full). Default 0.5.
69    #[serde(default = "half_f")]
70    pub mix: f32,
71    /// Feature scale of the blend mask (default 3).
72    #[serde(default = "three")]
73    pub mask_scale: f32,
74    /// Seed of the blend mask.
75    #[serde(default)]
76    pub mask_seed: u32,
77    /// World-space triplanar sampling in the browser (uv repeats per metre;
78    /// 0 = classic UV sampling). Kills projection seams on carved shapes —
79    /// set ~0.5 for architecture. Exported GLBs keep UVs (glTF has no
80    /// triplanar), so exports still look right, just with seams.
81    #[serde(default)]
82    pub triplanar: f32,
83}
84
85fn half_f() -> f32 {
86    0.5
87}
88fn three() -> f32 {
89    3.0
90}
91
92fn four() -> f32 {
93    4.0
94}
95fn four_u() -> u32 {
96    4
97}
98fn grey() -> Vec<[f32; 3]> {
99    vec![[0.6, 0.6, 0.6]]
100}
101fn rough_default() -> [f32; 2] {
102    [0.9, 0.9]
103}
104fn size_default() -> u32 {
105    256
106}
107
108impl TextureRecipe {
109    /// The effective roughness band, honouring `smoothness` when given.
110    pub fn rough_band(&self) -> [f32; 2] {
111        match self.smoothness {
112            Some([a, b]) => [1.0 - a, 1.0 - b],
113            None => self.roughness,
114        }
115    }
116
117    pub fn validate(&self) -> Result<(), String> {
118        if !KINDS.contains(&self.kind.as_str()) {
119            return Err(format!(
120                "unknown texture kind '{}' — one of {KINDS:?}",
121                self.kind
122            ));
123        }
124        if self.colors.is_empty() {
125            return Err("texture needs at least one color".into());
126        }
127        if self.colors.len() > 8 {
128            return Err("texture ramp: at most 8 colors".into());
129        }
130        if let Some(over) = &self.over {
131            if over.over.is_some() {
132                return Err(
133                    "texture layering is one level deep (the overlay cannot itself have `over`)"
134                        .into(),
135                );
136            }
137            over.validate()?;
138        }
139        Ok(())
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn minimal_and_full_forms_parse_and_validate() {
149        let t: TextureRecipe = serde_json::from_str(r#"{ "kind": "fbm" }"#).unwrap();
150        t.validate().unwrap();
151        assert_eq!(t.size, 256);
152        let t: TextureRecipe = serde_json::from_str(
153            r#"{ "kind": "voronoi", "scale": 6, "seed": 3,
154                 "colors": [[0.4,0.4,0.42],[0.55,0.53,0.5]],
155                 "roughness": [0.95, 0.8], "height": 0.6, "ao": 0.5 }"#,
156        )
157        .unwrap();
158        t.validate().unwrap();
159        let bad: TextureRecipe = serde_json::from_str(r#"{ "kind": "plaid" }"#).unwrap();
160        assert!(bad.validate().is_err());
161    }
162}