@call-me-sensei/toonlab 0.4.21 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/NPM-LIBRARY.md +87 -27
  2. package/README.md +11 -10
  3. package/agents/PROMPTS.md +17 -3
  4. package/agents/README.md +13 -3
  5. package/agents/claude/CLAUDE.md +3 -0
  6. package/agents/codex/AGENTS.md +112 -159
  7. package/agents/cursor/toonlab.mdc +7 -3
  8. package/agents/examples/game-foundation.mjs +104 -0
  9. package/agents/references/game-lifecycle.md +57 -0
  10. package/agents/references/runtime-entry-points.md +26 -0
  11. package/agents/references/style-bundles.md +4 -1
  12. package/agents/skills/claude/asset-sourcing/SKILL.md +7 -4
  13. package/agents/skills/claude/environment/SKILL.md +4 -0
  14. package/agents/skills/claude/game-dev/SKILL.md +63 -87
  15. package/agents/skills/claude/lighting/SKILL.md +36 -0
  16. package/agents/skills/claude/post-processing/SKILL.md +8 -3
  17. package/agents/skills/claude/rock-ground-shaders/SKILL.md +10 -15
  18. package/agents/skills/claude/rockgen/SKILL.md +52 -21
  19. package/agents/skills/claude/scene-style-application/SKILL.md +4 -0
  20. package/agents/skills/claude/style-presets/SKILL.md +11 -8
  21. package/agents/skills/claude/toon-shading/SKILL.md +4 -0
  22. package/agents/skills/claude/vegetation-sky/SKILL.md +13 -7
  23. package/agents/skills/claude/visual-verification/SKILL.md +45 -119
  24. package/agents/skills/claude/water/SKILL.md +6 -2
  25. package/agents/skills/codex/asset-sourcing/SKILL.md +7 -4
  26. package/agents/skills/codex/environment/SKILL.md +4 -0
  27. package/agents/skills/codex/game-dev/SKILL.md +63 -87
  28. package/agents/skills/codex/lighting/SKILL.md +36 -0
  29. package/agents/skills/codex/post-processing/SKILL.md +8 -3
  30. package/agents/skills/codex/rock-ground-shaders/SKILL.md +10 -15
  31. package/agents/skills/codex/rockgen/SKILL.md +52 -21
  32. package/agents/skills/codex/scene-style-application/SKILL.md +4 -0
  33. package/agents/skills/codex/style-presets/SKILL.md +11 -8
  34. package/agents/skills/codex/toon-shading/SKILL.md +4 -0
  35. package/agents/skills/codex/vegetation-sky/SKILL.md +13 -7
  36. package/agents/skills/codex/visual-verification/SKILL.md +45 -119
  37. package/agents/skills/codex/water/SKILL.md +6 -2
  38. package/database/apply-one-catalog-seed.mjs +63 -0
  39. package/database/providers.mjs +1 -0
  40. package/database/seeds/catalog/0005_2026-08-c8-first12.sql +1101 -0
  41. package/database/seeds/catalog/0006_2026-09-c8-first100.sql +14245 -0
  42. package/database/seeds/catalog/0007_2026-09-c8-first100-primary-model.sql +14845 -0
  43. package/mcp/lab-management.mjs +11 -8
  44. package/mcp/public-catalog.mjs +0 -16
  45. package/mcp/tree-lab-contract.mjs +2381 -0
  46. package/mcp/vite-plugin.mjs +5 -1
  47. package/package.json +29 -18
  48. package/src/catalog/officialCatalog.js +1 -0
  49. package/src/catalog/officialCatalogAssetRuntime.js +98 -1
  50. package/src/catalog/officialCatalogLod.js +160 -16
  51. package/src/catalog/officialCatalogPlacement.js +52 -12
  52. package/src/catalog/officialCatalogProvider.js +79 -8
  53. package/src/catalog/officialCatalogRockPackage.js +172 -0
  54. package/src/cloud/cloudReprojection.js +40 -7
  55. package/src/cloud/cloudVolume.js +44 -7
  56. package/src/cloud/noise/baseShapeVolume.js +8 -6
  57. package/src/cloud/noise/cirrusMap.js +2 -1
  58. package/src/cloud/noise/curlNoise.js +2 -1
  59. package/src/cloud/noise/erosionVolume.js +2 -1
  60. package/src/cloud/noise/textureCache.js +77 -0
  61. package/src/cloud/noise/weatherMap.js +2 -1
  62. package/src/core/sha256.js +83 -0
  63. package/src/ground-shader/groundShaderMaterial.js +12 -3
  64. package/src/rock-shader/rockMaterial.js +205 -28
  65. package/src/rock-shader/rockShaderRuntime.js +17 -1
  66. package/src/rock-shader/rockShaderSettings.js +16 -0
  67. package/src/rockgen/index.js +9 -0
  68. package/src/rockgen/lod/index.js +1 -0
  69. package/src/rockgen/lod/rockDenseFieldPolicy.js +143 -0
  70. package/src/rockgen/rockDocument.js +899 -35
  71. package/src/rockgen/surface/c7GeologySurface.js +39 -9
  72. package/src/rockgen/surface/naturalRockSurface.js +1948 -0
  73. package/src/shaders-tsl/chunks/projected-water-caustics.js +14 -2
  74. package/src/shaders-tsl/chunks/water-foam.js +6 -6
  75. package/src/shaders-tsl/chunks/water-lighting.js +28 -5
  76. package/src/shaders-tsl/chunks/water-shore-state.js +5 -2
  77. package/src/shaders-tsl/chunks/water-waves.js +10 -5
  78. package/src/shaders-tsl/flower.js +7 -4
  79. package/src/shaders-tsl/grass.js +4 -3
  80. package/src/shaders-tsl/water-shore-state-simulation.js +3 -2
  81. package/src/shaders-tsl/water.js +209 -41
  82. package/src/sky/skySystem.js +30 -7
  83. package/src/toon/settings/perspectiveRemovalSettings.js +1 -1
  84. package/src/toon/toonSettings.js +74 -0
  85. package/src/vegetation/stylizedFlower.js +6 -0
  86. package/src/version.js +1 -1
  87. package/src/water/waterDetailSpectrum.js +91 -0
  88. package/src/water/waterDynamics.js +422 -0
  89. package/src/water/waterFoamParticles.js +196 -0
  90. package/src/water/waterFoamTexture.js +57 -0
  91. package/src/water/waterHydrodynamics.js +209 -0
  92. package/src/water/waterMaterial.js +1 -0
  93. package/src/water/waterRenderExtension.js +107 -0
  94. package/src/water/waterScenePasses.js +14 -6
  95. package/src/water/waterSettings.js +53 -26
  96. package/src/water/waterShoreMaterial.js +38 -14
  97. package/src/water/waterSpectralOcean.js +137 -0
  98. package/src/water/waterStageSettings.js +19 -1
  99. package/src/water/waterSurface.js +62 -18
  100. package/src/water/waterUnderwaterAtmosphere.js +30 -3
  101. package/types/catalog/officialCatalog.d.ts +1 -0
  102. package/types/catalog/officialCatalogPlacement.d.ts +4 -0
  103. package/types/catalog/officialCatalogRockPackage.d.ts +68 -0
  104. package/types/cloud/noise/baseShapeVolume.d.ts +1 -1
  105. package/types/cloud/noise/textureCache.d.ts +12 -0
  106. package/types/core/sha256.d.ts +2 -0
  107. package/types/index.d.ts +30 -0
  108. package/types/rock-shader/rockMaterial.d.ts +3 -0
  109. package/types/rock-shader/rockShaderSettings.d.ts +2 -0
  110. package/types/rockgen/index.d.ts +1 -0
  111. package/types/rockgen/lod/index.d.ts +1 -0
  112. package/types/rockgen/lod/rockDenseFieldPolicy.d.ts +181 -0
  113. package/types/rockgen/rockDocument.d.ts +654 -71
  114. package/types/rockgen/surface/naturalRockSurface.d.ts +100 -0
  115. package/types/shaders-tsl/chunks/projected-water-caustics.d.ts +7 -1
  116. package/types/shaders-tsl/chunks/water-lighting.d.ts +4 -3
  117. package/types/shaders-tsl/water.d.ts +2 -1
  118. package/types/version.d.ts +1 -1
  119. package/types/water/waterDetailSpectrum.d.ts +13 -0
  120. package/types/water/waterDynamics.d.ts +89 -0
  121. package/types/water/waterFoamParticles.d.ts +32 -0
  122. package/types/water/waterFoamTexture.d.ts +1 -0
  123. package/types/water/waterHydrodynamics.d.ts +49 -0
  124. package/types/water/waterRenderExtension.d.ts +16 -0
  125. package/types/water/waterScenePasses.d.ts +1 -1
  126. package/types/water/waterSettings.d.ts +1 -0
  127. package/types/water/waterSpectralOcean.d.ts +17 -0
  128. package/types/water/waterStageSettings.d.ts +1 -1
  129. package/types/water/waterUnderwaterAtmosphere.d.ts +19 -4
