@call-me-sensei/toonlab 0.4.19 → 0.4.21

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 (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -0,0 +1,701 @@
1
+ // Geometry-side surface detail for rock meshes.
2
+ //
3
+ // A detail normal map can only shade relief that the silhouette never shows.
4
+ // Published catalog cliffs are 1.5k-4k triangles, which is ample at mid
5
+ // distance and visibly faceted in a close framing: large planar spans and hard
6
+ // straight silhouette edges that no normal map can break up, because the
7
+ // geometry genuinely is flat there.
8
+ //
9
+ // This module adds the missing geometry, deterministically and at load time:
10
+ // subdivide, then displace along the interpolated normal with a value-noise
11
+ // fBm evaluated in the mesh's own local space.
12
+ //
13
+ // Two decisions worth stating, because the obvious implementations are wrong:
14
+ //
15
+ // * Normals are NOT recomputed with `computeVertexNormals()`. Subdivision
16
+ // output is non-indexed, so that call produces flat per-face normals and
17
+ // makes the faceting worse than it started. Welding by position instead
18
+ // over-smooths: a rock's crisp arris edges are load-bearing in the
19
+ // stylized read, and averaging across them rounds the asset into a pebble.
20
+ // The original normals are interpolated through subdivision and then
21
+ // perturbed by the *gradient of the same height field* that moved the
22
+ // vertices, so shading stays consistent with the new surface while every
23
+ // authored hard edge survives.
24
+ //
25
+ // * Displacement is evaluated in local space, not world space. A rock's
26
+ // surface must not swim when the scene moves it, and keying the field to
27
+ // `variation` is what makes two placements of the same geology carry
28
+ // different relief — the per-asset separation that shared texture maps
29
+ // cannot provide.
30
+ //
31
+ // Nothing here touches the catalog artifact; it operates on the loaded mesh.
32
+
33
+ /**
34
+ * Displacement defaults, tuned against shot S08's 85 mm framing on
35
+ * ROCK-COAST-01 — the closest any launch frame gets to a rock.
36
+ *
37
+ * `amount` is in metres and the useful window is narrow. At 0.055 the straight
38
+ * silhouette edges soften but the broad faces stay visibly planar; at 0.16 with
39
+ * a 0.40 scale the asset turns to lumpy wax and the crisp stylized arris that
40
+ * makes it read as cliff rather than boulder is gone. 0.10 m at a 0.65 m
41
+ * feature scale erodes the edges and breaks up the faces while keeping the
42
+ * authored silhouette legible.
43
+ *
44
+ * `subdivisions: 2` (16x triangles) resolves this scale almost as well as 3
45
+ * (64x) at a quarter of the cost, so 3 is reserved for a hero close-up.
46
+ */
47
+ export const DEFAULT_ROCK_GEOMETRY_DETAIL = Object.freeze({
48
+ amount: 0.1,
49
+ // Concavity channel for the moss mask. `cavityMicro` folds the displacement's
50
+ // own pits into the macro curvature, so moss gathers in the fine hollows the
51
+ // noise just carved as well as in the mesh's structural crevices.
52
+ cavity: true,
53
+ cavityGain: 2.6,
54
+ cavityMicro: 0.55,
55
+ lacunarity: 2.03,
56
+ normalStrength: 1,
57
+ octaves: 4,
58
+ scale: 0.65,
59
+ subdivisions: 2,
60
+ variation: 0,
61
+ });
62
+
63
+ /** Subdivision is 4^levels triangles; past this a hero mesh stops being sane. */
64
+ export const MAX_ROCK_DETAIL_SUBDIVISIONS = 3;
65
+
66
+ /**
67
+ * Displacement as a fraction of the coarsest feature size.
68
+ *
69
+ * The reference pairing (0.1 m displacement at a 0.65 m feature scale) is
70
+ * 0.154, so this preserves the tuned cliff response exactly while making the
71
+ * relationship the derivation actually depends on explicit.
72
+ */
73
+ const FEATURE_RELIEF_FRACTION = DEFAULT_ROCK_GEOMETRY_DETAIL.amount
74
+ / DEFAULT_ROCK_GEOMETRY_DETAIL.scale;
75
+
76
+ /**
77
+ * The size the defaults were tuned against: ROCK-COAST-01 at 5.94 m.
78
+ * `amount` and `scale` are absolute metres, so they only mean what they were
79
+ * tuned to mean on a rock of about this size.
80
+ */
81
+ export const ROCK_GEOMETRY_DETAIL_REFERENCE = Object.freeze({
82
+ amount: DEFAULT_ROCK_GEOMETRY_DETAIL.amount,
83
+ scale: DEFAULT_ROCK_GEOMETRY_DETAIL.scale,
84
+ size: 5.94,
85
+ /** ROCK-COAST-01's LOD0 count, which sets the reference facet size. */
86
+ triangles: 4006,
87
+ });
88
+
89
+ /**
90
+ * Relief multipliers by stone character.
91
+ *
92
+ * A fractured cliff and a water-worn garden boulder are not the same surface
93
+ * at different sizes — the boulder is smoother *in proportion to itself*,
94
+ * because the process that shaped it removed relief rather than creating it.
95
+ * Scaling the cliff numbers geometrically onto a 1.3 m boulder gives a lumpy
96
+ * potato; these are the honest per-character corrections.
97
+ */
98
+ export const ROCK_GEOMETRY_DETAIL_CHARACTER = Object.freeze({
99
+ /** Fractured, bedded, quarried — the tuned cliff response. */
100
+ fractured: 1,
101
+ /** Glacially rounded or water-worn: soft convex form, shallow relief. */
102
+ worn: 0.42,
103
+ /** Weathered but still angular: talus, frost-shattered fragments. */
104
+ weathered: 0.72,
105
+ });
106
+
107
+ /**
108
+ * Detail options for a rock of a given measured size.
109
+ *
110
+ * `amount` and `scale` are metres, so the tuned defaults silently mean
111
+ * something different on every asset that is not 5.94 m across. This is the
112
+ * geometry-side twin of `resolveCatalogRockProjectionScale`: hold the relief
113
+ * *proportional to the stone* instead of holding the absolute displacement,
114
+ * which is what keeps a 0.6 m stepping stone and a 6 m cliff reading as the
115
+ * same material.
116
+ *
117
+ * `subdivisions` is derived from a triangle target rather than fixed, because
118
+ * the catalog's density is wildly uneven — garden-scale families ship
119
+ * 120-460 triangles where cliff families ship 1,500-4,000, so one fixed level
120
+ * count either starves the small assets or wastes budget on the large ones.
121
+ *
122
+ * @param {object} options
123
+ * @param {number|number[]} [options.size] Largest dimension in metres, or a bounds triple.
124
+ * @param {string} [options.character] Key of `ROCK_GEOMETRY_DETAIL_CHARACTER`.
125
+ * @param {number} [options.relief] Explicit relief multiplier; wins over `character`.
126
+ * @param {number} [options.triangles] Source triangle count, for the subdivision target.
127
+ * @param {number} [options.triangleTarget] Desired post-subdivision triangles.
128
+ * @param {number} [options.variation] Per-asset decorrelation index.
129
+ * @param {number} [options.maxSubdivisions] Ceiling on subdivision levels.
130
+ * @returns {RockGeometryDetailOptions} options ready for `applyRockGeometryDetail`.
131
+ */
132
+ export function resolveRockGeometryDetailForSize({
133
+ size = ROCK_GEOMETRY_DETAIL_REFERENCE.size,
134
+ character = 'fractured',
135
+ relief = Number.NaN,
136
+ triangles = 0,
137
+ triangleTarget = 2600,
138
+ variation = 0,
139
+ maxSubdivisions = MAX_ROCK_DETAIL_SUBDIVISIONS,
140
+ } = {}) {
141
+ const largest = Array.isArray(size)
142
+ ? Math.max(...size.map((value) => Math.abs(Number(value) || 0)))
143
+ : Math.abs(Number(size) || 0);
144
+ const span = largest > 0 ? largest : ROCK_GEOMETRY_DETAIL_REFERENCE.size;
145
+ const ratio = span / ROCK_GEOMETRY_DETAIL_REFERENCE.size;
146
+ const character_ = ROCK_GEOMETRY_DETAIL_CHARACTER[character];
147
+ const reliefScale = Number.isFinite(relief)
148
+ ? Math.max(0, relief)
149
+ : (Number.isFinite(character_) ? character_ : 1);
150
+
151
+ const ceiling = Math.max(0, Math.min(MAX_ROCK_DETAIL_SUBDIVISIONS, Math.trunc(maxSubdivisions)));
152
+ let subdivisions = 0;
153
+ const source = Math.max(0, Math.trunc(Number(triangles) || 0));
154
+ if (source > 0 && triangleTarget > 0) {
155
+ while (subdivisions < ceiling && source * (4 ** subdivisions) < triangleTarget) {
156
+ subdivisions += 1;
157
+ }
158
+ } else {
159
+ subdivisions = Math.min(ceiling, DEFAULT_ROCK_GEOMETRY_DETAIL.subdivisions);
160
+ }
161
+
162
+ // Relief is keyed to FACET SIZE, not to the asset span, and the difference
163
+ // is not academic.
164
+ //
165
+ // Scaling the tuned 0.1 m by span alone gives 9 mm on a 1.3 m boulder. That
166
+ // is invisible, because what has to be eroded is not the rock — it is the
167
+ // flat spans of its hull, and midpoint subdivision splits triangles without
168
+ // moving them, so a 120-triangle hull is still a 120-facet hull at 7,680
169
+ // triangles. Facet size is `span / sqrt(triangles)`, and by that measure the
170
+ // 1.28 m / 120-triangle boulder has facets of 0.117 m against the 5.94 m /
171
+ // 4,006-triangle cliff's 0.094 m — LARGER, despite being a fifth the size.
172
+ // Span-derived relief had it backwards and flattened exactly the assets that
173
+ // needed the most help.
174
+ const referenceFacet = ROCK_GEOMETRY_DETAIL_REFERENCE.size
175
+ / Math.sqrt(ROCK_GEOMETRY_DETAIL_REFERENCE.triangles);
176
+ const facet = source > 0 ? span / Math.sqrt(source) : referenceFacet * ratio;
177
+ const facetRatio = referenceFacet > 0 ? facet / referenceFacet : 1;
178
+ const featureScale = ROCK_GEOMETRY_DETAIL_REFERENCE.scale * facetRatio;
179
+ return {
180
+ amount: featureScale * FEATURE_RELIEF_FRACTION * reliefScale,
181
+ cavity: DEFAULT_ROCK_GEOMETRY_DETAIL.cavity,
182
+ cavityGain: DEFAULT_ROCK_GEOMETRY_DETAIL.cavityGain,
183
+ cavityMicro: DEFAULT_ROCK_GEOMETRY_DETAIL.cavityMicro,
184
+ lacunarity: DEFAULT_ROCK_GEOMETRY_DETAIL.lacunarity,
185
+ normalStrength: DEFAULT_ROCK_GEOMETRY_DETAIL.normalStrength,
186
+ octaves: DEFAULT_ROCK_GEOMETRY_DETAIL.octaves,
187
+ scale: featureScale,
188
+ // Water-worn stone has no arris edges to protect — the process that made
189
+ // it removed them. Smoothing is wrong for a fractured cliff and right
190
+ // here, and without it a 120-facet catalog boulder shades as a gemstone.
191
+ smoothNormals: character === 'worn' && !Number.isFinite(relief),
192
+ subdivisions,
193
+ variation: Math.trunc(Number(variation) || 0),
194
+ };
195
+ }
196
+
197
+ function hash3(x, y, z) {
198
+ let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(z | 0, 2147483647);
199
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
200
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
201
+ }
202
+
203
+ function smoothstep01(t) {
204
+ return t * t * (3 - (2 * t));
205
+ }
206
+
207
+ /** Trilinear value noise in [0,1]. */
208
+ function valueNoise3(x, y, z) {
209
+ const xi = Math.floor(x);
210
+ const yi = Math.floor(y);
211
+ const zi = Math.floor(z);
212
+ const tx = smoothstep01(x - xi);
213
+ const ty = smoothstep01(y - yi);
214
+ const tz = smoothstep01(z - zi);
215
+ let result = 0;
216
+ for (let dz = 0; dz <= 1; dz += 1) {
217
+ const wz = dz ? tz : 1 - tz;
218
+ for (let dy = 0; dy <= 1; dy += 1) {
219
+ const wy = dy ? ty : 1 - ty;
220
+ for (let dx = 0; dx <= 1; dx += 1) {
221
+ const wx = dx ? tx : 1 - tx;
222
+ result += hash3(xi + dx, yi + dy, zi + dz) * wx * wy * wz;
223
+ }
224
+ }
225
+ }
226
+ return result;
227
+ }
228
+
229
+ /**
230
+ * Displacement controls. Declared explicitly because inferring them from the
231
+ * frozen defaults yields literal types (`scale?: 0.65`), which would reject
232
+ * every real caller value and force a permissive declaration fallback.
233
+ *
234
+ * @typedef {object} RockGeometryDetailOptions
235
+ * @property {number} [amount] Peak displacement along the normal, metres.
236
+ * @property {boolean} [cavity] Write the concavity channel the moss mask reads.
237
+ * @property {number} [cavityGain] Scales the curvature response before clamping.
238
+ * @property {number} [cavityMicro] Folds displacement pits into the macro curvature.
239
+ * @property {number} [lacunarity] Frequency step between octaves.
240
+ * @property {number} [normalStrength] Scales the gradient-driven normal tilt.
241
+ * @property {number} [octaves] fBm octave count.
242
+ * @property {number} [scale] Coarsest feature size, metres.
243
+ * @property {boolean} [smoothNormals] Average normals across coincident vertices first.
244
+ * @property {number} [subdivisions] Midpoint subdivision levels (4^n triangles).
245
+ * @property {number} [variation] Per-asset index; equal indices give equal relief.
246
+ */
247
+
248
+ /**
249
+ * @typedef {object} RockDetailHeightOptions
250
+ * @property {number} [lacunarity]
251
+ * @property {number} [octaves]
252
+ * @property {number} [scale]
253
+ * @property {number} [seed]
254
+ */
255
+
256
+ /**
257
+ * Signed fBm height in roughly [-1, 1].
258
+ *
259
+ * `seed` offsets the lattice rather than reseeding the hash, which keeps the
260
+ * field continuous and makes adjacent variation indices genuinely different
261
+ * surfaces instead of near-copies.
262
+ *
263
+ * @param {number} x
264
+ * @param {number} y
265
+ * @param {number} z
266
+ * @param {RockDetailHeightOptions} [options]
267
+ * @returns {number}
268
+ */
269
+ export function rockDetailHeight(x, y, z, {
270
+ lacunarity = DEFAULT_ROCK_GEOMETRY_DETAIL.lacunarity,
271
+ octaves = DEFAULT_ROCK_GEOMETRY_DETAIL.octaves,
272
+ scale = DEFAULT_ROCK_GEOMETRY_DETAIL.scale,
273
+ seed = 0,
274
+ } = {}) {
275
+ const invScale = 1 / Math.max(1e-4, scale);
276
+ const offset = (seed % 64) * 17.31;
277
+ let frequency = invScale;
278
+ let amplitude = 1;
279
+ let total = 0;
280
+ let normalization = 0;
281
+ for (let octave = 0; octave < octaves; octave += 1) {
282
+ total += amplitude * valueNoise3(
283
+ (x * frequency) + offset,
284
+ (y * frequency) + (offset * 0.7),
285
+ (z * frequency) + (offset * 1.3),
286
+ );
287
+ normalization += amplitude;
288
+ frequency *= lacunarity;
289
+ amplitude *= 0.5;
290
+ }
291
+ return ((total / normalization) * 2) - 1;
292
+ }
293
+
294
+ /** Vertex attribute carrying the concavity term the moss mask reads. */
295
+ export const ROCK_CAVITY_ATTRIBUTE = 'rockCavity';
296
+
297
+ /**
298
+ * Vertex attribute carrying the CONVEXITY term — the signed opposite of
299
+ * `ROCK_CAVITY_ATTRIBUTE`.
300
+ *
301
+ * `computeMeshCavity` clamps its curvature to [0,1], so every convex vertex —
302
+ * every crown, ridge, shoulder and exposed corner — resolves to exactly 0 and
303
+ * is indistinguishable from a flat face. The moss support term could therefore
304
+ * express "this is a hollow" but had no way to express "this is a weather-beaten
305
+ * crown", and a crown is precisely where moss is absent: it sheds water, it is
306
+ * scoured, and it dries first.
307
+ *
308
+ * That missing half is why moss distributed evenly over exposed upper faces
309
+ * (D19-212). It is the same estimator, the same pass and the same interpolation;
310
+ * only the sign kept is different.
311
+ */
312
+ export const ROCK_EXPOSURE_ATTRIBUTE = 'rockExposure';
313
+
314
+ function positionKey(x, y, z) {
315
+ // 0.1 mm quantisation: welds the duplicated corners of a non-indexed soup
316
+ // without merging genuinely distinct surfaces.
317
+ return `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`;
318
+ }
319
+
320
+ /**
321
+ * Per-vertex concavity of the mesh's own form, in [0,1].
322
+ *
323
+ * Moss does not colonise by slope. It colonises where moisture collects and
324
+ * light is indirect — crevices, hollows, the junction where one slab meets
325
+ * another. Slope cannot express any of that, which is why a slope-only mask
326
+ * reads as a tint painted on rather than a material growing in.
327
+ *
328
+ * The estimator is the standard discrete mean-curvature sign test: for each
329
+ * welded vertex, take the centroid of its one-ring neighbours and project the
330
+ * offset onto the vertex normal. A centroid sitting along +n means the surface
331
+ * curves away on all sides — a hollow. Along -n means a ridge or an exposed
332
+ * corner. Normalising by the local edge length keeps it scale-invariant, so the
333
+ * same term works on a 0.4 m stepping stone and a 6 m cliff.
334
+ *
335
+ * Computed BEFORE subdivision deliberately: the coarse mesh's edges span the
336
+ * macro form, which is where the real crevices are. Subdivision then
337
+ * interpolates the attribute for free, giving a smooth moisture field rather
338
+ * than a stair-stepped one.
339
+ *
340
+ * @param {import('three').BufferGeometry} geometry Non-indexed triangle soup.
341
+ * @param {number} [gain] Scales the curvature response before clamping.
342
+ * @returns {Float32Array} one value per vertex.
343
+ */
344
+ export function computeMeshCavity(geometry, gain = 2.6) {
345
+ return computeMeshCurvature(geometry, gain).cavity;
346
+ }
347
+
348
+ /**
349
+ * Per-vertex concavity AND convexity of the mesh's own form, both in [0,1].
350
+ *
351
+ * One traversal, one curvature estimate, two rectified halves. `cavity` keeps
352
+ * the positive sign (hollows, crevices, slab junctions) and `exposure` keeps the
353
+ * negative one (crowns, ridges, shoulders, exposed corners). They are mutually
354
+ * exclusive per vertex by construction, and a flat face scores 0 in both.
355
+ *
356
+ * Splitting them matters because the two halves answer different questions for
357
+ * the moss mask: cavity says where water lingers, exposure says where it never
358
+ * does. A mask holding only the first can add moss to hollows but cannot take it
359
+ * off a crown, which leaves the noise field as the sole thing distinguishing one
360
+ * upper face from another — and noise uncorrelated with form is the definition
361
+ * of camouflage (D19-212).
362
+ *
363
+ * @param {import('three').BufferGeometry} geometry Non-indexed triangle soup.
364
+ * @param {number} [gain] Scales the curvature response before clamping.
365
+ * @returns {{cavity: Float32Array, exposure: Float32Array}} one value per vertex each.
366
+ */
367
+ export function computeMeshCurvature(geometry, gain = 2.6) {
368
+ const position = geometry.attributes.position;
369
+ const normal = geometry.attributes.normal;
370
+ const count = position.count;
371
+ const values = new Float32Array(count);
372
+ const exposure = new Float32Array(count);
373
+ if (!normal) return { cavity: values, exposure };
374
+
375
+ /** @type {Map<string, {x: number, y: number, z: number, n: number, edge: number, edges: number}>} */
376
+ const rings = new Map();
377
+ const addNeighbour = (key, nx, ny, nz, edge) => {
378
+ const entry = rings.get(key);
379
+ if (!entry) return;
380
+ entry.x += nx; entry.y += ny; entry.z += nz; entry.n += 1;
381
+ entry.edge += edge; entry.edges += 1;
382
+ };
383
+
384
+ const keys = new Array(count);
385
+ for (let i = 0; i < count; i += 1) {
386
+ const key = positionKey(position.getX(i), position.getY(i), position.getZ(i));
387
+ keys[i] = key;
388
+ if (!rings.has(key)) rings.set(key, { edge: 0, edges: 0, n: 0, x: 0, y: 0, z: 0 });
389
+ }
390
+
391
+ for (let triangle = 0; triangle < count; triangle += 3) {
392
+ for (let corner = 0; corner < 3; corner += 1) {
393
+ const self = triangle + corner;
394
+ const sx = position.getX(self);
395
+ const sy = position.getY(self);
396
+ const sz = position.getZ(self);
397
+ for (let other = 1; other <= 2; other += 1) {
398
+ const index = triangle + ((corner + other) % 3);
399
+ const ox = position.getX(index);
400
+ const oy = position.getY(index);
401
+ const oz = position.getZ(index);
402
+ addNeighbour(keys[self], ox, oy, oz, Math.hypot(ox - sx, oy - sy, oz - sz));
403
+ }
404
+ }
405
+ }
406
+
407
+ for (let i = 0; i < count; i += 1) {
408
+ const ring = rings.get(keys[i]);
409
+ if (!ring || ring.n === 0 || ring.edges === 0) continue;
410
+ const px = position.getX(i);
411
+ const py = position.getY(i);
412
+ const pz = position.getZ(i);
413
+ const meanEdge = ring.edge / ring.edges;
414
+ if (!(meanEdge > 0)) continue;
415
+ let nx = normal.getX(i);
416
+ let ny = normal.getY(i);
417
+ let nz = normal.getZ(i);
418
+ const length = Math.hypot(nx, ny, nz) || 1;
419
+ nx /= length; ny /= length; nz /= length;
420
+ const dx = (ring.x / ring.n) - px;
421
+ const dy = (ring.y / ring.n) - py;
422
+ const dz = (ring.z / ring.n) - pz;
423
+ const curvature = ((dx * nx) + (dy * ny) + (dz * nz)) / meanEdge;
424
+ values[i] = Math.min(1, Math.max(0, curvature * gain));
425
+ exposure[i] = Math.min(1, Math.max(0, -curvature * gain));
426
+ }
427
+ return { cavity: values, exposure };
428
+ }
429
+
430
+ /**
431
+ * Averages normals across coincident vertices, in place.
432
+ *
433
+ * This is the operation the module's header warns against — and it is warned
434
+ * against for FRACTURED stone, where welding rounds the authored arris edges
435
+ * into a pebble. For water-worn stone the reverse is true: the asset has no
436
+ * arris edges to protect, and the catalog's garden-scale families ship flat
437
+ * per-face normals on a 120-460 triangle hull, so the shading breaks into
438
+ * gemstone facets that no amount of subdivision or displacement can hide.
439
+ *
440
+ * Applied BEFORE subdivision, so the interpolation that follows carries a
441
+ * smooth field rather than re-splitting a faceted one.
442
+ *
443
+ * @param {import('three').BufferGeometry} geometry Non-indexed triangle soup.
444
+ * @returns {number} vertices whose normal was replaced.
445
+ */
446
+ export function smoothMeshNormals(geometry) {
447
+ const position = geometry.attributes.position;
448
+ const normal = geometry.attributes.normal;
449
+ if (!position || !normal) return 0;
450
+ const count = position.count;
451
+ /** @type {Map<string, number[]>} */
452
+ const sums = new Map();
453
+ const keys = new Array(count);
454
+ for (let i = 0; i < count; i += 1) {
455
+ const key = positionKey(position.getX(i), position.getY(i), position.getZ(i));
456
+ keys[i] = key;
457
+ const entry = sums.get(key);
458
+ if (entry) {
459
+ entry[0] += normal.getX(i);
460
+ entry[1] += normal.getY(i);
461
+ entry[2] += normal.getZ(i);
462
+ } else {
463
+ sums.set(key, [normal.getX(i), normal.getY(i), normal.getZ(i)]);
464
+ }
465
+ }
466
+ let replaced = 0;
467
+ for (let i = 0; i < count; i += 1) {
468
+ const entry = sums.get(keys[i]);
469
+ if (!entry) continue;
470
+ const length = Math.hypot(entry[0], entry[1], entry[2]);
471
+ if (!(length > 0)) continue;
472
+ normal.setXYZ(i, entry[0] / length, entry[1] / length, entry[2] / length);
473
+ replaced += 1;
474
+ }
475
+ normal.needsUpdate = true;
476
+ return replaced;
477
+ }
478
+
479
+ function interpolatedAttributeArray(attribute, triangleCount, verticesPerTriangle) {
480
+ const itemSize = attribute.itemSize;
481
+ return new Float32Array(triangleCount * verticesPerTriangle * itemSize);
482
+ }
483
+
484
+ /**
485
+ * One level of midpoint subdivision on a non-indexed geometry: every triangle
486
+ * becomes four. Every float attribute is interpolated, so uvs, colors and any
487
+ * authored channel survive rather than being silently dropped.
488
+ */
489
+ function subdivideOnce(geometry) {
490
+ const attributes = Object.entries(geometry.attributes)
491
+ .filter(([, attribute]) => attribute?.array && typeof attribute.itemSize === 'number');
492
+ const position = geometry.attributes.position;
493
+ const triangleCount = position.count / 3;
494
+ const next = {};
495
+ for (const [name, attribute] of attributes) {
496
+ next[name] = interpolatedAttributeArray(attribute, triangleCount * 4, 3);
497
+ }
498
+
499
+ for (const [name, attribute] of attributes) {
500
+ const size = attribute.itemSize;
501
+ const source = attribute.array;
502
+ const target = next[name];
503
+ let write = 0;
504
+ const emit = (values) => {
505
+ for (let i = 0; i < size; i += 1) target[write + i] = values[i];
506
+ write += size;
507
+ };
508
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
509
+ const base = triangle * 3 * size;
510
+ const a = [];
511
+ const b = [];
512
+ const c = [];
513
+ for (let i = 0; i < size; i += 1) {
514
+ a.push(source[base + i]);
515
+ b.push(source[base + size + i]);
516
+ c.push(source[base + (size * 2) + i]);
517
+ }
518
+ const ab = a.map((value, i) => (value + b[i]) * 0.5);
519
+ const bc = b.map((value, i) => (value + c[i]) * 0.5);
520
+ const ca = c.map((value, i) => (value + a[i]) * 0.5);
521
+ emit(a); emit(ab); emit(ca);
522
+ emit(ab); emit(b); emit(bc);
523
+ emit(ca); emit(bc); emit(c);
524
+ emit(ab); emit(bc); emit(ca);
525
+ }
526
+ }
527
+
528
+ for (const [name, attribute] of attributes) {
529
+ geometry.setAttribute(name, new attribute.constructor(next[name], attribute.itemSize));
530
+ }
531
+ return geometry;
532
+ }
533
+
534
+ /**
535
+ * Subdivides and displaces one geometry in place.
536
+ *
537
+ * @param {import('three').BufferGeometry} geometry
538
+ * @param {RockGeometryDetailOptions} [options]
539
+ * @returns {{triangles: number, subdivisions: number, amount: number, cavity: boolean} | null} applied detail.
540
+ */
541
+ export function applyRockGeometryDetailToGeometry(geometry, {
542
+ amount = DEFAULT_ROCK_GEOMETRY_DETAIL.amount,
543
+ cavity = DEFAULT_ROCK_GEOMETRY_DETAIL.cavity,
544
+ cavityGain = DEFAULT_ROCK_GEOMETRY_DETAIL.cavityGain,
545
+ cavityMicro = DEFAULT_ROCK_GEOMETRY_DETAIL.cavityMicro,
546
+ lacunarity = DEFAULT_ROCK_GEOMETRY_DETAIL.lacunarity,
547
+ normalStrength = DEFAULT_ROCK_GEOMETRY_DETAIL.normalStrength,
548
+ octaves = DEFAULT_ROCK_GEOMETRY_DETAIL.octaves,
549
+ scale = DEFAULT_ROCK_GEOMETRY_DETAIL.scale,
550
+ smoothNormals = false,
551
+ subdivisions = DEFAULT_ROCK_GEOMETRY_DETAIL.subdivisions,
552
+ variation = 0,
553
+ } = {}) {
554
+ if (!geometry?.attributes?.position) return null;
555
+ const levels = Math.max(0, Math.min(MAX_ROCK_DETAIL_SUBDIVISIONS, Math.trunc(subdivisions)));
556
+
557
+ let working = geometry.index ? geometry.toNonIndexed() : geometry;
558
+ if (working !== geometry) {
559
+ for (const name of Object.keys(geometry.attributes)) geometry.deleteAttribute(name);
560
+ geometry.setIndex(null);
561
+ for (const [name, attribute] of Object.entries(working.attributes)) {
562
+ geometry.setAttribute(name, attribute);
563
+ }
564
+ working.dispose?.();
565
+ working = geometry;
566
+ }
567
+ // Before both the cavity estimate and subdivision: the cavity term projects
568
+ // onto the vertex normal, so it wants the smoothed field too.
569
+ if (smoothNormals) smoothMeshNormals(working);
570
+ // Macro concavity from the coarse form, before subdivision multiplies the
571
+ // vertex count and shrinks the one-ring to micro scale. Subdivision then
572
+ // interpolates it like any other float attribute.
573
+ if (cavity && working.attributes.normal) {
574
+ const CavityArray = working.attributes.position.constructor;
575
+ // Both halves of the same curvature estimate. Written together so the two
576
+ // attributes can never disagree about which mesh they were measured on,
577
+ // and so the runtime's "every mesh carries it" gate covers both at once.
578
+ const curvature = computeMeshCurvature(working, cavityGain);
579
+ working.setAttribute(
580
+ ROCK_CAVITY_ATTRIBUTE,
581
+ new CavityArray(curvature.cavity, 1),
582
+ );
583
+ working.setAttribute(
584
+ ROCK_EXPOSURE_ATTRIBUTE,
585
+ new CavityArray(curvature.exposure, 1),
586
+ );
587
+ }
588
+
589
+ for (let level = 0; level < levels; level += 1) subdivideOnce(working);
590
+
591
+ const position = working.attributes.position;
592
+ const normal = working.attributes.normal;
593
+ const cavityAttribute = working.attributes[ROCK_CAVITY_ATTRIBUTE] ?? null;
594
+ const exposureAttribute = working.attributes[ROCK_EXPOSURE_ATTRIBUTE] ?? null;
595
+ const noise = { lacunarity, octaves, scale, seed: Math.trunc(variation) };
596
+ // Finite-difference step for the gradient. Small relative to the finest
597
+ // octave so the perturbation tracks the surface actually being built.
598
+ const epsilon = Math.max(1e-3, scale / (2 ** Math.max(1, octaves)));
599
+
600
+ if (amount !== 0 && normal) {
601
+ for (let i = 0; i < position.count; i += 1) {
602
+ const x = position.getX(i);
603
+ const y = position.getY(i);
604
+ const z = position.getZ(i);
605
+ let nx = normal.getX(i);
606
+ let ny = normal.getY(i);
607
+ let nz = normal.getZ(i);
608
+ const length = Math.hypot(nx, ny, nz) || 1;
609
+ nx /= length; ny /= length; nz /= length;
610
+
611
+ const height = rockDetailHeight(x, y, z, noise);
612
+ position.setXYZ(i, x + (nx * height * amount), y + (ny * height * amount), z + (nz * height * amount));
613
+
614
+ // A vertex pushed inward is a pit; moisture and moss collect there. Fold
615
+ // it into the interpolated macro curvature so the moss mask sees both
616
+ // scales through one channel.
617
+ if (cavityAttribute && cavityMicro > 0) {
618
+ const pit = Math.max(0, -height);
619
+ const merged = cavityAttribute.getX(i) + (pit * cavityMicro);
620
+ cavityAttribute.setX(i, Math.min(1, Math.max(0, merged)));
621
+ }
622
+ // The mirror of the pit: a vertex pushed outward is a micro-bump, which
623
+ // sheds water and dries first exactly as a macro crown does. Folding it in
624
+ // here is what gives the exposure term detail at the displacement's own
625
+ // scale rather than only at the coarse hull's.
626
+ if (exposureAttribute && cavityMicro > 0) {
627
+ const bump = Math.max(0, height);
628
+ const merged = exposureAttribute.getX(i) + (bump * cavityMicro);
629
+ exposureAttribute.setX(i, Math.min(1, Math.max(0, merged)));
630
+ }
631
+
632
+ if (normalStrength !== 0) {
633
+ // Gradient of the height field, then remove its normal component so
634
+ // only the tangential slope tilts the shading normal.
635
+ const gx = (rockDetailHeight(x + epsilon, y, z, noise) - height) / epsilon;
636
+ const gy = (rockDetailHeight(x, y + epsilon, z, noise) - height) / epsilon;
637
+ const gz = (rockDetailHeight(x, y, z + epsilon, noise) - height) / epsilon;
638
+ const along = (gx * nx) + (gy * ny) + (gz * nz);
639
+ const tx = gx - (along * nx);
640
+ const ty = gy - (along * ny);
641
+ const tz = gz - (along * nz);
642
+ const k = amount * normalStrength;
643
+ const px = nx - (tx * k);
644
+ const py = ny - (ty * k);
645
+ const pz = nz - (tz * k);
646
+ const plen = Math.hypot(px, py, pz) || 1;
647
+ normal.setXYZ(i, px / plen, py / plen, pz / plen);
648
+ }
649
+ }
650
+ position.needsUpdate = true;
651
+ normal.needsUpdate = true;
652
+ if (cavityAttribute) cavityAttribute.needsUpdate = true;
653
+ }
654
+
655
+ working.computeBoundingBox?.();
656
+ working.computeBoundingSphere?.();
657
+ return {
658
+ amount,
659
+ cavity: Boolean(cavityAttribute),
660
+ subdivisions: levels,
661
+ triangles: position.count / 3,
662
+ };
663
+ }
664
+
665
+ /**
666
+ * Adds geometry detail to every rock mesh under `root`.
667
+ *
668
+ * Idempotent per mesh: a geometry already enriched is skipped, so calling this
669
+ * twice cannot compound displacement into mush.
670
+ *
671
+ * @param {import('three').Object3D} root
672
+ * @param {RockGeometryDetailOptions} [options]
673
+ * @returns {{meshes: number, triangles: number, trianglesBefore: number, skipped: number, cavity: number}} meshes that received a cavity channel are counted in `cavity`.
674
+ */
675
+ export function applyRockGeometryDetail(root, options = {}) {
676
+ const report = { cavity: 0, meshes: 0, skipped: 0, triangles: 0, trianglesBefore: 0 };
677
+ root?.traverse?.((object) => {
678
+ if (!object?.isMesh || !object.geometry) return;
679
+ if (object.userData?.rockShaderExclude === true) return;
680
+ // Catalog artifacts pack every LOD as a sibling node and the consumer hides
681
+ // all but LOD0. Subdividing the hidden ones triples the cost for geometry
682
+ // that is never drawn at this distance.
683
+ if (object.visible === false) {
684
+ report.skipped += 1;
685
+ return;
686
+ }
687
+ if (object.geometry.userData?.toonLabRockDetail) {
688
+ report.skipped += 1;
689
+ return;
690
+ }
691
+ const before = (object.geometry.index?.count ?? object.geometry.attributes.position?.count ?? 0) / 3;
692
+ const applied = applyRockGeometryDetailToGeometry(object.geometry, options);
693
+ if (!applied) return;
694
+ object.geometry.userData = { ...object.geometry.userData, toonLabRockDetail: applied };
695
+ report.meshes += 1;
696
+ if (applied.cavity) report.cavity += 1;
697
+ report.trianglesBefore += before;
698
+ report.triangles += applied.triangles;
699
+ });
700
+ return report;
701
+ }