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}