@@ -0,0 +1,1948 @@
1
+ // Deterministic realistic surfaces for Nature Reference Rocks.
2
+ //
3
+ // This module deliberately shares no texture bytes, seeds, or asset identities
4
+ // with the released 480-rock/C7 library. It carries forward only the fixes that
5
+ // proved useful there: metre-isotropic world projection, bounds-driven
6
+ // reprojection after edits, non-uniform geological fabrics, independent PBR
7
+ // channels, and deterministic replaceable derivatives. Bedded profiles use an
8
+ // axis-conditioned side sampler; non-bedded profiles retain triplanar.
9
+
10
+ import { sha256Hex } from '../../core/sha256.js';
11
+
12
+ export const C8_FIRST12_SURFACE_SCHEMA = 'toonlab/c8-first12-geology-surface';
13
+ export const C8_FIRST12_SURFACE_VERSION = 2;
14
+
15
+ export const C8_SEMANTIC_SURFACE_REGIONS_SCHEMA = 'toonlab/c8-semantic-surface-regions';
16
+ export const C8_SEMANTIC_SURFACE_REGIONS_VERSION = 1;
17
+
18
+ export const C8_FIRST12_MAP_ROLES = Object.freeze([
19
+ 'ao',
20
+ 'baseColor',
21
+ 'heightMicro',
22
+ 'normalGL',
23
+ 'orm',
24
+ 'roughness',
25
+ 'smoothness',
26
+ ]);
27
+
28
+ export const C8_FIRST12_ASSET_IDS = Object.freeze([
29
+ 'hoodoo-caprock',
30
+ 'tor-block-pile',
31
+ 'boulder-rounded',
32
+ 'block-jointed',
33
+ 'boulder-river-worn',
34
+ 'slab-bedded',
35
+ 'outcrop-jointed',
36
+ 'outcrop-bedded',
37
+ 'ledge-resistant',
38
+ 'pillar-residual',
39
+ 'sea-stack',
40
+ 'volcanic-neck',
41
+ ]);
42
+
43
+ const PROFILE = (profile) => Object.freeze({
44
+ ...profile,
45
+ admissionLimits: profile.admissionLimits ? Object.freeze([...profile.admissionLimits]) : null,
46
+ baseDark: Object.freeze(profile.baseDark),
47
+ baseLight: Object.freeze(profile.baseLight),
48
+ fractures: Object.freeze({
49
+ ...profile.fractures,
50
+ width: Object.freeze(profile.fractures.width),
51
+ }),
52
+ mineralDark: Object.freeze(profile.mineralDark),
53
+ mineralLight: Object.freeze(profile.mineralLight),
54
+ roughness: Object.freeze(profile.roughness),
55
+ semanticRegionRoles: profile.semanticRegionRoles
56
+ ? Object.freeze([...profile.semanticRegionRoles])
57
+ : null,
58
+ strata: profile.strata ? Object.freeze({
59
+ ...profile.strata,
60
+ dark: Object.freeze(profile.strata.dark),
61
+ light: Object.freeze(profile.strata.light),
62
+ }) : null,
63
+ });
64
+
65
+ /**
66
+ * Lithology profiles are intentionally narrower than visual "rock families".
67
+ * They control material/fabric only; morphology remains owned by the edited
68
+ * high source and the subtype recipe.
69
+ */
70
+ export const C8_FIRST12_LITHOLOGY_PROFILES = Object.freeze({
71
+ 'claron-carbonate-bedded': PROFILE({
72
+ label: 'Iron-stained bedded carbonate',
73
+ lithology: 'limestone/dolostone caprock and weaker carbonate beds',
74
+ fabric: 'bedded-carbonate',
75
+ baseDark: [0.20, 0.055, 0.026],
76
+ baseLight: [0.78, 0.31, 0.15],
77
+ mineralDark: [0.10, 0.020, 0.012],
78
+ mineralLight: [0.82, 0.56, 0.36],
79
+ baseTileMetres: 1.25,
80
+ heightMicroSpanMetres: 0.026,
81
+ normalStrength: 1.15,
82
+ roughness: [0.66, 0.94],
83
+ grainScale: 0.074,
84
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 4, branchCount: 3, width: [0.0012, 0.0046], depth: 0.040 },
85
+ strata: { frequency: 7.2, spacingJitter: 0.10, warp: 0.18, curvature: 0.04, colorStrength: 0.22, reliefStrength: 0.30, dark: [0.12, 0.018, 0.012], light: [0.88, 0.48, 0.27] },
86
+ }),
87
+ 'coarse-granite-jointed': PROFILE({
88
+ label: 'Coarse jointed granite',
89
+ lithology: 'coarse granite/monzogranite',
90
+ fabric: 'crystalline-jointed',
91
+ baseDark: [0.16, 0.17, 0.18],
92
+ baseLight: [0.62, 0.58, 0.52],
93
+ mineralDark: [0.035, 0.040, 0.045],
94
+ mineralLight: [0.78, 0.72, 0.64],
95
+ baseTileMetres: 1.10,
96
+ heightMicroSpanMetres: 0.020,
97
+ normalStrength: 1.05,
98
+ roughness: [0.58, 0.88],
99
+ grainScale: 0.090,
100
+ fractures: { mode: 'orthogonal-joints', primaryCount: 5, branchCount: 4, width: [0.0009, 0.0038], depth: 0.043 },
101
+ strata: null,
102
+ }),
103
+ 'weathered-monzogranite': PROFILE({
104
+ label: 'Weathered rounded monzogranite',
105
+ lithology: 'granite/monzogranite with granular weathering',
106
+ fabric: 'crystalline-rounded',
107
+ baseDark: [0.17, 0.16, 0.15],
108
+ baseLight: [0.67, 0.60, 0.51],
109
+ mineralDark: [0.045, 0.042, 0.040],
110
+ mineralLight: [0.82, 0.74, 0.64],
111
+ baseTileMetres: 0.82,
112
+ heightMicroSpanMetres: 0.016,
113
+ normalStrength: 0.86,
114
+ roughness: [0.56, 0.86],
115
+ grainScale: 0.086,
116
+ fractures: { mode: 'weathered-relict-joints', primaryCount: 3, branchCount: 2, width: [0.0010, 0.0032], depth: 0.024 },
117
+ strata: null,
118
+ }),
119
+ 'river-abraded-sandstone': PROFILE({
120
+ label: 'River-abraded sandstone',
121
+ lithology: 'quartz sandstone with transport abrasion',
122
+ fabric: 'abraded-sandstone',
123
+ baseDark: [0.24, 0.105, 0.045],
124
+ baseLight: [0.79, 0.49, 0.25],
125
+ mineralDark: [0.11, 0.046, 0.023],
126
+ mineralLight: [0.91, 0.72, 0.48],
127
+ baseTileMetres: 0.72,
128
+ heightMicroSpanMetres: 0.012,
129
+ normalStrength: 0.72,
130
+ roughness: [0.48, 0.78],
131
+ grainScale: 0.070,
132
+ fractures: { mode: 'transport-impact', primaryCount: 3, branchCount: 2, width: [0.0008, 0.0026], depth: 0.020 },
133
+ strata: { frequency: 5.1, spacingJitter: 0.08, warp: 0.12, curvature: 0.04, colorStrength: 0.10, reliefStrength: 0.18, dark: [0.17, 0.060, 0.026], light: [0.86, 0.59, 0.34] },
134
+ }),
135
+ 'red-sandstone-bedded': PROFILE({
136
+ label: 'Iron-rich bedded sandstone',
137
+ lithology: 'cross-bedded quartz sandstone',
138
+ fabric: 'bedded-sandstone',
139
+ baseDark: [0.24, 0.030, 0.014],
140
+ baseLight: [0.86, 0.25, 0.075],
141
+ mineralDark: [0.085, 0.008, 0.006],
142
+ mineralLight: [0.92, 0.54, 0.27],
143
+ baseTileMetres: 1.05,
144
+ heightMicroSpanMetres: 0.018,
145
+ normalStrength: 0.92,
146
+ roughness: [0.64, 0.92],
147
+ grainScale: 0.076,
148
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 4, branchCount: 3, width: [0.0010, 0.0042], depth: 0.036 },
149
+ strata: { frequency: 8.2, spacingJitter: 0.14, warp: 0.28, curvature: 0.06, colorStrength: 0.44, reliefStrength: 0.38, dark: [0.095, 0.006, 0.004], light: [0.95, 0.52, 0.22] },
150
+ }),
151
+ 'kaibab-limestone-ledge': PROFILE({
152
+ label: 'Resistant pale limestone',
153
+ lithology: 'massive to bedded limestone/dolostone',
154
+ fabric: 'bedded-carbonate',
155
+ baseDark: [0.24, 0.22, 0.17],
156
+ baseLight: [0.74, 0.69, 0.55],
157
+ mineralDark: [0.12, 0.11, 0.085],
158
+ mineralLight: [0.88, 0.84, 0.70],
159
+ baseTileMetres: 1.55,
160
+ heightMicroSpanMetres: 0.024,
161
+ normalStrength: 1.14,
162
+ roughness: [0.68, 0.95],
163
+ grainScale: 0.068,
164
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 4, branchCount: 3, width: [0.0012, 0.0048], depth: 0.042 },
165
+ strata: { frequency: 5.6, spacingJitter: 0.08, warp: 0.12, curvature: 0.03, colorStrength: 0.15, reliefStrength: 0.24, dark: [0.18, 0.16, 0.12], light: [0.83, 0.78, 0.62] },
166
+ }),
167
+ 'quartz-sandstone-pillar': PROFILE({
168
+ label: 'Weathered quartz-sandstone pillar',
169
+ lithology: 'quartz sandstone with iron-stained bedding and joints',
170
+ fabric: 'bedded-sandstone',
171
+ baseDark: [0.19, 0.055, 0.025],
172
+ baseLight: [0.73, 0.34, 0.15],
173
+ mineralDark: [0.075, 0.017, 0.009],
174
+ mineralLight: [0.89, 0.59, 0.32],
175
+ baseTileMetres: 1.45,
176
+ heightMicroSpanMetres: 0.024,
177
+ normalStrength: 1.04,
178
+ roughness: [0.62, 0.91],
179
+ grainScale: 0.079,
180
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 5, branchCount: 4, width: [0.0010, 0.0044], depth: 0.040 },
181
+ strata: { frequency: 6.8, spacingJitter: 0.11, warp: 0.22, curvature: 0.05, colorStrength: 0.30, reliefStrength: 0.32, dark: [0.09, 0.015, 0.008], light: [0.91, 0.53, 0.27] },
182
+ }),
183
+ 'coastal-sandstone-stack': PROFILE({
184
+ label: 'Salt-weathered coastal sandstone',
185
+ lithology: 'bedded coastal sandstone',
186
+ fabric: 'coastal-bedded-sandstone',
187
+ baseDark: [0.18, 0.12, 0.075],
188
+ baseLight: [0.66, 0.49, 0.31],
189
+ mineralDark: [0.07, 0.047, 0.032],
190
+ mineralLight: [0.84, 0.73, 0.57],
191
+ baseTileMetres: 1.62,
192
+ heightMicroSpanMetres: 0.032,
193
+ normalStrength: 1.20,
194
+ roughness: [0.66, 0.96],
195
+ grainScale: 0.072,
196
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 5, branchCount: 4, width: [0.0013, 0.0054], depth: 0.050 },
197
+ strata: { frequency: 5.9, spacingJitter: 0.10, warp: 0.18, curvature: 0.04, colorStrength: 0.20, reliefStrength: 0.30, dark: [0.12, 0.075, 0.047], light: [0.77, 0.62, 0.44] },
198
+ }),
199
+ 'phonolite-porphyry-cooling': PROFILE({
200
+ label: 'Porphyritic intrusive phonolite',
201
+ lithology: 'phonolite porphyry / resistant intrusive volcanic rock',
202
+ fabric: 'porphyritic-cooling-jointed',
203
+ baseDark: [0.035, 0.040, 0.042],
204
+ baseLight: [0.30, 0.32, 0.30],
205
+ mineralDark: [0.012, 0.015, 0.016],
206
+ mineralLight: [0.58, 0.61, 0.56],
207
+ baseTileMetres: 1.35,
208
+ heightMicroSpanMetres: 0.022,
209
+ normalStrength: 1.18,
210
+ roughness: [0.58, 0.88],
211
+ grainScale: 0.084,
212
+ fractures: { mode: 'cooling-joints', primaryCount: 6, branchCount: 4, width: [0.0009, 0.0040], depth: 0.047 },
213
+ strata: null,
214
+ }),
215
+ 'basalt-mafic-cooling': PROFILE({
216
+ label: 'Mafic basalt with cooling joints',
217
+ lithology: 'fine-grained basalt / mafic volcanic rock',
218
+ fabric: 'crystalline-mafic-cooling-jointed',
219
+ baseDark: [0.018, 0.022, 0.024],
220
+ baseLight: [0.19, 0.21, 0.20],
221
+ mineralDark: [0.006, 0.008, 0.009],
222
+ mineralLight: [0.38, 0.41, 0.37],
223
+ baseTileMetres: 1.18,
224
+ heightMicroSpanMetres: 0.018,
225
+ normalStrength: 1.10,
226
+ roughness: [0.57, 0.87],
227
+ grainScale: 0.060,
228
+ fractures: { mode: 'cooling-joints', primaryCount: 6, branchCount: 4, width: [0.0008, 0.0038], depth: 0.045 },
229
+ strata: null,
230
+ }),
231
+ 'conglomerate-clastic': PROFILE({
232
+ label: 'Matrix-supported conglomerate',
233
+ lithology: 'conglomerate with rounded to subrounded clasts in a finer matrix',
234
+ fabric: 'clastic-conglomerate',
235
+ baseDark: [0.16, 0.12, 0.085],
236
+ baseLight: [0.57, 0.48, 0.36],
237
+ mineralDark: [0.055, 0.045, 0.038],
238
+ mineralLight: [0.78, 0.70, 0.57],
239
+ baseTileMetres: 0.92,
240
+ heightMicroSpanMetres: 0.024,
241
+ normalStrength: 1.04,
242
+ roughness: [0.63, 0.92],
243
+ grainScale: 0.095,
244
+ fractures: { mode: 'matrix-cross-joints', primaryCount: 3, branchCount: 3, width: [0.0010, 0.0040], depth: 0.031 },
245
+ strata: null,
246
+ }),
247
+ 'fissile-shale': PROFILE({
248
+ label: 'Dark fissile shale',
249
+ lithology: 'thinly laminated shale',
250
+ fabric: 'fine-clastic-fissile-bedded',
251
+ baseDark: [0.055, 0.052, 0.048],
252
+ baseLight: [0.31, 0.30, 0.27],
253
+ mineralDark: [0.018, 0.018, 0.017],
254
+ mineralLight: [0.47, 0.45, 0.39],
255
+ baseTileMetres: 0.58,
256
+ heightMicroSpanMetres: 0.010,
257
+ normalStrength: 0.82,
258
+ roughness: [0.60, 0.91],
259
+ grainScale: 0.045,
260
+ fractures: { mode: 'fissility-cross-joints', primaryCount: 5, branchCount: 3, width: [0.0005, 0.0024], depth: 0.025 },
261
+ strata: { frequency: 12.4, spacingJitter: 0.17, warp: 0.10, curvature: 0.025, colorStrength: 0.19, reliefStrength: 0.29, dark: [0.026, 0.025, 0.023], light: [0.39, 0.37, 0.32] },
262
+ }),
263
+ 'glassy-volcanic-vitrophyre': PROFILE({
264
+ label: 'Glassy volcanic vitrophyre',
265
+ lithology: 'vitrophyre / glass-rich volcanic rock',
266
+ fabric: 'glassy-volcanic-conchoidal',
267
+ baseDark: [0.010, 0.012, 0.014],
268
+ baseLight: [0.20, 0.17, 0.15],
269
+ mineralDark: [0.003, 0.004, 0.005],
270
+ mineralLight: [0.40, 0.32, 0.26],
271
+ baseTileMetres: 0.68,
272
+ heightMicroSpanMetres: 0.009,
273
+ normalStrength: 0.68,
274
+ roughness: [0.34, 0.70],
275
+ grainScale: 0.052,
276
+ fractures: { mode: 'conchoidal-fracture', primaryCount: 4, branchCount: 4, width: [0.0005, 0.0028], depth: 0.034 },
277
+ strata: null,
278
+ }),
279
+ 'gneiss-foliated': PROFILE({
280
+ label: 'Foliated gneiss',
281
+ lithology: 'compositionally banded gneiss',
282
+ fabric: 'crystalline-gneissic-foliation',
283
+ baseDark: [0.08, 0.085, 0.088],
284
+ baseLight: [0.55, 0.54, 0.50],
285
+ mineralDark: [0.018, 0.020, 0.022],
286
+ mineralLight: [0.78, 0.76, 0.69],
287
+ baseTileMetres: 1.12,
288
+ heightMicroSpanMetres: 0.018,
289
+ normalStrength: 0.93,
290
+ roughness: [0.54, 0.84],
291
+ grainScale: 0.075,
292
+ fractures: { mode: 'foliation-cross-joints', primaryCount: 4, branchCount: 3, width: [0.0008, 0.0035], depth: 0.034 },
293
+ strata: { frequency: 6.3, spacingJitter: 0.16, warp: 0.34, curvature: 0.10, colorStrength: 0.42, reliefStrength: 0.20, dark: [0.025, 0.028, 0.032], light: [0.78, 0.76, 0.69] },
294
+ }),
295
+ 'grey-siltstone-jointed': PROFILE({
296
+ label: 'Grey jointed siltstone',
297
+ lithology: 'fine grey siltstone with persistent joints',
298
+ fabric: 'fine-clastic-jointed',
299
+ baseDark: [0.105, 0.115, 0.115],
300
+ baseLight: [0.52, 0.54, 0.51],
301
+ mineralDark: [0.044, 0.049, 0.050],
302
+ mineralLight: [0.70, 0.71, 0.66],
303
+ baseTileMetres: 0.96,
304
+ heightMicroSpanMetres: 0.014,
305
+ normalStrength: 0.86,
306
+ roughness: [0.64, 0.92],
307
+ grainScale: 0.052,
308
+ fractures: { mode: 'orthogonal-joints', primaryCount: 5, branchCount: 4, width: [0.0008, 0.0038], depth: 0.038 },
309
+ strata: null,
310
+ }),
311
+ 'quartzite-jointed': PROFILE({
312
+ label: 'Hard jointed quartzite',
313
+ lithology: 'quartzite with sparse persistent joints',
314
+ fabric: 'crystalline-quartzite-jointed',
315
+ baseDark: [0.17, 0.16, 0.15],
316
+ baseLight: [0.69, 0.66, 0.61],
317
+ mineralDark: [0.07, 0.065, 0.061],
318
+ mineralLight: [0.88, 0.86, 0.81],
319
+ baseTileMetres: 1.08,
320
+ heightMicroSpanMetres: 0.014,
321
+ normalStrength: 0.89,
322
+ roughness: [0.50, 0.80],
323
+ grainScale: 0.058,
324
+ fractures: { mode: 'orthogonal-joints', primaryCount: 4, branchCount: 3, width: [0.0007, 0.0032], depth: 0.037 },
325
+ strata: null,
326
+ }),
327
+ 'schist-phyllite-cleavage': PROFILE({
328
+ label: 'Phyllitic cleavage fabric',
329
+ lithology: 'phyllite to fine schist with penetrative cleavage',
330
+ fabric: 'metamorphic-cleavage-foliation',
331
+ baseDark: [0.045, 0.055, 0.055],
332
+ baseLight: [0.31, 0.35, 0.34],
333
+ mineralDark: [0.014, 0.018, 0.019],
334
+ mineralLight: [0.54, 0.58, 0.55],
335
+ baseTileMetres: 0.72,
336
+ heightMicroSpanMetres: 0.011,
337
+ normalStrength: 0.78,
338
+ roughness: [0.43, 0.78],
339
+ grainScale: 0.043,
340
+ fractures: { mode: 'cleavage-cross-joints', primaryCount: 5, branchCount: 3, width: [0.0005, 0.0026], depth: 0.029 },
341
+ strata: { frequency: 10.1, spacingJitter: 0.14, warp: 0.17, curvature: 0.05, colorStrength: 0.16, reliefStrength: 0.23, dark: [0.020, 0.026, 0.027], light: [0.45, 0.49, 0.47] },
342
+ }),
343
+ 'silicic-lava-blocky': PROFILE({
344
+ label: 'Blocky silicic lava',
345
+ lithology: 'silicic lava with blocky carapace',
346
+ fabric: 'silicic-volcanic-blocky',
347
+ baseDark: [0.06, 0.050, 0.047],
348
+ baseLight: [0.39, 0.34, 0.31],
349
+ mineralDark: [0.020, 0.017, 0.016],
350
+ mineralLight: [0.62, 0.57, 0.52],
351
+ baseTileMetres: 1.22,
352
+ heightMicroSpanMetres: 0.025,
353
+ normalStrength: 1.18,
354
+ roughness: [0.62, 0.91],
355
+ grainScale: 0.069,
356
+ fractures: { mode: 'blocky-cooling-fracture', primaryCount: 6, branchCount: 5, width: [0.0010, 0.0046], depth: 0.050 },
357
+ strata: null,
358
+ }),
359
+ 'silicic-lava-flow-banded': PROFILE({
360
+ label: 'Flow-banded silicic lava',
361
+ lithology: 'silicic lava with coherent flow banding',
362
+ fabric: 'silicic-volcanic-flow-banded',
363
+ baseDark: [0.07, 0.050, 0.045],
364
+ baseLight: [0.48, 0.37, 0.31],
365
+ mineralDark: [0.022, 0.016, 0.015],
366
+ mineralLight: [0.70, 0.61, 0.53],
367
+ baseTileMetres: 1.16,
368
+ heightMicroSpanMetres: 0.020,
369
+ normalStrength: 0.98,
370
+ roughness: [0.54, 0.84],
371
+ grainScale: 0.062,
372
+ fractures: { mode: 'flow-band-cross-fractures', primaryCount: 4, branchCount: 3, width: [0.0008, 0.0035], depth: 0.036 },
373
+ strata: { frequency: 7.4, spacingJitter: 0.16, warp: 0.42, curvature: 0.13, colorStrength: 0.34, reliefStrength: 0.22, dark: [0.028, 0.018, 0.017], light: [0.67, 0.55, 0.46] },
374
+ }),
375
+ 'soft-bedded-mudstone': PROFILE({
376
+ label: 'Soft bedded mudstone',
377
+ lithology: 'thinly bedded mudstone and silt-rich weak sediment',
378
+ fabric: 'fine-clastic-soft-bedded',
379
+ baseDark: [0.15, 0.075, 0.043],
380
+ baseLight: [0.58, 0.32, 0.18],
381
+ mineralDark: [0.055, 0.027, 0.019],
382
+ mineralLight: [0.75, 0.48, 0.29],
383
+ baseTileMetres: 0.84,
384
+ heightMicroSpanMetres: 0.016,
385
+ normalStrength: 0.88,
386
+ roughness: [0.70, 0.96],
387
+ grainScale: 0.049,
388
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 3, branchCount: 3, width: [0.0008, 0.0035], depth: 0.027 },
389
+ strata: { frequency: 9.6, spacingJitter: 0.21, warp: 0.24, curvature: 0.08, colorStrength: 0.31, reliefStrength: 0.34, dark: [0.065, 0.026, 0.017], light: [0.72, 0.42, 0.24] },
390
+ }),
391
+ 'travertine-tufa-accretionary': PROFILE({
392
+ label: 'Accretionary travertine and tufa',
393
+ lithology: 'porous precipitated carbonate with accretionary laminae',
394
+ fabric: 'carbonate-accretionary-banded',
395
+ baseDark: [0.23, 0.16, 0.085],
396
+ baseLight: [0.81, 0.70, 0.49],
397
+ mineralDark: [0.10, 0.065, 0.037],
398
+ mineralLight: [0.92, 0.86, 0.69],
399
+ baseTileMetres: 0.78,
400
+ heightMicroSpanMetres: 0.027,
401
+ normalStrength: 1.12,
402
+ roughness: [0.63, 0.94],
403
+ grainScale: 0.056,
404
+ fractures: { mode: 'accretionary-shrinkage', primaryCount: 3, branchCount: 3, width: [0.0007, 0.0030], depth: 0.025 },
405
+ strata: { frequency: 8.7, spacingJitter: 0.20, warp: 0.31, curvature: 0.11, colorStrength: 0.28, reliefStrength: 0.31, dark: [0.12, 0.075, 0.040], light: [0.90, 0.79, 0.58] },
406
+ }),
407
+ 'viscous-lava-spine': PROFILE({
408
+ label: 'Dense viscous lava spine',
409
+ lithology: 'dense silicic to intermediate lava spine',
410
+ fabric: 'silicic-volcanic-spine-fractured',
411
+ baseDark: [0.035, 0.032, 0.031],
412
+ baseLight: [0.28, 0.26, 0.24],
413
+ mineralDark: [0.010, 0.009, 0.009],
414
+ mineralLight: [0.50, 0.47, 0.43],
415
+ baseTileMetres: 1.04,
416
+ heightMicroSpanMetres: 0.022,
417
+ normalStrength: 1.12,
418
+ roughness: [0.60, 0.89],
419
+ grainScale: 0.064,
420
+ fractures: { mode: 'spine-shear-fractures', primaryCount: 6, branchCount: 4, width: [0.0008, 0.0040], depth: 0.046 },
421
+ strata: null,
422
+ }),
423
+ 'volcanic-breccia': PROFILE({
424
+ label: 'Volcanic breccia',
425
+ lithology: 'angular volcanic fragments in a finer volcaniclastic matrix',
426
+ fabric: 'volcanic-breccia-clastic',
427
+ baseDark: [0.075, 0.055, 0.045],
428
+ baseLight: [0.43, 0.33, 0.26],
429
+ mineralDark: [0.022, 0.018, 0.016],
430
+ mineralLight: [0.64, 0.54, 0.44],
431
+ baseTileMetres: 0.90,
432
+ heightMicroSpanMetres: 0.026,
433
+ normalStrength: 1.16,
434
+ roughness: [0.65, 0.94],
435
+ grainScale: 0.083,
436
+ fractures: { mode: 'matrix-cross-joints', primaryCount: 4, branchCount: 4, width: [0.0010, 0.0042], depth: 0.038 },
437
+ strata: null,
438
+ }),
439
+ 'volcaniclastic-tuff': PROFILE({
440
+ label: 'Weakly bedded volcaniclastic tuff',
441
+ lithology: 'weathered ash-rich volcaniclastic tuff',
442
+ fabric: 'volcaniclastic-tuff-bedded',
443
+ baseDark: [0.14, 0.105, 0.072],
444
+ baseLight: [0.66, 0.54, 0.39],
445
+ mineralDark: [0.055, 0.039, 0.028],
446
+ mineralLight: [0.82, 0.73, 0.58],
447
+ baseTileMetres: 1.06,
448
+ heightMicroSpanMetres: 0.023,
449
+ normalStrength: 1.00,
450
+ roughness: [0.69, 0.95],
451
+ grainScale: 0.060,
452
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 4, branchCount: 3, width: [0.0009, 0.0040], depth: 0.034 },
453
+ strata: { frequency: 5.4, spacingJitter: 0.18, warp: 0.20, curvature: 0.06, colorStrength: 0.19, reliefStrength: 0.25, dark: [0.085, 0.056, 0.037], light: [0.77, 0.65, 0.48] },
454
+ }),
455
+ 'fault-scarp-regolith': PROFILE({
456
+ label: 'Admission-limited fault-scarp regolith',
457
+ lithology: 'neutral weathered bedrock and regolith; exact substrate not admitted',
458
+ fabric: 'neutral-fault-regolith',
459
+ admissionLimits: ['Do not infer a named host lithology.', 'Do not paint a continuous fault plane as a homogeneous material band.'],
460
+ baseDark: [0.11, 0.095, 0.076],
461
+ baseLight: [0.50, 0.45, 0.36],
462
+ mineralDark: [0.042, 0.036, 0.030],
463
+ mineralLight: [0.65, 0.61, 0.52],
464
+ baseTileMetres: 1.15,
465
+ heightMicroSpanMetres: 0.019,
466
+ normalStrength: 0.96,
467
+ roughness: [0.68, 0.95],
468
+ grainScale: 0.072,
469
+ fractures: { mode: 'fault-damage-fractures', primaryCount: 5, branchCount: 5, width: [0.0009, 0.0045], depth: 0.041 },
470
+ strata: null,
471
+ }),
472
+ 'neutral-coastal-bedrock': PROFILE({
473
+ label: 'Admission-limited coastal bedrock',
474
+ lithology: 'neutral coastal bedrock; exact lithology not admitted',
475
+ fabric: 'neutral-coastal-weathered',
476
+ admissionLimits: ['Retain only salt/wave weathering supported by the source.', 'Do not infer sandstone bedding.'],
477
+ baseDark: [0.10, 0.105, 0.102],
478
+ baseLight: [0.48, 0.49, 0.45],
479
+ mineralDark: [0.038, 0.041, 0.040],
480
+ mineralLight: [0.64, 0.65, 0.59],
481
+ baseTileMetres: 1.42,
482
+ heightMicroSpanMetres: 0.024,
483
+ normalStrength: 1.07,
484
+ roughness: [0.63, 0.93],
485
+ grainScale: 0.070,
486
+ fractures: { mode: 'coastal-relict-joints', primaryCount: 4, branchCount: 4, width: [0.0010, 0.0048], depth: 0.044 },
487
+ strata: null,
488
+ }),
489
+ 'neutral-massive-bedrock': PROFILE({
490
+ label: 'Admission-limited massive bedrock',
491
+ lithology: 'neutral massive bedrock; exact lithology not admitted',
492
+ fabric: 'neutral-massive-jointed',
493
+ admissionLimits: ['Do not infer sedimentary bedding, foliation, or volcanic flow bands.'],
494
+ baseDark: [0.12, 0.12, 0.115],
495
+ baseLight: [0.53, 0.52, 0.48],
496
+ mineralDark: [0.045, 0.045, 0.043],
497
+ mineralLight: [0.69, 0.68, 0.62],
498
+ baseTileMetres: 1.50,
499
+ heightMicroSpanMetres: 0.021,
500
+ normalStrength: 1.00,
501
+ roughness: [0.62, 0.90],
502
+ grainScale: 0.077,
503
+ fractures: { mode: 'weathered-relict-joints', primaryCount: 4, branchCount: 3, width: [0.0010, 0.0042], depth: 0.038 },
504
+ strata: null,
505
+ }),
506
+ 'neutral-sedimentary-bedded': PROFILE({
507
+ label: 'Admission-limited bedded sedimentary rock',
508
+ lithology: 'neutral bedded sedimentary rock; exact lithology not admitted',
509
+ fabric: 'neutral-sedimentary-bedded',
510
+ admissionLimits: ['Preserve only coherent bedding supported by the source.', 'Do not infer sandstone, limestone, or mudstone mineralogy.'],
511
+ baseDark: [0.13, 0.115, 0.095],
512
+ baseLight: [0.57, 0.53, 0.44],
513
+ mineralDark: [0.050, 0.043, 0.035],
514
+ mineralLight: [0.72, 0.68, 0.58],
515
+ baseTileMetres: 1.38,
516
+ heightMicroSpanMetres: 0.019,
517
+ normalStrength: 0.93,
518
+ roughness: [0.66, 0.93],
519
+ grainScale: 0.065,
520
+ fractures: { mode: 'bedding-cross-joints', primaryCount: 4, branchCount: 3, width: [0.0009, 0.0040], depth: 0.035 },
521
+ strata: { frequency: 5.8, spacingJitter: 0.15, warp: 0.19, curvature: 0.055, colorStrength: 0.14, reliefStrength: 0.22, dark: [0.085, 0.071, 0.056], light: [0.68, 0.63, 0.53] },
522
+ }),
523
+ 'neutral-structural-clast': PROFILE({
524
+ label: 'Admission-limited structural clast',
525
+ lithology: 'neutral joint- or fabric-bounded clast; exact lithology not admitted',
526
+ fabric: 'neutral-structural-clast',
527
+ admissionLimits: ['Retain planar structure without naming its mineralogy or parent fabric.'],
528
+ baseDark: [0.12, 0.12, 0.115],
529
+ baseLight: [0.55, 0.54, 0.50],
530
+ mineralDark: [0.045, 0.045, 0.043],
531
+ mineralLight: [0.70, 0.69, 0.64],
532
+ baseTileMetres: 0.92,
533
+ heightMicroSpanMetres: 0.016,
534
+ normalStrength: 0.90,
535
+ roughness: [0.61, 0.89],
536
+ grainScale: 0.065,
537
+ fractures: { mode: 'orthogonal-joints', primaryCount: 4, branchCount: 3, width: [0.0008, 0.0035], depth: 0.034 },
538
+ strata: null,
539
+ }),
540
+ 'diabase-intrusion-host-contact': PROFILE({
541
+ label: 'Diabase intrusion and host contact',
542
+ lithology: 'dark diabase intrusion in a compositionally distinct host',
543
+ fabric: 'semantic-composite-intrusion-contact',
544
+ requiresSemanticRegions: true,
545
+ semanticRegionRoles: ['diabase-intrusion', 'host-bedrock', 'contact-zone'],
546
+ baseDark: [0.025, 0.030, 0.031], baseLight: [0.25, 0.27, 0.25],
547
+ mineralDark: [0.008, 0.010, 0.010], mineralLight: [0.45, 0.48, 0.43],
548
+ baseTileMetres: 1.10, heightMicroSpanMetres: 0.020, normalStrength: 1.05,
549
+ roughness: [0.58, 0.90], grainScale: 0.068,
550
+ fractures: { mode: 'contact-cross-fractures', primaryCount: 4, branchCount: 3, width: [0.0008, 0.0038], depth: 0.040 },
551
+ strata: null,
552
+ }),
553
+ 'quartz-vein-phyllite-contact': PROFILE({
554
+ label: 'Quartz vein in phyllite contact',
555
+ lithology: 'quartz fracture fill in a phyllitic host',
556
+ fabric: 'semantic-composite-vein-contact',
557
+ requiresSemanticRegions: true,
558
+ semanticRegionRoles: ['quartz-vein', 'phyllite-host', 'contact-zone'],
559
+ baseDark: [0.045, 0.052, 0.052], baseLight: [0.36, 0.39, 0.37],
560
+ mineralDark: [0.014, 0.018, 0.018], mineralLight: [0.82, 0.82, 0.77],
561
+ baseTileMetres: 0.76, heightMicroSpanMetres: 0.012, normalStrength: 0.82,
562
+ roughness: [0.42, 0.81], grainScale: 0.046,
563
+ fractures: { mode: 'vein-contact-fractures', primaryCount: 4, branchCount: 4, width: [0.0005, 0.0028], depth: 0.030 },
564
+ strata: null,
565
+ }),
566
+ 'mixed-clast-abraded': PROFILE({
567
+ label: 'Mixed abraded clast assembly',
568
+ lithology: 'multiple transported clast lithologies',
569
+ fabric: 'semantic-composite-mixed-abraded-clasts',
570
+ requiresSemanticRegions: true,
571
+ semanticRegionRoles: ['clast-material-groups', 'matrix-or-ground'],
572
+ baseDark: [0.12, 0.11, 0.095], baseLight: [0.58, 0.53, 0.44],
573
+ mineralDark: [0.040, 0.038, 0.034], mineralLight: [0.76, 0.71, 0.62],
574
+ baseTileMetres: 0.72, heightMicroSpanMetres: 0.013, normalStrength: 0.78,
575
+ roughness: [0.49, 0.84], grainScale: 0.062,
576
+ fractures: { mode: 'transport-impact', primaryCount: 3, branchCount: 2, width: [0.0007, 0.0028], depth: 0.021 },
577
+ strata: null,
578
+ }),
579
+ 'mixed-clast-angular': PROFILE({
580
+ label: 'Mixed angular clast assembly',
581
+ lithology: 'multiple angular source-rock lithologies',
582
+ fabric: 'semantic-composite-mixed-angular-clasts',
583
+ requiresSemanticRegions: true,
584
+ semanticRegionRoles: ['clast-material-groups', 'matrix-or-ground'],
585
+ baseDark: [0.10, 0.095, 0.085], baseLight: [0.52, 0.49, 0.43],
586
+ mineralDark: [0.034, 0.032, 0.029], mineralLight: [0.70, 0.67, 0.59],
587
+ baseTileMetres: 0.86, heightMicroSpanMetres: 0.021, normalStrength: 1.06,
588
+ roughness: [0.62, 0.93], grainScale: 0.070,
589
+ fractures: { mode: 'orthogonal-joints', primaryCount: 5, branchCount: 4, width: [0.0009, 0.0040], depth: 0.040 },
590
+ strata: null,
591
+ }),
592
+ 'mixed-clast-glacial': PROFILE({
593
+ label: 'Mixed glacial clast assembly',
594
+ lithology: 'multiple glacially transported clast lithologies',
595
+ fabric: 'semantic-composite-mixed-glacial-clasts',
596
+ requiresSemanticRegions: true,
597
+ semanticRegionRoles: ['clast-material-groups', 'matrix-or-ground', 'abraded-or-plucked-faces'],
598
+ baseDark: [0.105, 0.105, 0.098], baseLight: [0.54, 0.53, 0.48],
599
+ mineralDark: [0.038, 0.039, 0.037], mineralLight: [0.72, 0.71, 0.65],
600
+ baseTileMetres: 0.94, heightMicroSpanMetres: 0.017, normalStrength: 0.91,
601
+ roughness: [0.55, 0.88], grainScale: 0.067,
602
+ fractures: { mode: 'glacial-relict-fractures', primaryCount: 4, branchCount: 3, width: [0.0008, 0.0034], depth: 0.030 },
603
+ strata: null,
604
+ }),
605
+ });
606
+
607
+ const SEMANTIC_ROLE = ({
608
+ allowedProfileIds,
609
+ effect = null,
610
+ minimumCoverageFraction = 0.005,
611
+ minimumInstances = 1,
612
+ }) => Object.freeze({
613
+ allowedProfileIds: Object.freeze([...allowedProfileIds]),
614
+ effect: effect ? Object.freeze({ ...effect }) : null,
615
+ minimumCoverageFraction,
616
+ minimumInstances,
617
+ });
618
+
619
+ const COMPOSITE_RECIPE = ({ roles, distinctMaterialRole = null, minimumDistinctMaterials = 1 }) => Object.freeze({
620
+ coordinateSpace: 'geometry-uv0',
621
+ distinctMaterialRole,
622
+ minimumDistinctMaterials,
623
+ roles: Object.freeze(Object.fromEntries(Object.entries(roles).map(([role, contract]) => [
624
+ role,
625
+ SEMANTIC_ROLE(contract),
626
+ ]))),
627
+ });
628
+
629
+ /**
630
+ * Composite profiles may only be synthesized from author-authored UV0 region
631
+ * masks bound to the exact edited geometry. These recipes define which
632
+ * independently generated single-lithology surfaces each admitted role may
633
+ * use. They do not infer contacts or clast membership from color or shape.
634
+ */
635
+ export const C8_COMPOSITE_SURFACE_RECIPES = Object.freeze({
636
+ 'diabase-intrusion-host-contact': COMPOSITE_RECIPE({
637
+ roles: {
638
+ 'diabase-intrusion': {
639
+ allowedProfileIds: ['basalt-mafic-cooling'],
640
+ minimumCoverageFraction: 0.04,
641
+ },
642
+ 'host-bedrock': {
643
+ allowedProfileIds: [
644
+ 'neutral-massive-bedrock', 'neutral-sedimentary-bedded',
645
+ 'red-sandstone-bedded', 'grey-siltstone-jointed',
646
+ ],
647
+ minimumCoverageFraction: 0.04,
648
+ },
649
+ 'contact-zone': {
650
+ allowedProfileIds: ['neutral-structural-clast', 'fault-scarp-regolith'],
651
+ effect: { aoMultiplier: 0.88, heightMultiplier: 0.72, roughnessOffset: 0.055 },
652
+ minimumCoverageFraction: 0.005,
653
+ },
654
+ },
655
+ }),
656
+ 'quartz-vein-phyllite-contact': COMPOSITE_RECIPE({
657
+ roles: {
658
+ 'quartz-vein': {
659
+ allowedProfileIds: ['quartzite-jointed'],
660
+ effect: { heightMultiplier: 0.82, roughnessOffset: -0.08 },
661
+ minimumCoverageFraction: 0.01,
662
+ },
663
+ 'phyllite-host': {
664
+ allowedProfileIds: ['schist-phyllite-cleavage'],
665
+ minimumCoverageFraction: 0.04,
666
+ },
667
+ 'contact-zone': {
668
+ allowedProfileIds: ['neutral-structural-clast'],
669
+ effect: { aoMultiplier: 0.90, heightMultiplier: 0.70, roughnessOffset: 0.04 },
670
+ minimumCoverageFraction: 0.005,
671
+ },
672
+ },
673
+ }),
674
+ 'mixed-clast-abraded': COMPOSITE_RECIPE({
675
+ distinctMaterialRole: 'clast-material-groups',
676
+ minimumDistinctMaterials: 2,
677
+ roles: {
678
+ 'clast-material-groups': {
679
+ allowedProfileIds: [
680
+ 'weathered-monzogranite', 'basalt-mafic-cooling', 'quartzite-jointed',
681
+ 'river-abraded-sandstone', 'kaibab-limestone-ledge',
682
+ ],
683
+ effect: { heightMultiplier: 0.70, roughnessOffset: -0.035 },
684
+ minimumCoverageFraction: 0.04,
685
+ minimumInstances: 2,
686
+ },
687
+ 'matrix-or-ground': {
688
+ allowedProfileIds: ['neutral-structural-clast', 'grey-siltstone-jointed'],
689
+ effect: { aoMultiplier: 0.90, heightMultiplier: 0.64, roughnessOffset: 0.07 },
690
+ minimumCoverageFraction: 0.01,
691
+ },
692
+ },
693
+ }),
694
+ 'mixed-clast-angular': COMPOSITE_RECIPE({
695
+ distinctMaterialRole: 'clast-material-groups',
696
+ minimumDistinctMaterials: 2,
697
+ roles: {
698
+ 'clast-material-groups': {
699
+ allowedProfileIds: [
700
+ 'coarse-granite-jointed', 'basalt-mafic-cooling', 'quartzite-jointed',
701
+ 'red-sandstone-bedded', 'kaibab-limestone-ledge',
702
+ ],
703
+ minimumCoverageFraction: 0.04,
704
+ minimumInstances: 2,
705
+ },
706
+ 'matrix-or-ground': {
707
+ allowedProfileIds: ['neutral-structural-clast', 'fault-scarp-regolith'],
708
+ effect: { aoMultiplier: 0.88, heightMultiplier: 0.68, roughnessOffset: 0.075 },
709
+ minimumCoverageFraction: 0.01,
710
+ },
711
+ },
712
+ }),
713
+ 'mixed-clast-glacial': COMPOSITE_RECIPE({
714
+ distinctMaterialRole: 'clast-material-groups',
715
+ minimumDistinctMaterials: 2,
716
+ roles: {
717
+ 'clast-material-groups': {
718
+ allowedProfileIds: [
719
+ 'weathered-monzogranite', 'basalt-mafic-cooling', 'quartzite-jointed',
720
+ 'red-sandstone-bedded', 'kaibab-limestone-ledge',
721
+ ],
722
+ effect: { heightMultiplier: 0.82, roughnessOffset: -0.015 },
723
+ minimumCoverageFraction: 0.04,
724
+ minimumInstances: 2,
725
+ },
726
+ 'matrix-or-ground': {
727
+ allowedProfileIds: ['fault-scarp-regolith', 'neutral-structural-clast'],
728
+ effect: { aoMultiplier: 0.86, heightMultiplier: 0.60, roughnessOffset: 0.085 },
729
+ minimumCoverageFraction: 0.01,
730
+ },
731
+ 'abraded-or-plucked-faces': {
732
+ allowedProfileIds: [
733
+ 'weathered-monzogranite', 'basalt-mafic-cooling', 'quartzite-jointed',
734
+ 'red-sandstone-bedded', 'kaibab-limestone-ledge',
735
+ ],
736
+ effect: { heightMultiplier: 0.48, roughnessOffset: -0.065 },
737
+ minimumCoverageFraction: 0.005,
738
+ },
739
+ },
740
+ }),
741
+ });
742
+
743
+ const BINDING = (familyId, profileId, surfaceRationale) => Object.freeze({
744
+ familyId,
745
+ profileId,
746
+ surfaceRationale,
747
+ });
748
+
749
+ export const C8_FIRST12_ASSET_SURFACE_BINDINGS = Object.freeze({
750
+ 'hoodoo-caprock': BINDING('fins-spires-and-hoodoos', 'claron-carbonate-bedded', 'Warm iron-stained carbonate beds, differential resistance, cross-joints, and granular weathering support a caprock hoodoo without deriving its silhouette.'),
751
+ 'tor-block-pile': BINDING('residuals-and-outcrops', 'coarse-granite-jointed', 'Coarse crystalline variation and intersecting joint traces support a granite tor; the editable high source owns every block boundary.'),
752
+ 'boulder-rounded': BINDING('detached-clasts', 'weathered-monzogranite', 'Granular weathering, sparse relict joints, and broad mineral patches complement rounded granite morphology without inflating it.'),
753
+ 'block-jointed': BINDING('detached-clasts', 'coarse-granite-jointed', 'Coarse mineral fabric and unequal joint traces reinforce, but never generate, the joint-bounded planar mass.'),
754
+ 'boulder-river-worn': BINDING('detached-clasts', 'river-abraded-sandstone', 'Subdued sandstone bedding, elongated abrasion scars, and lower-amplitude relief preserve the transported river-worn read.'),
755
+ 'slab-bedded': BINDING('detached-clasts', 'red-sandstone-bedded', 'Non-periodic iron-rich beds and sparse cross-fractures align with a thin sandstone slab while the mesh owns its broken perimeter.'),
756
+ 'outcrop-jointed': BINDING('residuals-and-outcrops', 'coarse-granite-jointed', 'Coarse granite and coherent multi-directional joints support an attached jointed outcrop without turning it into detached cubes.'),
757
+ 'outcrop-bedded': BINDING('residuals-and-outcrops', 'red-sandstone-bedded', 'Warped, terminating sandstone beds and cross-joints support one attached strike-and-dip mass rather than uniform rings.'),
758
+ 'ledge-resistant': BINDING('rock-surfaces-and-steps', 'kaibab-limestone-ledge', 'Pale resistant carbonate bedding, pitting, and terminating seams support the hard ledge above recessed rock.'),
759
+ 'pillar-residual': BINDING('fins-spires-and-hoodoos', 'quartz-sandstone-pillar', 'Iron-stained quartz-sandstone bedding and vertical joints support a continuous residual pillar; texture does not manufacture shelves.'),
760
+ 'sea-stack': BINDING('coastal-residuals', 'coastal-sandstone-stack', 'Salt-weathered sandstone beds, cavities, and cross-joints support a detached coastal remnant while the high source owns its wave-cut base.'),
761
+ 'volcanic-neck': BINDING('volcanic-and-cooling-forms', 'phonolite-porphyry-cooling', 'Dark porphyritic variation and non-uniform cooling joints support a resistant volcanic plug without implying cone symmetry.'),
762
+ });
763
+
764
+ function clamp(value, minimum, maximum) {
765
+ return Math.min(maximum, Math.max(minimum, value));
766
+ }
767
+
768
+ function clamp01(value) {
769
+ return clamp(value, 0, 1);
770
+ }
771
+
772
+ function lerp(left, right, amount) {
773
+ return left + ((right - left) * amount);
774
+ }
775
+
776
+ function smoothstep(minimum, maximum, value) {
777
+ const amount = clamp01((value - minimum) / Math.max(1e-9, maximum - minimum));
778
+ return amount * amount * (3 - (2 * amount));
779
+ }
780
+
781
+ function linearToSrgb(value) {
782
+ const channel = clamp01(value);
783
+ return channel < 0.0031308
784
+ ? channel * 12.92
785
+ : (1.055 * (channel ** (1 / 2.4))) - 0.055;
786
+ }
787
+
788
+ function srgbToLinear(value) {
789
+ const channel = clamp01(value);
790
+ return channel <= 0.04045
791
+ ? channel / 12.92
792
+ : ((channel + 0.055) / 1.055) ** 2.4;
793
+ }
794
+
795
+ function fnv(value, hash) {
796
+ return Math.imul((hash ^ value) >>> 0, 16777619) >>> 0;
797
+ }
798
+
799
+ export function c8First12SurfaceSeed(value) {
800
+ let hash = 2166136261 >>> 0;
801
+ for (const character of String(value ?? '')) hash = fnv(character.charCodeAt(0), hash);
802
+ return hash >>> 0;
803
+ }
804
+
805
+ function resolveC8First12Seed(seed, assetId) {
806
+ if (seed === null) return c8First12SurfaceSeed(`${assetId}:c8-first12-surface-v1`);
807
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xFFFFFFFF) {
808
+ throw new RangeError('Nature Reference Rock surface seed must be an integer from 0 through 4294967295.');
809
+ }
810
+ return seed >>> 0;
811
+ }
812
+
813
+ function assetBinding(assetId, bindingOverride = null, profileIdOverride = null) {
814
+ const binding = bindingOverride ?? (profileIdOverride
815
+ ? { familyId: 'unresolved-family', profileId: profileIdOverride, surfaceRationale: 'Explicit production lithology binding; morphology remains owned by the edited high source.' }
816
+ : C8_FIRST12_ASSET_SURFACE_BINDINGS[assetId]);
817
+ if (!binding) throw new RangeError(`Unknown Nature Reference Rock first-12 asset “${String(assetId)}”.`);
818
+ if (!C8_FIRST12_LITHOLOGY_PROFILES[binding.profileId]) {
819
+ throw new RangeError(`Unknown Nature Reference Rock lithology profile “${String(binding.profileId)}”.`);
820
+ }
821
+ return binding;
822
+ }
823
+
824
+ function assertHomogeneousSurfaceAllowed(binding, profile, operation, semanticRegions = null) {
825
+ if (binding.requiresSemanticRegions === true || profile.requiresSemanticRegions === true) {
826
+ if (semanticRegions) return;
827
+ const roles = profile.semanticRegionRoles?.join(', ') ?? 'explicit material regions';
828
+ throw new RangeError(
829
+ `Nature Reference Rock ${operation} for composite profile “${binding.profileId}” requires semantic regions (${roles}); homogeneous synthesis is forbidden.`,
830
+ );
831
+ }
832
+ }
833
+
834
+ function sha256Bytes(bytes) {
835
+ return sha256Hex(bytes);
836
+ }
837
+
838
+ // Compact JSON masks retain authored pixels without serializing typed-array keys.
839
+ function packSemanticMask(mask) {
840
+ const runs = [];
841
+ for (let start = 0; start < mask.length;) {
842
+ let end = start + 1;
843
+ while (end < mask.length && mask[end] === mask[start]) end += 1;
844
+ runs.push(end - start, mask[start]);
845
+ start = end;
846
+ }
847
+ return { encoding: 'rle-u8-v1', length: mask.length, runs };
848
+ }
849
+
850
+ function unpackSemanticMask(mask, pixelCount) {
851
+ if (mask instanceof Uint8Array && mask.length === pixelCount) return mask;
852
+ if (mask?.encoding !== 'rle-u8-v1' || mask.length !== pixelCount
853
+ || !Array.isArray(mask.runs) || mask.runs.length % 2 || mask.runs.length > pixelCount * 2) {
854
+ throw new RangeError('Semantic mask requires exact raw bytes or the supported packed encoding.');
855
+ }
856
+ const decoded = new Uint8Array(pixelCount);
857
+ let offset = 0;
858
+ for (let index = 0; index < mask.runs.length; index += 2) {
859
+ const count = mask.runs[index], value = mask.runs[index + 1];
860
+ if (!Number.isSafeInteger(count) || count <= 0 || offset + count > pixelCount
861
+ || !Number.isInteger(value) || value < 0 || value > 255) {
862
+ throw new RangeError('Invalid semantic mask run.');
863
+ }
864
+ decoded.fill(value, offset, offset + count);
865
+ offset += count;
866
+ }
867
+ if (offset !== pixelCount) throw new RangeError('Semantic mask runs do not cover the declared dimensions.');
868
+ return decoded;
869
+ }
870
+
871
+ function portableSemanticRegions(regionSet) {
872
+ return {
873
+ ...semanticRegionAudit(regionSet),
874
+ regions: regionSet.regions.map(({ mask, effect, ...region }) => ({ ...region, mask: packSemanticMask(mask) })),
875
+ };
876
+ }
877
+
878
+ function normalizeCompositeSemanticRegions({
879
+ binding,
880
+ geometrySha256,
881
+ profile,
882
+ semanticRegions,
883
+ size,
884
+ }) {
885
+ const recipe = C8_COMPOSITE_SURFACE_RECIPES[binding.profileId];
886
+ if (!recipe) throw new RangeError(`Missing composite surface recipe for “${binding.profileId}”.`);
887
+ assertHomogeneousSurfaceAllowed(binding, profile, 'semantic-region synthesis', semanticRegions);
888
+ if (semanticRegions.schema !== C8_SEMANTIC_SURFACE_REGIONS_SCHEMA
889
+ || semanticRegions.version !== C8_SEMANTIC_SURFACE_REGIONS_VERSION) {
890
+ throw new RangeError(
891
+ `Nature Reference Rock composite surface requires ${C8_SEMANTIC_SURFACE_REGIONS_SCHEMA} v${C8_SEMANTIC_SURFACE_REGIONS_VERSION}.`,
892
+ );
893
+ }
894
+ if (semanticRegions.coordinateSpace !== recipe.coordinateSpace) {
895
+ throw new RangeError(`Nature Reference Rock composite semantic masks must use ${recipe.coordinateSpace}.`);
896
+ }
897
+ if (!/^[a-f0-9]{64}$/u.test(String(geometrySha256 ?? ''))
898
+ || semanticRegions.geometrySha256 !== geometrySha256) {
899
+ throw new RangeError('Nature Reference Rock composite semantic masks must be SHA-256-bound to the exact edited geometry.');
900
+ }
901
+ if (semanticRegions.width !== size || semanticRegions.height !== size) {
902
+ throw new RangeError(`Nature Reference Rock composite semantic masks must exactly match the ${size}x${size} output map.`);
903
+ }
904
+ if (!Array.isArray(semanticRegions.regions) || semanticRegions.regions.length < 2) {
905
+ throw new RangeError('Nature Reference Rock composite semantic surface requires an explicit authored region array.');
906
+ }
907
+ const pixelCount = size * size;
908
+ const ids = new Set();
909
+ const coverageByRole = new Map();
910
+ const regionsByRole = new Map();
911
+ const coverageSum = new Float32Array(pixelCount);
912
+ const normalizedRegions = semanticRegions.regions.map((region, index) => {
913
+ const id = String(region?.id ?? '');
914
+ const role = String(region?.role ?? '');
915
+ const roleContract = recipe.roles[role];
916
+ if (!id || ids.has(id)) throw new RangeError(`Nature Reference Rock composite semantic region ${index} requires a unique non-empty id.`);
917
+ ids.add(id);
918
+ if (!roleContract) throw new RangeError(`Nature Reference Rock composite semantic region “${id}” has forbidden role “${role}”.`);
919
+ if (region.sourceKind !== 'authored-geometry-semantic-mask'
920
+ || typeof region.geometryRegionId !== 'string'
921
+ || region.geometryRegionId.length === 0) {
922
+ throw new RangeError(`Nature Reference Rock composite semantic region “${id}” must identify its authored geometry region source.`);
923
+ }
924
+ const materialProfileId = String(region.materialProfileId ?? '');
925
+ if (!roleContract.allowedProfileIds.includes(materialProfileId)
926
+ || C8_FIRST12_LITHOLOGY_PROFILES[materialProfileId]?.requiresSemanticRegions === true) {
927
+ throw new RangeError(
928
+ `Nature Reference Rock composite semantic region “${id}” cannot use material profile “${materialProfileId}” for role “${role}”.`,
929
+ );
930
+ }
931
+ const mask = unpackSemanticMask(region.mask, pixelCount);
932
+ if (region.maskSha256 && region.maskSha256 !== sha256Bytes(mask)) {
933
+ throw new RangeError(`Semantic mask hash mismatch for “${id}”.`);
934
+ }
935
+ let coverageWeight = 0;
936
+ for (let pixel = 0; pixel < pixelCount; pixel += 1) {
937
+ const weight = mask[pixel] / 255;
938
+ coverageWeight += weight;
939
+ coverageSum[pixel] += weight;
940
+ }
941
+ const coverageFraction = coverageWeight / pixelCount;
942
+ if (coverageFraction < 0.005) {
943
+ throw new RangeError(`Nature Reference Rock composite semantic region “${id}” has only dummy coverage (${coverageFraction}).`);
944
+ }
945
+ coverageByRole.set(role, (coverageByRole.get(role) ?? 0) + coverageFraction);
946
+ const roleRegions = regionsByRole.get(role) ?? [];
947
+ roleRegions.push({ materialProfileId });
948
+ regionsByRole.set(role, roleRegions);
949
+ return Object.freeze({
950
+ coverageFraction,
951
+ effect: roleContract.effect,
952
+ geometryRegionId: region.geometryRegionId,
953
+ id,
954
+ mask,
955
+ maskSha256: sha256Bytes(mask),
956
+ materialProfileId,
957
+ role,
958
+ sourceKind: region.sourceKind,
959
+ });
960
+ });
961
+ for (const [role, roleContract] of Object.entries(recipe.roles)) {
962
+ const roleRegions = regionsByRole.get(role) ?? [];
963
+ if (roleRegions.length < roleContract.minimumInstances) {
964
+ throw new RangeError(`Nature Reference Rock composite semantic role “${role}” requires at least ${roleContract.minimumInstances} authored regions.`);
965
+ }
966
+ if ((coverageByRole.get(role) ?? 0) < roleContract.minimumCoverageFraction) {
967
+ throw new RangeError(`Nature Reference Rock composite semantic role “${role}” has insufficient authored coverage.`);
968
+ }
969
+ }
970
+ if (recipe.distinctMaterialRole) {
971
+ const distinct = new Set((regionsByRole.get(recipe.distinctMaterialRole) ?? [])
972
+ .map(({ materialProfileId }) => materialProfileId));
973
+ if (distinct.size < recipe.minimumDistinctMaterials) {
974
+ throw new RangeError(
975
+ `Nature Reference Rock composite semantic role “${recipe.distinctMaterialRole}” requires at least ${recipe.minimumDistinctMaterials} distinct admitted materials.`,
976
+ );
977
+ }
978
+ }
979
+ let uncoveredPixels = 0;
980
+ for (const coverage of coverageSum) if (coverage <= (1 / 255)) uncoveredPixels += 1;
981
+ if (uncoveredPixels > 0) {
982
+ throw new RangeError(`Nature Reference Rock composite semantic masks leave ${uncoveredPixels} output pixels without an authored material region.`);
983
+ }
984
+ const contractHashParts = [
985
+ `${semanticRegions.schema}:${semanticRegions.version}:${geometrySha256}:${size}`,
986
+ ];
987
+ for (const region of normalizedRegions) {
988
+ contractHashParts.push(
989
+ `${region.id}:${region.role}:${region.materialProfileId}:${region.geometryRegionId}:${region.maskSha256}`,
990
+ );
991
+ }
992
+ return Object.freeze({
993
+ contractSha256: sha256Hex(contractHashParts.join('')),
994
+ coordinateSpace: recipe.coordinateSpace,
995
+ geometrySha256,
996
+ height: size,
997
+ profileId: binding.profileId,
998
+ regions: Object.freeze(normalizedRegions),
999
+ roles: Object.freeze(Object.fromEntries([...coverageByRole].map(([role, coverageFraction]) => [
1000
+ role,
1001
+ Object.freeze({
1002
+ coverageFraction,
1003
+ regionCount: regionsByRole.get(role).length,
1004
+ }),
1005
+ ]))),
1006
+ schema: C8_SEMANTIC_SURFACE_REGIONS_SCHEMA,
1007
+ version: C8_SEMANTIC_SURFACE_REGIONS_VERSION,
1008
+ width: size,
1009
+ });
1010
+ }
1011
+
1012
+ function semanticRegionAudit(regionSet) {
1013
+ return Object.freeze({
1014
+ contractSha256: regionSet.contractSha256,
1015
+ coordinateSpace: regionSet.coordinateSpace,
1016
+ geometrySha256: regionSet.geometrySha256,
1017
+ height: regionSet.height,
1018
+ profileId: regionSet.profileId,
1019
+ regions: Object.freeze(regionSet.regions.map((region) => Object.freeze({
1020
+ coverageFraction: region.coverageFraction,
1021
+ geometryRegionId: region.geometryRegionId,
1022
+ id: region.id,
1023
+ maskSha256: region.maskSha256,
1024
+ materialProfileId: region.materialProfileId,
1025
+ role: region.role,
1026
+ sourceKind: region.sourceKind,
1027
+ }))),
1028
+ roles: regionSet.roles,
1029
+ schema: regionSet.schema,
1030
+ version: regionSet.version,
1031
+ width: regionSet.width,
1032
+ });
1033
+ }
1034
+
1035
+ function dimensionsFromBounds(editedBoundsMetres) {
1036
+ if (Array.isArray(editedBoundsMetres)) {
1037
+ return { dimensions: editedBoundsMetres.slice(0, 3).map(Number), maximum: null, minimum: null };
1038
+ }
1039
+ const minimum = editedBoundsMetres?.minimum ?? editedBoundsMetres?.min ?? null;
1040
+ const maximum = editedBoundsMetres?.maximum ?? editedBoundsMetres?.max ?? null;
1041
+ if (Array.isArray(minimum) && Array.isArray(maximum)) {
1042
+ const resolvedMinimum = minimum.slice(0, 3).map(Number);
1043
+ const resolvedMaximum = maximum.slice(0, 3).map(Number);
1044
+ return {
1045
+ dimensions: resolvedMaximum.map((value, index) => value - resolvedMinimum[index]),
1046
+ maximum: resolvedMaximum,
1047
+ minimum: resolvedMinimum,
1048
+ };
1049
+ }
1050
+ const dimensions = editedBoundsMetres?.dimensionsMetres
1051
+ ?? editedBoundsMetres?.dimensions
1052
+ ?? [editedBoundsMetres?.width, editedBoundsMetres?.height, editedBoundsMetres?.depth];
1053
+ return { dimensions: Array.from(dimensions ?? []).slice(0, 3).map(Number), maximum: null, minimum: null };
1054
+ }
1055
+
1056
+ export function normalizeC8First12EditedBounds(editedBoundsMetres) {
1057
+ const resolved = dimensionsFromBounds(editedBoundsMetres);
1058
+ if (resolved.dimensions.length !== 3
1059
+ || resolved.dimensions.some((value) => !Number.isFinite(value) || value <= 0)) {
1060
+ throw new RangeError('Nature Reference Rock surface projection requires positive edited X/Y/Z bounds in metres.');
1061
+ }
1062
+ if (resolved.minimum && resolved.maximum
1063
+ && resolved.maximum.some((value, index) => !Number.isFinite(value)
1064
+ || !Number.isFinite(resolved.minimum[index])
1065
+ || value <= resolved.minimum[index])) {
1066
+ throw new RangeError('Nature Reference Rock edited bounds require finite maximum values greater than minimum values.');
1067
+ }
1068
+ return Object.freeze({
1069
+ axisOrder: 'x-y-z',
1070
+ dimensionsMetres: Object.freeze(resolved.dimensions),
1071
+ maximumMetres: resolved.maximum ? Object.freeze(resolved.maximum) : null,
1072
+ minimumMetres: resolved.minimum ? Object.freeze(resolved.minimum) : null,
1073
+ upAxis: 'y',
1074
+ });
1075
+ }
1076
+
1077
+ export function resolveC8First12Projection({ assetId, binding: bindingOverride = null, editedBoundsMetres, profileId = null } = {}) {
1078
+ const binding = assetBinding(assetId, bindingOverride, profileId);
1079
+ const profile = C8_FIRST12_LITHOLOGY_PROFILES[binding.profileId];
1080
+ if (profile.requiresSemanticRegions === true) {
1081
+ throw new RangeError(
1082
+ `Nature Reference Rock projection for composite profile “${binding.profileId}” requires semantic regions; resolve it through the geometry-bound surface specification.`,
1083
+ );
1084
+ }
1085
+ const bounds = normalizeC8First12EditedBounds(editedBoundsMetres);
1086
+ const dimensions = bounds.dimensionsMetres;
1087
+ const sorted = [...dimensions].sort((left, right) => left - right);
1088
+ const geometricMean = (dimensions[0] * dimensions[1] * dimensions[2]) ** (1 / 3);
1089
+ const characteristicMetres = Math.sqrt(Math.max(geometricMean * sorted[1], 1e-9));
1090
+ const formationScale = clamp(Math.sqrt(characteristicMetres / 1.5), 0.82, 3.6);
1091
+ const scaleMetres = profile.baseTileMetres * formationScale;
1092
+ const fingerprint = c8First12SurfaceSeed(JSON.stringify(bounds)).toString(16).padStart(8, '0');
1093
+ const mode = profile.strata ? 'directional-bedding' : 'triplanar';
1094
+ const upAxis = bounds.upAxis;
1095
+ const mapRoleProjection = Object.freeze(Object.fromEntries(
1096
+ C8_FIRST12_MAP_ROLES.map((role) => [role, Object.freeze({
1097
+ mode,
1098
+ upAxis,
1099
+ })]),
1100
+ ));
1101
+ return Object.freeze({
1102
+ axisScaleMetres: Object.freeze([scaleMetres, scaleMetres, scaleMetres]),
1103
+ bounds,
1104
+ boundsRole: 'derive metre texture scale and edit invalidation only; bounds do not set the world-space sampling origin',
1105
+ boundsFingerprint: fingerprint,
1106
+ characteristicMetres,
1107
+ coordinateFrame: 'absolute-world-position-y-up',
1108
+ mapRoleProjection,
1109
+ mode,
1110
+ recalculatedFromEditedBounds: true,
1111
+ scaleMetres,
1112
+ structuralFabricProjection: Object.freeze({
1113
+ coherentBedPlaneRequired: profile.strata !== null,
1114
+ directGenericSymmetricTriplanarAllowed: profile.strata === null,
1115
+ directionalFabricHandoff: profile.strata
1116
+ ? 'Runtime directional-bedding sampler preserves one world-up bed coordinate across both lateral projections and suppresses the top-axis projection.'
1117
+ : 'Generic world-space triplanar is permitted after normal/tangent integrity review.',
1118
+ genericSymmetricTriplanarCannotApproveBeddedAssets: profile.strata !== null,
1119
+ runtimeAxisConditionedSamplerImplemented: profile.strata !== null,
1120
+ }),
1121
+ textureCoordinateScalePerMetre: Object.freeze([1 / scaleMetres, 1 / scaleMetres, 1 / scaleMetres]),
1122
+ tileSpan: Object.freeze(dimensions.map((value) => value / scaleMetres)),
1123
+ translationChangesWorldAnchoredPhase: true,
1124
+ upAxis,
1125
+ directionalBeddingAxisUv: mode === 'directional-bedding' ? Object.freeze({
1126
+ distributedTopNormalWeight: 'abs(normal.y) split equally across xProjection and zProjection before normalization',
1127
+ suppressedProjection: 'yProjection/XZ',
1128
+ xProjection: 'u=worldPosition.z/scaleMetres,v=worldPosition.y/scaleMetres',
1129
+ zProjection: 'u=worldPosition.x/scaleMetres,v=worldPosition.y/scaleMetres',
1130
+ }) : null,
1131
+ triplanarAxisUv: Object.freeze({
1132
+ xProjection: 'u=worldPosition.z/scaleMetres,v=worldPosition.y/scaleMetres',
1133
+ yProjection: 'u=worldPosition.x/scaleMetres,v=worldPosition.z/scaleMetres',
1134
+ zProjection: 'u=worldPosition.x/scaleMetres,v=worldPosition.y/scaleMetres',
1135
+ }),
1136
+ });
1137
+ }
1138
+
1139
+ function latticeHash(x, y, period, seed) {
1140
+ const wrappedX = ((x % period) + period) % period;
1141
+ const wrappedY = ((y % period) + period) % period;
1142
+ const value = Math.sin((wrappedX * 127.1) + (wrappedY * 311.7) + (seed * 0.0137)) * 43758.5453123;
1143
+ return value - Math.floor(value);
1144
+ }
1145
+
1146
+ function tileNoise(u, v, frequency, seed) {
1147
+ const x = u * frequency;
1148
+ const y = v * frequency;
1149
+ const x0 = Math.floor(x);
1150
+ const y0 = Math.floor(y);
1151
+ const fx0 = x - x0;
1152
+ const fy0 = y - y0;
1153
+ const fx = fx0 * fx0 * (3 - (2 * fx0));
1154
+ const fy = fy0 * fy0 * (3 - (2 * fy0));
1155
+ const a = latticeHash(x0, y0, frequency, seed);
1156
+ const b = latticeHash(x0 + 1, y0, frequency, seed);
1157
+ const c = latticeHash(x0, y0 + 1, frequency, seed);
1158
+ const d = latticeHash(x0 + 1, y0 + 1, frequency, seed);
1159
+ return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
1160
+ }
1161
+
1162
+ function tileFbm(u, v, baseFrequency, seed, octaves = 3) {
1163
+ let amplitude = 0.56;
1164
+ let total = 0;
1165
+ let weight = 0;
1166
+ for (let octave = 0; octave < octaves; octave += 1) {
1167
+ total += tileNoise(u, v, baseFrequency * (2 ** octave), seed + (octave * 37)) * amplitude;
1168
+ weight += amplitude;
1169
+ amplitude *= 0.48;
1170
+ }
1171
+ return total / weight;
1172
+ }
1173
+
1174
+ function randomSequence(seed) {
1175
+ let state = seed >>> 0;
1176
+ return () => {
1177
+ state = (state + 0x6D2B79F5) >>> 0;
1178
+ let value = state;
1179
+ value = Math.imul(value ^ (value >>> 15), value | 1);
1180
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
1181
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
1182
+ };
1183
+ }
1184
+
1185
+ function fractureAngle(mode, index, random) {
1186
+ if (mode === 'orthogonal-joints') {
1187
+ return (index % 2 === 0 ? Math.PI * 0.5 : 0.12) + ((random() - 0.5) * 0.46);
1188
+ }
1189
+ if (mode === 'bedding-cross-joints') {
1190
+ return (index % 3 === 0 ? 0.28 : Math.PI * 0.5) + ((random() - 0.5) * 0.58);
1191
+ }
1192
+ if (mode === 'cooling-joints') return (Math.PI * 0.5) + ((random() - 0.5) * 0.38);
1193
+ if (mode === 'transport-impact') return 0.30 + ((random() - 0.5) * 0.82);
1194
+ if (mode === 'weathered-relict-joints') return (random() * Math.PI) + ((index % 2) * 0.24);
1195
+ if (mode === 'fissility-cross-joints') {
1196
+ return (index % 3 === 0 ? Math.PI * 0.5 : 0.04) + ((random() - 0.5) * 0.32);
1197
+ }
1198
+ if (mode === 'foliation-cross-joints' || mode === 'cleavage-cross-joints') {
1199
+ return (index % 3 === 0 ? Math.PI * 0.5 : 0.18) + ((random() - 0.5) * 0.48);
1200
+ }
1201
+ if (mode === 'flow-band-cross-fractures') {
1202
+ return (index % 2 === 0 ? Math.PI * 0.5 : 0.32) + ((random() - 0.5) * 0.62);
1203
+ }
1204
+ if (mode === 'spine-shear-fractures') {
1205
+ return (Math.PI * 0.5) + ((random() - 0.5) * 0.54);
1206
+ }
1207
+ return random() * Math.PI;
1208
+ }
1209
+
1210
+ function createFractureNetwork(profile, seed) {
1211
+ const random = randomSequence(seed ^ 0xA5831F29);
1212
+ const segments = [];
1213
+ const widths = [];
1214
+ const primaryPaths = [];
1215
+ for (let index = 0; index < profile.fractures.primaryCount; index += 1) {
1216
+ const angle = fractureAngle(profile.fractures.mode, index, random);
1217
+ const centreX = 0.12 + (random() * 0.76);
1218
+ const centreY = 0.12 + (random() * 0.76);
1219
+ const length = 0.19 + (random() * 0.43);
1220
+ const width = lerp(profile.fractures.width[0], profile.fractures.width[1], random() ** 1.7);
1221
+ const tangentX = Math.cos(angle);
1222
+ const tangentY = Math.sin(angle);
1223
+ const perpendicularX = -tangentY;
1224
+ const perpendicularY = tangentX;
1225
+ const points = [];
1226
+ const pointCount = 6;
1227
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
1228
+ const amount = pointIndex / (pointCount - 1);
1229
+ const along = (amount - 0.5) * length;
1230
+ const taper = Math.sin(amount * Math.PI);
1231
+ const jitter = (random() - 0.5) * 0.082 * taper;
1232
+ points.push({
1233
+ x: centreX + (tangentX * along) + (perpendicularX * jitter),
1234
+ y: centreY + (tangentY * along) + (perpendicularY * jitter),
1235
+ });
1236
+ }
1237
+ for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex += 1) {
1238
+ const segmentWidth = width * lerp(1.12, 0.62, pointIndex / (points.length - 2));
1239
+ widths.push(segmentWidth);
1240
+ segments.push({
1241
+ ...points[pointIndex],
1242
+ ax: points[pointIndex].x,
1243
+ ay: points[pointIndex].y,
1244
+ bx: points[pointIndex + 1].x,
1245
+ by: points[pointIndex + 1].y,
1246
+ kind: 'primary',
1247
+ width: segmentWidth,
1248
+ });
1249
+ }
1250
+ primaryPaths.push({ angle, points, width });
1251
+ }
1252
+ for (let branchIndex = 0; branchIndex < profile.fractures.branchCount; branchIndex += 1) {
1253
+ const parent = primaryPaths[branchIndex % primaryPaths.length];
1254
+ const pointIndex = 1 + Math.floor(random() * (parent.points.length - 2));
1255
+ const origin = parent.points[pointIndex];
1256
+ const direction = parent.angle + (branchIndex % 2 === 0 ? 1 : -1) * lerp(0.58, 1.12, random());
1257
+ const branchLength = 0.075 + (random() * 0.19);
1258
+ const bend = (random() - 0.5) * 0.42;
1259
+ const middle = {
1260
+ x: origin.x + (Math.cos(direction) * branchLength * 0.52),
1261
+ y: origin.y + (Math.sin(direction) * branchLength * 0.52),
1262
+ };
1263
+ const end = {
1264
+ x: middle.x + (Math.cos(direction + bend) * branchLength * 0.48),
1265
+ y: middle.y + (Math.sin(direction + bend) * branchLength * 0.48),
1266
+ };
1267
+ const branchWidth = parent.width * lerp(0.32, 0.64, random());
1268
+ for (const [start, finish, taper] of [[origin, middle, 1], [middle, end, 0.62]]) {
1269
+ const segmentWidth = branchWidth * taper;
1270
+ widths.push(segmentWidth);
1271
+ segments.push({
1272
+ ax: start.x,
1273
+ ay: start.y,
1274
+ bx: finish.x,
1275
+ by: finish.y,
1276
+ kind: 'branch',
1277
+ width: segmentWidth,
1278
+ });
1279
+ }
1280
+ }
1281
+ return {
1282
+ audit: Object.freeze({
1283
+ branchCount: profile.fractures.branchCount,
1284
+ mode: profile.fractures.mode,
1285
+ primaryCount: profile.fractures.primaryCount,
1286
+ segmentCount: segments.length,
1287
+ widthMaximum: Math.max(...widths),
1288
+ widthMinimum: Math.min(...widths),
1289
+ widthRatio: Math.max(...widths) / Math.max(1e-9, Math.min(...widths)),
1290
+ }),
1291
+ segments,
1292
+ };
1293
+ }
1294
+
1295
+ function pointToSegmentDistance(px, py, segment) {
1296
+ const dx = segment.bx - segment.ax;
1297
+ const dy = segment.by - segment.ay;
1298
+ const amount = clamp01((((px - segment.ax) * dx) + ((py - segment.ay) * dy))
1299
+ / Math.max(1e-9, (dx * dx) + (dy * dy)));
1300
+ return Math.hypot(px - (segment.ax + (dx * amount)), py - (segment.ay + (dy * amount)));
1301
+ }
1302
+
1303
+ function toroidalPointToSegmentDistance(px, py, segment) {
1304
+ const centreX = (segment.ax + segment.bx) * 0.5;
1305
+ const centreY = (segment.ay + segment.by) * 0.5;
1306
+ const wrappedX = px + Math.round(centreX - px);
1307
+ const wrappedY = py + Math.round(centreY - py);
1308
+ return pointToSegmentDistance(wrappedX, wrappedY, segment);
1309
+ }
1310
+
1311
+ function fractureField(u, v, network, seed) {
1312
+ // A small periodic domain warp keeps joints coherent while avoiding the
1313
+ // ruler-straight/polyline read that was rejected in the 480-rock review.
1314
+ const warpedU = u + ((tileFbm(u, v, 7, seed + 773, 2) - 0.5) * 0.036);
1315
+ const warpedV = v + ((tileFbm(u, v, 7, seed + 787, 2) - 0.5) * 0.036);
1316
+ const localWidth = lerp(0.58, 1.32, tileFbm(u, v, 13, seed + 797, 2));
1317
+ let fracture = 0;
1318
+ for (const segment of network.segments) {
1319
+ const distance = toroidalPointToSegmentDistance(warpedU, warpedV, segment);
1320
+ const width = segment.width * localWidth;
1321
+ const line = 1 - smoothstep(width, width * 3.1, distance);
1322
+ fracture = Math.max(fracture, line);
1323
+ }
1324
+ const breakup = lerp(0.03, 1, smoothstep(0.30, 0.70, tileFbm(u, v, 11, seed + 811, 2)));
1325
+ const intermittent = lerp(0.12, 1, smoothstep(0.24, 0.74, tileNoise(u, v, 23, seed + 829)));
1326
+ return fracture * breakup * intermittent;
1327
+ }
1328
+
1329
+ function strataField(u, v, strata, seed) {
1330
+ if (!strata) return { dark: 0, light: 0, relief: 0, value: 0.5 };
1331
+ const cross = u;
1332
+ const along = v;
1333
+ const bandCount = Math.max(3, Math.round(strata.frequency));
1334
+ const warp = (tileFbm(u, v, 3, seed + 347, 3) - 0.5) * strata.warp;
1335
+ const spacingWarp = Math.sin((cross * 3 + (tileNoise(u, v, 5, seed + 359) * 0.28)) * Math.PI * 2)
1336
+ * strata.spacingJitter;
1337
+ const curvature = Math.sin((along * 2 + (tileNoise(u, v, 4, seed + 367) * 0.12)) * Math.PI * 2)
1338
+ * strata.curvature;
1339
+ // Whole-band cycles on the along axis keep the field tileable. Periodic
1340
+ // warp bends and changes spacing without forcing a diagonal grille.
1341
+ const coordinate = (along * bandCount) + warp + spacingWarp + curvature;
1342
+ const bandIndex = Math.floor(coordinate);
1343
+ const phase = coordinate - bandIndex;
1344
+ const wave = 0.5 + (0.5 * Math.sin(coordinate * Math.PI * 2));
1345
+ const termination = smoothstep(0.18, 0.78, tileFbm(u, v, 5, seed + 389, 2));
1346
+ const darkRandom = latticeHash(bandIndex, 0, bandCount, seed + 401);
1347
+ const lightRandom = latticeHash(bandIndex, 1, bandCount, seed + 409);
1348
+ const darkWidth = lerp(0.018, 0.095, latticeHash(bandIndex, 2, bandCount, seed + 419));
1349
+ const lightWidth = lerp(0.010, 0.050, latticeHash(bandIndex, 3, bandCount, seed + 431));
1350
+ const lightCentre = lerp(0.32, 0.68, latticeHash(bandIndex, 4, bandCount, seed + 439));
1351
+ const boundaryDistance = Math.min(phase, 1 - phase);
1352
+ const lightDistance = Math.abs(phase - lightCentre);
1353
+ const darkContinuity = smoothstep(0.22, 0.76, termination + ((darkRandom - 0.5) * 0.44));
1354
+ const lightContinuity = smoothstep(0.20, 0.78, (1 - termination) + ((lightRandom - 0.5) * 0.38));
1355
+ // Each bed has its own width/strength and a regional continuity mask, so
1356
+ // bands pinch, fade, and terminate instead of repeating as wallpaper.
1357
+ const dark = (1 - smoothstep(darkWidth, darkWidth * 2.1, boundaryDistance))
1358
+ * darkContinuity * lerp(0.22, 1, darkRandom ** 1.4);
1359
+ const light = (1 - smoothstep(lightWidth, lightWidth * 2.2, lightDistance))
1360
+ * lightContinuity * lerp(0.16, 1, lightRandom ** 1.3);
1361
+ return {
1362
+ dark,
1363
+ light,
1364
+ relief: (((wave - 0.5) * 0.016)
1365
+ + (light * 0.044) - (dark * 0.032)) * strata.reliefStrength,
1366
+ value: wave,
1367
+ };
1368
+ }
1369
+
1370
+ function surfaceField(u, v, profile, seed, grainFrequency) {
1371
+ const macro = tileFbm(u, v, 3, seed + 17, 4);
1372
+ const meso = tileFbm(u, v, 9, seed + 43, 3);
1373
+ const micro = tileFbm(u, v, Math.max(13, Math.round(grainFrequency / 3)), seed + 71, 2);
1374
+ const fine = tileNoise(u, v, grainFrequency, seed + 101);
1375
+ let height = ((macro - 0.5) * 0.56) + ((meso - 0.5) * 0.30)
1376
+ + ((micro - 0.5) * 0.11) + ((fine - 0.5) * 0.03);
1377
+ if (profile.fabric.includes('carbonate')) {
1378
+ const pit = (1 - smoothstep(0.11, 0.31, micro))
1379
+ * smoothstep(0.34, 0.72, tileFbm(u, v, 7, seed + 131, 2));
1380
+ height -= pit * 0.095;
1381
+ }
1382
+ if (profile.fabric.includes('coastal')) {
1383
+ const tafoni = (1 - smoothstep(0.13, 0.37, meso))
1384
+ * smoothstep(0.42, 0.75, tileFbm(u, v, 5, seed + 149, 2));
1385
+ height -= tafoni * 0.115;
1386
+ }
1387
+ if (profile.fabric.includes('abraded')) {
1388
+ const scarCoordinate = (u * 3) + v + ((macro - 0.5) * 0.7);
1389
+ const impactScar = (1 - smoothstep(0.04, 0.20, Math.abs(Math.sin(scarCoordinate * Math.PI * 2))))
1390
+ * smoothstep(0.48, 0.78, meso);
1391
+ height -= impactScar * 0.025;
1392
+ }
1393
+ if (profile.fabric.includes('crystalline') || profile.fabric.includes('porphyritic')) {
1394
+ const crystal = smoothstep(0.91, 0.985, fine)
1395
+ * smoothstep(0.42, 0.75, tileFbm(u, v, 5, seed + 163, 2));
1396
+ height += crystal * 0.020;
1397
+ }
1398
+ return { fine, height, macro, meso, micro };
1399
+ }
1400
+
1401
+ function mixColor(left, right, amount) {
1402
+ return left.map((channel, index) => lerp(channel, right[index], amount));
1403
+ }
1404
+
1405
+ function coefficientOfVariation(values) {
1406
+ if (values.length < 2) return 0;
1407
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
1408
+ if (mean <= 1e-9) return 0;
1409
+ const variance = values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / values.length;
1410
+ return Math.sqrt(variance) / mean;
1411
+ }
1412
+
1413
+ function measureStrataVariation(strataDark, strataLight, size) {
1414
+ const rowMeans = new Float32Array(size);
1415
+ for (let y = 0; y < size; y += 1) {
1416
+ let total = 0;
1417
+ for (let x = 0; x < size; x += 1) {
1418
+ const offset = (y * size) + x;
1419
+ total += Math.max(strataDark[offset], strataLight[offset]);
1420
+ }
1421
+ rowMeans[y] = total / size;
1422
+ }
1423
+ const peaks = [];
1424
+ for (let y = 0; y < size; y += 1) {
1425
+ const previous = rowMeans[(y + size - 1) % size];
1426
+ const current = rowMeans[y];
1427
+ const next = rowMeans[(y + 1) % size];
1428
+ if (current >= 0.025 && current > previous && current >= next) peaks.push({ strength: current, y });
1429
+ }
1430
+ const spacings = peaks.map((peak, index) => {
1431
+ const next = peaks[(index + 1) % peaks.length];
1432
+ return ((next?.y ?? peak.y) - peak.y + size) % size;
1433
+ }).filter((spacing) => spacing > 0);
1434
+ const spacingCoefficientOfVariation = coefficientOfVariation(spacings);
1435
+ const strengthCoefficientOfVariation = coefficientOfVariation(peaks.map((peak) => peak.strength));
1436
+ return Object.freeze({
1437
+ measuredNonUniform: peaks.length >= 3
1438
+ && (spacingCoefficientOfVariation >= 0.045 || strengthCoefficientOfVariation >= 0.075),
1439
+ peakCount: peaks.length,
1440
+ spacingCoefficientOfVariation,
1441
+ strengthCoefficientOfVariation,
1442
+ });
1443
+ }
1444
+
1445
+ function createCompositeGeologyMapData({
1446
+ assetId,
1447
+ binding,
1448
+ geometrySha256,
1449
+ profile,
1450
+ projectionScaleMetres,
1451
+ resolvedSeed,
1452
+ semanticRegions,
1453
+ size,
1454
+ }) {
1455
+ const regionSet = normalizeCompositeSemanticRegions({
1456
+ binding,
1457
+ geometrySha256,
1458
+ profile,
1459
+ semanticRegions,
1460
+ size,
1461
+ });
1462
+ const constituents = regionSet.regions.map((region) => {
1463
+ const regionSeed = c8First12SurfaceSeed(
1464
+ `${resolvedSeed}:${assetId}:${region.id}:${region.materialProfileId}`,
1465
+ );
1466
+ const maps = createC8First12GeologyMapData({
1467
+ assetId: `${assetId}:${region.id}`,
1468
+ binding: Object.freeze({
1469
+ familyId: binding.familyId,
1470
+ profileId: region.materialProfileId,
1471
+ surfaceRationale: `Authored composite region ${region.id} (${region.role}).`,
1472
+ }),
1473
+ projectionScaleMetres,
1474
+ seed: regionSeed,
1475
+ size,
1476
+ });
1477
+ return Object.freeze({ maps, region, regionSeed });
1478
+ });
1479
+ const pixelCount = size * size;
1480
+ const maps = Object.fromEntries(C8_FIRST12_MAP_ROLES.map((role) => [role, new Uint8Array(pixelCount * 4)]));
1481
+ const physicalSpanMetres = Math.max(...constituents.map(({ maps: generated }) => (
1482
+ generated.heightMicroDecode.physicalSpanMetres
1483
+ )));
1484
+ const physicalHeights = new Float32Array(pixelCount);
1485
+ let byteHash = 2166136261 >>> 0;
1486
+ let normalMinimumZ = 1;
1487
+ let roughnessMinimum = Infinity;
1488
+ let roughnessMaximum = -Infinity;
1489
+ for (let pixel = 0; pixel < pixelCount; pixel += 1) {
1490
+ const byteOffset = pixel * 4;
1491
+ let totalWeight = 0;
1492
+ let linearRed = 0;
1493
+ let linearGreen = 0;
1494
+ let linearBlue = 0;
1495
+ let normalX = 0;
1496
+ let normalY = 0;
1497
+ let normalZ = 0;
1498
+ let physicalHeight = 0;
1499
+ let ao = 0;
1500
+ let roughness = 0;
1501
+ for (const constituent of constituents) {
1502
+ const weight = constituent.region.mask[pixel] / 255;
1503
+ if (weight <= 0) continue;
1504
+ totalWeight += weight;
1505
+ const effect = constituent.region.effect ?? {};
1506
+ const base = constituent.maps.maps.baseColor;
1507
+ linearRed += srgbToLinear(base[byteOffset] / 255) * weight;
1508
+ linearGreen += srgbToLinear(base[byteOffset + 1] / 255) * weight;
1509
+ linearBlue += srgbToLinear(base[byteOffset + 2] / 255) * weight;
1510
+ const normal = constituent.maps.maps.normalGL;
1511
+ const reliefMultiplier = effect.heightMultiplier ?? 1;
1512
+ normalX += (((normal[byteOffset] / 255) * 2) - 1) * reliefMultiplier * weight;
1513
+ normalY += (((normal[byteOffset + 1] / 255) * 2) - 1) * reliefMultiplier * weight;
1514
+ normalZ += (((normal[byteOffset + 2] / 255) * 2) - 1) * weight;
1515
+ const heightSample = constituent.maps.maps.heightMicro[byteOffset] / 255;
1516
+ physicalHeight += ((heightSample - 0.5)
1517
+ * constituent.maps.heightMicroDecode.physicalSpanMetres
1518
+ * reliefMultiplier) * weight;
1519
+ const constituentAo = constituent.maps.maps.ao[byteOffset] / 255;
1520
+ ao += clamp01(constituentAo * (effect.aoMultiplier ?? 1)) * weight;
1521
+ const constituentRoughness = constituent.maps.maps.roughness[byteOffset] / 255;
1522
+ roughness += clamp01(constituentRoughness + (effect.roughnessOffset ?? 0)) * weight;
1523
+ }
1524
+ const inverseWeight = 1 / totalWeight;
1525
+ const colorBytes = [linearRed, linearGreen, linearBlue]
1526
+ .map((channel) => Math.round(linearToSrgb(channel * inverseWeight) * 255));
1527
+ normalX *= inverseWeight;
1528
+ normalY *= inverseWeight;
1529
+ normalZ *= inverseWeight;
1530
+ const normalLength = Math.max(1e-9, Math.hypot(normalX, normalY, normalZ));
1531
+ normalX /= normalLength;
1532
+ normalY /= normalLength;
1533
+ normalZ /= normalLength;
1534
+ normalMinimumZ = Math.min(normalMinimumZ, normalZ);
1535
+ physicalHeight *= inverseWeight;
1536
+ physicalHeights[pixel] = physicalHeight;
1537
+ ao = clamp01(ao * inverseWeight);
1538
+ roughness = clamp01(roughness * inverseWeight);
1539
+ roughnessMinimum = Math.min(roughnessMinimum, roughness);
1540
+ roughnessMaximum = Math.max(roughnessMaximum, roughness);
1541
+ const normalBytes = [normalX, normalY, normalZ]
1542
+ .map((channel) => Math.round((channel * 0.5 + 0.5) * 255));
1543
+ const heightByte = Math.round(clamp01((physicalHeight / physicalSpanMetres) + 0.5) * 255);
1544
+ const aoByte = Math.round(ao * 255);
1545
+ const roughnessByte = Math.round(roughness * 255);
1546
+ const smoothnessByte = 255 - roughnessByte;
1547
+ const values = {
1548
+ ao: [aoByte, aoByte, aoByte, 255],
1549
+ baseColor: [...colorBytes, 255],
1550
+ heightMicro: [heightByte, heightByte, heightByte, 255],
1551
+ normalGL: [...normalBytes, 255],
1552
+ orm: [aoByte, roughnessByte, 0, 255],
1553
+ roughness: [roughnessByte, roughnessByte, roughnessByte, 255],
1554
+ smoothness: [smoothnessByte, smoothnessByte, smoothnessByte, 255],
1555
+ };
1556
+ for (const [role, channels] of Object.entries(values)) maps[role].set(channels, byteOffset);
1557
+ for (const role of C8_FIRST12_MAP_ROLES) {
1558
+ for (let channel = 0; channel < 4; channel += 1) byteHash = fnv(values[role][channel], byteHash);
1559
+ }
1560
+ }
1561
+ let horizontalEdgeDelta = 0;
1562
+ let verticalEdgeDelta = 0;
1563
+ for (let index = 0; index < size; index += 1) {
1564
+ horizontalEdgeDelta += Math.abs(physicalHeights[index * size]
1565
+ - physicalHeights[(index * size) + size - 1]) / physicalSpanMetres;
1566
+ verticalEdgeDelta += Math.abs(physicalHeights[index]
1567
+ - physicalHeights[((size - 1) * size) + index]) / physicalSpanMetres;
1568
+ }
1569
+ return Object.freeze({
1570
+ audit: Object.freeze({
1571
+ byteHash: byteHash.toString(16).padStart(8, '0'),
1572
+ constituentSurfaces: Object.freeze(constituents.map(({ maps: generated, region, regionSeed }) => Object.freeze({
1573
+ byteHash: generated.audit.byteHash,
1574
+ id: region.id,
1575
+ materialProfileId: region.materialProfileId,
1576
+ role: region.role,
1577
+ seed: regionSeed,
1578
+ }))),
1579
+ mapRoles: C8_FIRST12_MAP_ROLES,
1580
+ normalMinimumZ,
1581
+ patternChecks: Object.freeze({
1582
+ authoredSemanticRegionBoundaries: true,
1583
+ homogeneousCompositeForbidden: true,
1584
+ uniformGridPatternForbidden: true,
1585
+ }),
1586
+ profileId: binding.profileId,
1587
+ projectionScaleMetres: projectionScaleMetres ?? null,
1588
+ roughnessRange: Object.freeze([roughnessMinimum, roughnessMaximum]),
1589
+ seed: resolvedSeed,
1590
+ semanticRegions: semanticRegionAudit(regionSet),
1591
+ size,
1592
+ tileEdgeMeanAbsoluteDelta: Object.freeze({
1593
+ horizontal: horizontalEdgeDelta / size,
1594
+ vertical: verticalEdgeDelta / size,
1595
+ }),
1596
+ }),
1597
+ heightMicroDecode: Object.freeze({
1598
+ encoding: 'UNORM8',
1599
+ formula: '(sample - 0.5) * physicalSpanMetres',
1600
+ midlevel: 0.5,
1601
+ physicalSpanMetres,
1602
+ role: 'bounded material/surface relief; not silhouette authority',
1603
+ }),
1604
+ maps: Object.freeze(maps),
1605
+ normalGLDerivation: Object.freeze({
1606
+ compositeBlend: 'decode tangent NormalGL, apply admitted role relief, weight, and renormalize',
1607
+ encoding: 'tangent-space OpenGL (+Y), RGB UNORM8',
1608
+ source: 'geometry-bound semantic masks plus independently generated single-lithology HeightMicro surfaces',
1609
+ }),
1610
+ });
1611
+ }
1612
+
1613
+ /**
1614
+ * Generate independent, AO-free BaseColor and PBR support channels. The output
1615
+ * is a tileable material field. It cannot replace a real high-to-LOD signed
1616
+ * residual bake and never mutates geometry.
1617
+ */
1618
+ export function createC8First12GeologyMapData({
1619
+ assetId,
1620
+ binding: bindingOverride = null,
1621
+ geometrySha256 = null,
1622
+ profileId = null,
1623
+ projectionScaleMetres = null,
1624
+ seed = null,
1625
+ semanticRegions = null,
1626
+ size = 1024,
1627
+ } = {}) {
1628
+ const binding = assetBinding(assetId, bindingOverride, profileId);
1629
+ const profile = C8_FIRST12_LITHOLOGY_PROFILES[binding.profileId];
1630
+ assertHomogeneousSurfaceAllowed(binding, profile, 'map generation', semanticRegions);
1631
+ if (!Number.isInteger(size) || size < 64 || size > 4096) {
1632
+ throw new RangeError('Nature Reference Rock map size must be an integer from 64 to 4096; production packages enforce 1024+.');
1633
+ }
1634
+ const resolvedSeed = resolveC8First12Seed(seed, assetId);
1635
+ if (profile.requiresSemanticRegions === true) {
1636
+ return createCompositeGeologyMapData({
1637
+ assetId,
1638
+ binding,
1639
+ geometrySha256,
1640
+ profile,
1641
+ projectionScaleMetres,
1642
+ resolvedSeed,
1643
+ semanticRegions,
1644
+ size,
1645
+ });
1646
+ }
1647
+ const resolvedProjectionScale = projectionScaleMetres === null
1648
+ ? profile.baseTileMetres
1649
+ : Number(projectionScaleMetres);
1650
+ if (!Number.isFinite(resolvedProjectionScale) || resolvedProjectionScale <= 0) {
1651
+ throw new RangeError('Nature Reference Rock map generation requires a positive projection scale in metres.');
1652
+ }
1653
+ const pixelCount = size * size;
1654
+ const grainFrequency = clamp(Math.round(resolvedProjectionScale / profile.grainScale), 16, 96);
1655
+ const heightRaw = new Float32Array(pixelCount);
1656
+ const value = new Float32Array(pixelCount);
1657
+ const macro = new Float32Array(pixelCount);
1658
+ const meso = new Float32Array(pixelCount);
1659
+ const micro = new Float32Array(pixelCount);
1660
+ const fine = new Float32Array(pixelCount);
1661
+ const fracture = new Float32Array(pixelCount);
1662
+ const strataDark = new Float32Array(pixelCount);
1663
+ const strataLight = new Float32Array(pixelCount);
1664
+ const network = createFractureNetwork(profile, resolvedSeed);
1665
+ let minimumHeight = Infinity;
1666
+ let maximumHeight = -Infinity;
1667
+ let fractureCoverage = 0;
1668
+ let strataCoverage = 0;
1669
+ for (let y = 0; y < size; y += 1) {
1670
+ for (let x = 0; x < size; x += 1) {
1671
+ const offset = (y * size) + x;
1672
+ const u = x / size;
1673
+ const v = y / size;
1674
+ const field = surfaceField(u, v, profile, resolvedSeed, grainFrequency);
1675
+ const bed = strataField(u, v, profile.strata, resolvedSeed);
1676
+ const cracks = fractureField(u, v, network, resolvedSeed);
1677
+ const resolvedHeight = field.height + bed.relief - (cracks * profile.fractures.depth);
1678
+ heightRaw[offset] = resolvedHeight;
1679
+ macro[offset] = field.macro;
1680
+ meso[offset] = field.meso;
1681
+ micro[offset] = field.micro;
1682
+ fine[offset] = field.fine;
1683
+ fracture[offset] = cracks;
1684
+ strataDark[offset] = bed.dark;
1685
+ strataLight[offset] = bed.light;
1686
+ value[offset] = clamp01(0.5 + ((field.macro - 0.5) * 0.45)
1687
+ + ((field.meso - 0.5) * 0.31) + ((field.micro - 0.5) * 0.18)
1688
+ + ((field.fine - 0.5) * 0.06));
1689
+ minimumHeight = Math.min(minimumHeight, resolvedHeight);
1690
+ maximumHeight = Math.max(maximumHeight, resolvedHeight);
1691
+ if (cracks > 0.18) fractureCoverage += 1;
1692
+ if (bed.dark > 0.12 || bed.light > 0.12) strataCoverage += 1;
1693
+ }
1694
+ }
1695
+ const heightRange = Math.max(1e-9, maximumHeight - minimumHeight);
1696
+ const strataVariation = profile.strata
1697
+ ? measureStrataVariation(strataDark, strataLight, size)
1698
+ : Object.freeze({
1699
+ measuredNonUniform: true,
1700
+ peakCount: 0,
1701
+ spacingCoefficientOfVariation: null,
1702
+ strengthCoefficientOfVariation: null,
1703
+ });
1704
+ const heightNormalized = new Float32Array(pixelCount);
1705
+ for (let index = 0; index < pixelCount; index += 1) {
1706
+ heightNormalized[index] = clamp01((heightRaw[index] - minimumHeight) / heightRange);
1707
+ }
1708
+ let horizontalEdgeDelta = 0;
1709
+ let verticalEdgeDelta = 0;
1710
+ for (let index = 0; index < size; index += 1) {
1711
+ horizontalEdgeDelta += Math.abs(heightNormalized[index * size]
1712
+ - heightNormalized[(index * size) + size - 1]);
1713
+ verticalEdgeDelta += Math.abs(heightNormalized[index]
1714
+ - heightNormalized[((size - 1) * size) + index]);
1715
+ }
1716
+ const maps = Object.fromEntries(C8_FIRST12_MAP_ROLES.map((role) => [role, new Uint8Array(pixelCount * 4)]));
1717
+ const sample = (x, y) => heightNormalized[
1718
+ ((((y % size) + size) % size) * size) + (((x % size) + size) % size)
1719
+ ];
1720
+ let byteHash = 2166136261 >>> 0;
1721
+ let roughnessMinimum = Infinity;
1722
+ let roughnessMaximum = -Infinity;
1723
+ let normalMinimumZ = 1;
1724
+ for (let y = 0; y < size; y += 1) {
1725
+ for (let x = 0; x < size; x += 1) {
1726
+ const pixel = (y * size) + x;
1727
+ const byteOffset = pixel * 4;
1728
+ const grainPatch = smoothstep(0.46, 0.72, tileFbm(x / size, y / size, 5, resolvedSeed + 617, 2));
1729
+ const darkGrain = smoothstep(0.91, 0.986, fine[pixel]) * grainPatch;
1730
+ const lightGrain = smoothstep(0.92, 0.992, tileNoise(
1731
+ x / size,
1732
+ y / size,
1733
+ Math.min(127, grainFrequency + 17),
1734
+ resolvedSeed + 631,
1735
+ ))
1736
+ * (1 - grainPatch * 0.42);
1737
+ // Macro variation should modulate one rock material, not paint large
1738
+ // agate-like swirls between the profile's darkest and lightest extremes.
1739
+ const baseAmount = lerp(0.30, 0.70, smoothstep(0.08, 0.92, value[pixel]));
1740
+ let color = mixColor(profile.baseDark, profile.baseLight, baseAmount);
1741
+ color = mixColor(color, profile.mineralDark, darkGrain * 0.48);
1742
+ color = mixColor(color, profile.mineralLight, lightGrain * 0.34);
1743
+ if (profile.strata) {
1744
+ color = mixColor(color, profile.strata.dark,
1745
+ strataDark[pixel] * profile.strata.colorStrength);
1746
+ color = mixColor(color, profile.strata.light,
1747
+ strataLight[pixel] * profile.strata.colorStrength * 0.92);
1748
+ }
1749
+ // This is a mineral/oxidation tint only. AO and direct lighting never
1750
+ // feed BaseColor.
1751
+ color = mixColor(color, profile.mineralDark, fracture[pixel] * 0.18);
1752
+ const topLeft = sample(x - 1, y + 1);
1753
+ const top = sample(x, y + 1);
1754
+ const topRight = sample(x + 1, y + 1);
1755
+ const left = sample(x - 1, y);
1756
+ const right = sample(x + 1, y);
1757
+ const bottomLeft = sample(x - 1, y - 1);
1758
+ const bottom = sample(x, y - 1);
1759
+ const bottomRight = sample(x + 1, y - 1);
1760
+ const gradientX = ((topLeft + (2 * left) + bottomLeft)
1761
+ - (topRight + (2 * right) + bottomRight)) / 8;
1762
+ const gradientY = ((bottomLeft + (2 * bottom) + bottomRight)
1763
+ - (topLeft + (2 * top) + topRight)) / 8;
1764
+ const physicalSlopeScale = (size * profile.heightMicroSpanMetres
1765
+ / resolvedProjectionScale) * profile.normalStrength;
1766
+ let normalX = gradientX * physicalSlopeScale;
1767
+ let normalY = gradientY * physicalSlopeScale;
1768
+ let normalZ = 1;
1769
+ const normalLength = Math.hypot(normalX, normalY, normalZ);
1770
+ normalX /= normalLength;
1771
+ normalY /= normalLength;
1772
+ normalZ /= normalLength;
1773
+ normalMinimumZ = Math.min(normalMinimumZ, normalZ);
1774
+ // Derive authored micro-occlusion from resolution-independent scalar
1775
+ // fields. A one-pixel Laplacian would change the material when exporting
1776
+ // 1K versus 4K maps.
1777
+ const concavity = clamp01(((0.54 - micro[pixel]) * 0.38)
1778
+ + ((0.52 - meso[pixel]) * 0.20)
1779
+ + (fracture[pixel] * 0.34)
1780
+ + (strataDark[pixel] * 0.10));
1781
+ const ao = clamp01(1 - (concavity * 0.62) - ((1 - macro[pixel]) * 0.07));
1782
+ const roughness = clamp01(lerp(profile.roughness[0], profile.roughness[1], micro[pixel])
1783
+ + ((0.5 - meso[pixel]) * 0.05) + (fracture[pixel] * 0.035));
1784
+ roughnessMinimum = Math.min(roughnessMinimum, roughness);
1785
+ roughnessMaximum = Math.max(roughnessMaximum, roughness);
1786
+ const colorBytes = color.map((channel) => Math.round(linearToSrgb(channel) * 255));
1787
+ const normalBytes = [normalX, normalY, normalZ].map((channel) => Math.round((channel * 0.5 + 0.5) * 255));
1788
+ const heightByte = Math.round(heightNormalized[pixel] * 255);
1789
+ const aoByte = Math.round(ao * 255);
1790
+ const roughnessByte = Math.round(roughness * 255);
1791
+ const smoothnessByte = 255 - roughnessByte;
1792
+ const values = {
1793
+ ao: [aoByte, aoByte, aoByte, 255],
1794
+ baseColor: [...colorBytes, 255],
1795
+ heightMicro: [heightByte, heightByte, heightByte, 255],
1796
+ normalGL: [...normalBytes, 255],
1797
+ orm: [aoByte, roughnessByte, 0, 255],
1798
+ roughness: [roughnessByte, roughnessByte, roughnessByte, 255],
1799
+ smoothness: [smoothnessByte, smoothnessByte, smoothnessByte, 255],
1800
+ };
1801
+ for (const [role, channels] of Object.entries(values)) maps[role].set(channels, byteOffset);
1802
+ for (const role of C8_FIRST12_MAP_ROLES) {
1803
+ for (let channel = 0; channel < 4; channel += 1) byteHash = fnv(values[role][channel], byteHash);
1804
+ }
1805
+ }
1806
+ }
1807
+ return Object.freeze({
1808
+ audit: Object.freeze({
1809
+ byteHash: byteHash.toString(16).padStart(8, '0'),
1810
+ fractureCoverageFraction: fractureCoverage / pixelCount,
1811
+ fractureNetwork: network.audit,
1812
+ heightRawRange: Object.freeze([minimumHeight, maximumHeight]),
1813
+ grainFrequency,
1814
+ grainScaleMetres: profile.grainScale,
1815
+ mapRoles: C8_FIRST12_MAP_ROLES,
1816
+ normalMinimumZ,
1817
+ patternChecks: Object.freeze({
1818
+ branchedFractures: network.audit.branchCount > 0,
1819
+ nonUniformCrackWidths: network.audit.widthRatio > 1.5,
1820
+ nonUniformStrata: strataVariation.measuredNonUniform,
1821
+ uniformGridPatternForbidden: true,
1822
+ }),
1823
+ profileId: binding.profileId,
1824
+ projectionScaleMetres: resolvedProjectionScale,
1825
+ roughnessRange: Object.freeze([roughnessMinimum, roughnessMaximum]),
1826
+ seed: resolvedSeed,
1827
+ size,
1828
+ strataCoverageFraction: strataCoverage / pixelCount,
1829
+ strataVariation,
1830
+ tileEdgeMeanAbsoluteDelta: Object.freeze({
1831
+ horizontal: horizontalEdgeDelta / size,
1832
+ vertical: verticalEdgeDelta / size,
1833
+ }),
1834
+ }),
1835
+ heightMicroDecode: Object.freeze({
1836
+ encoding: 'UNORM8',
1837
+ formula: '(sample - 0.5) * physicalSpanMetres',
1838
+ midlevel: 0.5,
1839
+ physicalSpanMetres: profile.heightMicroSpanMetres,
1840
+ role: 'bounded material/surface relief; not silhouette authority',
1841
+ }),
1842
+ maps: Object.freeze(maps),
1843
+ normalGLDerivation: Object.freeze({
1844
+ artisticSlopeStrength: profile.normalStrength,
1845
+ encoding: 'tangent-space OpenGL (+Y), RGB UNORM8',
1846
+ exactPhysicalHeightNormalWhenStrengthIsOne: true,
1847
+ source: 'HeightMicro physicalSpanMetres and projection scaleMetres',
1848
+ }),
1849
+ });
1850
+ }
1851
+
1852
+ export function createC8First12SurfaceSpecification({
1853
+ assetId,
1854
+ binding: bindingOverride = null,
1855
+ boundsAuthority,
1856
+ editedBoundsMetres,
1857
+ geometrySha256,
1858
+ mapResolution = 1024,
1859
+ profileId = null,
1860
+ seed = null,
1861
+ semanticRegions = null,
1862
+ } = {}) {
1863
+ const binding = assetBinding(assetId, bindingOverride, profileId);
1864
+ const profile = C8_FIRST12_LITHOLOGY_PROFILES[binding.profileId];
1865
+ assertHomogeneousSurfaceAllowed(binding, profile, 'surface specification', semanticRegions);
1866
+ if (!/^[a-f0-9]{64}$/u.test(String(geometrySha256 ?? ''))) {
1867
+ throw new RangeError('Nature Reference Rock surface specification requires the current edited geometry SHA-256.');
1868
+ }
1869
+ if (!Number.isInteger(mapResolution) || mapResolution < 1024 || mapResolution > 4096) {
1870
+ throw new RangeError('Nature Reference Rock production map resolution must be an integer from 1024 to 4096.');
1871
+ }
1872
+ let semanticRegionSurface = null;
1873
+ let portableRegions = null;
1874
+ let projection;
1875
+ if (profile.requiresSemanticRegions === true) {
1876
+ const regionSet = normalizeCompositeSemanticRegions({
1877
+ binding,
1878
+ geometrySha256,
1879
+ profile,
1880
+ semanticRegions,
1881
+ size: mapResolution,
1882
+ });
1883
+ const bounds = normalizeC8First12EditedBounds(editedBoundsMetres);
1884
+ const constituentProjections = Object.freeze(Object.fromEntries(regionSet.regions.map((region) => [
1885
+ region.id,
1886
+ resolveC8First12Projection({
1887
+ assetId: `${assetId}:${region.id}`,
1888
+ binding: Object.freeze({
1889
+ familyId: binding.familyId,
1890
+ profileId: region.materialProfileId,
1891
+ surfaceRationale: `Authored composite region ${region.id} (${region.role}).`,
1892
+ }),
1893
+ editedBoundsMetres,
1894
+ }),
1895
+ ])));
1896
+ const mapRoleProjection = Object.freeze(Object.fromEntries(C8_FIRST12_MAP_ROLES.map((role) => [
1897
+ role,
1898
+ Object.freeze({ mode: 'semantic-regions-uv0-bake', upAxis: bounds.upAxis }),
1899
+ ])));
1900
+ projection = Object.freeze({
1901
+ bounds,
1902
+ boundsFingerprint: c8First12SurfaceSeed(JSON.stringify(bounds)).toString(16).padStart(8, '0'),
1903
+ constituentProjections,
1904
+ coordinateFrame: 'geometry-uv0-authored-semantic-mask',
1905
+ mapRoleProjection,
1906
+ mode: 'semantic-regions-uv0-bake',
1907
+ recalculatedFromEditedBounds: true,
1908
+ semanticRegionContractSha256: regionSet.contractSha256,
1909
+ upAxis: bounds.upAxis,
1910
+ });
1911
+ semanticRegionSurface = semanticRegionAudit(regionSet);
1912
+ portableRegions = portableSemanticRegions(regionSet);
1913
+ } else {
1914
+ projection = resolveC8First12Projection({ assetId, binding, editedBoundsMetres });
1915
+ }
1916
+ const resolvedSeed = resolveC8First12Seed(seed, assetId);
1917
+ return Object.freeze({
1918
+ assetId,
1919
+ boundsAuthority: String(boundsAuthority ?? 'edited-high-source-bounds'),
1920
+ familyId: binding.familyId,
1921
+ geometrySha256,
1922
+ heightResidual: Object.freeze({
1923
+ encoding: 'signed high-to-LOD geometric difference with explicit physical decode',
1924
+ fakeResidualForbidden: true,
1925
+ file: null,
1926
+ midlevel: 0.5,
1927
+ placeholderIsTextureMap: false,
1928
+ requiredBakeInputs: Object.freeze([
1929
+ 'approved high-source positions',
1930
+ 'target LOD positions and object normals',
1931
+ 'UV0 coverage and ray/cage metrics',
1932
+ ]),
1933
+ role: 'HeightResidual',
1934
+ sha256: null,
1935
+ status: 'required-real-high-to-lod-bake-pending',
1936
+ }),
1937
+ lithology: profile.lithology,
1938
+ mapResolution,
1939
+ mapRoles: C8_FIRST12_MAP_ROLES,
1940
+ profileId: binding.profileId,
1941
+ projection,
1942
+ schema: C8_FIRST12_SURFACE_SCHEMA,
1943
+ seed: resolvedSeed,
1944
+ ...(semanticRegionSurface ? { semanticRegionSurface, semanticRegions: portableRegions } : {}),
1945
+ surfaceRationale: binding.surfaceRationale,
1946
+ version: C8_FIRST12_SURFACE_VERSION,
1947
+ });
1948
+ }