@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
@@ -3,12 +3,26 @@ import {
3
3
  createToonRockMaterial,
4
4
  normalizeToonLabRockProfile,
5
5
  } from './rockMaterial.js';
6
+ import { applyRockGeometryDetail } from './rockGeometryDetail.js';
7
+
8
+ /**
9
+ * Weight of the concavity channel in the moss moisture term.
10
+ *
11
+ * `moisture = clamp(slope + cavity * strength)`. At 1.6 a fully concave crevice
12
+ * reaches saturation on its own, so moss grows in a vertical cleft that the
13
+ * slope term scores at zero, while an exposed convex face still depends on
14
+ * slope alone and stays bare.
15
+ */
16
+ export const ROCK_MOSS_CAVITY_STRENGTH = 1.6;
6
17
  import { createRockShaderSettings } from './rockShaderSettings.js';
7
18
  import { markFactoryStyleMaterial } from '../styles/styleMetadata.js';
19
+ import { withoutDegenerateDetailMaps } from './rockTextureIntegrity.js';
20
+ import { inspectRockRegionBindings } from './rockRegionRuntime.js';
21
+ import { sanitizeRockTangents } from './rockTangentIntegrity.js';
8
22
 
9
23
  const ORIGINAL_MATERIALS = new WeakMap();
10
24
  const ORIGINAL_SHADOW_FLAGS = new WeakMap();
11
- let defaultTextureSet = null;
25
+ const textureSetsByVariation = new Map();
12
26
 
13
27
  function clamp01(value) {
14
28
  return Math.min(1, Math.max(0, Number(value) || 0));
@@ -77,16 +91,22 @@ function makeProceduralTexture({
77
91
  }
78
92
 
79
93
  /**
80
- * Creates the neutral, deterministic fallback texture set used by the
81
- * Call Me Sensei rock shader. Applications can replace any map with authored
82
- * or licensed textures without changing the shader preset.
94
+ * Offsets every fallback map's seed by a variation index.
95
+ *
96
+ * Two rocks that resolve to the same fallback maps render with identical
97
+ * surface breakup. That reads as one rock duplicated — the failure §13 calls a
98
+ * "repeated prop pattern" — so a scene placing several rocks from one family
99
+ * needs a way to decorrelate them without authoring bespoke textures for each.
100
+ * Seed 0 reproduces the reviewed first-party set exactly.
83
101
  */
84
- export function createDefaultRockShaderTextureSet() {
85
- if (defaultTextureSet) return defaultTextureSet;
102
+ function variationSeed(base, variation) {
103
+ return base + (variation * 101);
104
+ }
86
105
 
87
- defaultTextureSet = Object.freeze({
106
+ function buildRockShaderTextureSet(variation) {
107
+ return Object.freeze({
88
108
  rock: makeProceduralTexture({
89
- seed: 3,
109
+ seed: variationSeed(3, variation),
90
110
  // Measured sRGB mean of the captured first-party T_RockClassic_BC input
91
111
  // (179.70, 179.92, 178.98). Keeping the deterministic fallback anchored
92
112
  // to that source lets the shader produce its reviewed grey sun face and
@@ -97,61 +117,85 @@ export function createDefaultRockShaderTextureSet() {
97
117
  variation: 0.18,
98
118
  }),
99
119
  rockNormal: makeProceduralTexture({
100
- seed: 5,
120
+ seed: variationSeed(5, variation),
101
121
  color: [0.5, 0.5, 1],
102
122
  variation: 0,
103
123
  colorSpace: THREE.NoColorSpace,
104
124
  channel: 'normal',
105
125
  }),
106
126
  smoothness: makeProceduralTexture({
107
- seed: 7,
127
+ seed: variationSeed(7, variation),
108
128
  color: [0.33, 0.33, 0.33],
109
129
  variation: 0.12,
110
130
  colorSpace: THREE.NoColorSpace,
111
131
  channel: 'mask',
112
132
  }),
113
133
  stripe: makeProceduralTexture({
114
- seed: 13,
134
+ seed: variationSeed(13, variation),
115
135
  color: [0.58, 0.55, 0.5],
116
136
  variation: 0.2,
117
137
  }),
118
138
  moss: makeProceduralTexture({
119
- seed: 17,
139
+ seed: variationSeed(17, variation),
120
140
  color: [0.3, 0.42, 0.2],
121
141
  variation: 0.16,
122
142
  }),
123
143
  grass: makeProceduralTexture({
124
- seed: 19,
144
+ seed: variationSeed(19, variation),
125
145
  color: [0.32, 0.48, 0.19],
126
146
  variation: 0.14,
127
147
  }),
128
148
  snow: makeProceduralTexture({
129
- seed: 23,
149
+ seed: variationSeed(23, variation),
130
150
  color: [0.88, 0.91, 0.94],
131
151
  variation: 0.06,
132
152
  }),
133
153
  sand: makeProceduralTexture({
134
- seed: 29,
154
+ seed: variationSeed(29, variation),
135
155
  color: [0.7, 0.58, 0.39],
136
156
  variation: 0.1,
137
157
  }),
138
158
  sandNormal: makeProceduralTexture({
139
- seed: 31,
159
+ seed: variationSeed(31, variation),
140
160
  color: [0.5, 0.5, 1],
141
161
  variation: 0,
142
162
  colorSpace: THREE.NoColorSpace,
143
163
  channel: 'normal',
144
164
  }),
145
165
  topMask: makeProceduralTexture({
146
- seed: 37,
166
+ seed: variationSeed(37, variation),
147
167
  color: [0.56, 0.56, 0.56],
148
168
  variation: 0.3,
149
169
  colorSpace: THREE.NoColorSpace,
150
170
  channel: 'mask',
151
171
  }),
152
172
  });
173
+ }
153
174
 
154
- return defaultTextureSet;
175
+ /**
176
+ * Creates the neutral, deterministic fallback texture set used by the
177
+ * Call Me Sensei rock shader. Applications can replace any map with authored
178
+ * or licensed textures without changing the shader preset.
179
+ *
180
+ * `variation` decorrelates the generated maps so several rocks sharing one
181
+ * catalog family do not render with identical surface breakup. Sets are
182
+ * memoized per variation index and are deterministic: the same index always
183
+ * yields the same maps. Index 0 is the reviewed first-party set.
184
+ *
185
+ * @param {{variation?: number}} [options]
186
+ */
187
+ export function createRockShaderTextureSet({ variation = 0 } = {}) {
188
+ const index = Number.isFinite(Number(variation)) ? Math.trunc(Number(variation)) : 0;
189
+ const existing = textureSetsByVariation.get(index);
190
+ if (existing) return existing;
191
+ const set = buildRockShaderTextureSet(index);
192
+ textureSetsByVariation.set(index, set);
193
+ return set;
194
+ }
195
+
196
+ /** The reviewed first-party fallback set (`variation: 0`). */
197
+ export function createDefaultRockShaderTextureSet() {
198
+ return createRockShaderTextureSet({ variation: 0 });
155
199
  }
156
200
 
157
201
  function tintArray(value, fallback) {
@@ -211,60 +255,59 @@ export function rockShaderSettingsToProfile(input = {}) {
211
255
  lighting: {
212
256
  exposure: lighting.exposure,
213
257
  ambientFloor: lighting.ambientFloor,
258
+ skyColorInfluence: lighting.skyColorInfluence,
214
259
  skyFillStrength: lighting.skyFillStrength,
215
260
  skyFillTint: tintArray(lighting.skyFillTint, [1, 1, 1]),
261
+ shadowFill: lighting.shadowFill,
262
+ shadowFillTint: tintArray(lighting.shadowFillTint, [1, 1, 1]),
216
263
  },
217
- shoreline: {
218
- wetBandWidth: shoreline.wetBandWidth,
219
- wetBandDarkening: shoreline.wetBandDarkening,
220
- wetRoughness: shoreline.wetRoughness,
221
- },
222
- normals: {
223
- distance: normals.distance,
224
- nearFlatten: normals.nearFlatten,
225
- farFlatten: normals.farFlatten,
226
- useSmoothed: normals.useSmoothed,
227
- normalGreenSign: normals.normalGreenSign,
228
- },
264
+ // SPREAD, not an allow-list — the same trap the moss group was carrying.
265
+ // These two settings groups map onto identically named profile groups, so
266
+ // naming each field bought nothing and cost a silent drop for any field
267
+ // added later. See the longer note on `moss` below.
268
+ shoreline: { ...shoreline },
269
+ normals: { ...normals },
229
270
  moss: {
230
- enabled: moss.enabled,
231
- size: moss.size,
232
- sharpness: moss.sharpness,
233
- offset: moss.offset,
234
- multiply: moss.multiply,
235
- colorPower: moss.colorPower,
271
+ // SPREAD, not an allow-list. This mapping used to name every field, so
272
+ // a field added to the settings schema was silently dropped here —
273
+ // between validation and the shader — with no error anywhere. Adding a
274
+ // moss capability then required edits in four separate files and failed
275
+ // invisibly if you missed one. The settings object is already
276
+ // schema-validated and clamped by `createRockShaderSettings`, so
277
+ // forwarding it wholesale is both safer and correct; the entries below
278
+ // exist only to coerce types the profile wants in a different shape.
279
+ // `scripts/verify-rock-shader.mjs` asserts schema/profile parity so this
280
+ // cannot silently regress.
281
+ ...moss,
236
282
  lowColor: tintArray(moss.lowColor, [0.18, 0.28, 0.09]),
237
283
  highColor: tintArray(moss.highColor, [0.42, 0.55, 0.2]),
238
284
  },
285
+ // The layer groups DO get renamed on the way through — the settings schema
286
+ // exposes `layerMask` / `grassLayer` / `snowLayer` / `sandLayer` and the
287
+ // profile wants `layers.{maskEnabled, sharpness, offset, grass, snow,
288
+ // sand}` — so the group names are mapped explicitly. The FIELDS inside each
289
+ // group are spread, because those names are shared and an allow-list there
290
+ // is the same silent-drop trap the moss group was carrying: a new sand or
291
+ // grass field would pass validation, be clamped, and then vanish before the
292
+ // shader with no error at any layer. Only `useAssetMask` -> `maskEnabled`
293
+ // is genuinely renamed, and the `tint` entries coerce a shape the profile
294
+ // wants differently. `scripts/verify-rock-shader.mjs` asserts parity for
295
+ // every group so this cannot silently regress again.
239
296
  layers: {
240
- maskEnabled: layerMask.useAssetMask,
241
297
  sharpness: layerMask.sharpness,
242
298
  offset: layerMask.offset,
299
+ maskEnabled: layerMask.useAssetMask,
243
300
  grass: {
244
- enabled: grassLayer.enabled,
245
- useGroundShader: grassLayer.useGroundShader,
246
- scale: grassLayer.scale,
301
+ ...grassLayer,
247
302
  tint: tintArray(grassLayer.tint, [0.48, 0.65, 0.3]),
248
- saturation: grassLayer.saturation,
249
- emission: grassLayer.emission,
250
303
  },
251
304
  snow: {
252
- enabled: snowLayer.enabled,
253
- scale: snowLayer.scale,
305
+ ...snowLayer,
254
306
  tint: tintArray(snowLayer.tint, [0.9, 0.93, 0.96]),
255
- saturation: snowLayer.saturation,
256
- emission: snowLayer.emission,
257
307
  },
258
308
  sand: {
259
- enabled: sandLayer.enabled,
260
- useGroundShader: sandLayer.useGroundShader,
261
- scale: sandLayer.scale,
309
+ ...sandLayer,
262
310
  tint: tintArray(sandLayer.tint, [0.72, 0.59, 0.4]),
263
- saturation: sandLayer.saturation,
264
- emission: sandLayer.emission,
265
- normalScale: sandLayer.normalScale,
266
- normalStrength: sandLayer.normalStrength,
267
- normalRotationDegrees: sandLayer.normalRotationDegrees,
268
311
  },
269
312
  },
270
313
  });
@@ -294,20 +337,91 @@ function disposeOwnedMaterial(material) {
294
337
  }
295
338
 
296
339
  function createMaterialForSource({
340
+ vertexCavityStrength = 0,
341
+ rockRegionEnabled = false,
297
342
  sourceMaterial,
298
343
  settings,
299
344
  profile,
300
345
  textures,
301
346
  name,
347
+ variation = 0,
348
+ degenerateMaps = null,
349
+ surfaceState = 'source',
302
350
  }) {
303
- const fallback = createDefaultRockShaderTextureSet();
351
+ const semantic = sourceMaterial?.userData?.toonlabRockSemanticMaterial ?? null;
352
+ const fallback = semantic ? {} : createRockShaderTextureSet({ variation });
353
+ // A placeholder in a real slot is worse than an empty slot: it silently
354
+ // displaces the deterministic fallback, which is a usable surface. Drop
355
+ // degenerate maps here so every consumer of the shader is protected, not
356
+ // only the ones that remembered to filter before calling.
357
+ const provided = withoutDegenerateDetailMaps(textures);
358
+ if (degenerateMaps && provided.rejected.length > 0) {
359
+ degenerateMaps.push(...provided.rejected);
360
+ }
304
361
  const textureSet = {
305
362
  ...fallback,
306
- ...textures,
363
+ ...(semantic?.textures ?? {}),
364
+ ...provided.textures,
307
365
  };
308
366
 
367
+ let materialProfile = profile;
368
+ if (semantic) {
369
+ materialProfile = structuredClone(semantic.profile);
370
+ // Scene state and coordinated lighting remain style-owned; source material
371
+ // identity (projection, PBR, masks, and layer inputs) remains set-owned.
372
+ materialProfile.lighting = structuredClone(profile.lighting);
373
+ materialProfile.shoreline = structuredClone(profile.shoreline);
374
+ materialProfile.base = {
375
+ ...materialProfile.base,
376
+ closeTintDistance: profile.base.closeTintDistance,
377
+ distantTint: profile.base.distantTint,
378
+ distantTintMix: profile.base.distantTintMix,
379
+ farTintDistance: profile.base.farTintDistance,
380
+ nearDetailDistance: profile.base.nearDetailDistance,
381
+ nearDetailScale: profile.base.nearDetailScale,
382
+ nearDetailStrength: profile.base.nearDetailStrength,
383
+ };
384
+ const selected = surfaceState === 'source'
385
+ ? null
386
+ : ['none', 'grass', 'sand', 'snow'].includes(surfaceState)
387
+ ? surfaceState
388
+ : (() => { throw new TypeError(`Unknown rock surface state: ${surfaceState}`); })();
389
+ if (selected !== null) {
390
+ for (const role of ['grass', 'sand', 'snow']) {
391
+ materialProfile.layers[role].enabled = selected === role;
392
+ }
393
+ if (selected !== 'none') {
394
+ // An explicit replacement state is a ToonLab-authored cap, not the
395
+ // source graph's often-disabled top-layer default. Keep all four mask
396
+ // signals active while using a production-visible coverage range.
397
+ materialProfile.layers.maskEnabled = true;
398
+ materialProfile.layers.offset = 0.2;
399
+ materialProfile.layers.sharpness = 4;
400
+ materialProfile.layers.heightStart = 0.15;
401
+ materialProfile.layers.heightEnd = 0.7;
402
+ materialProfile.layers.cavityStrength = Math.max(
403
+ 0.2,
404
+ Number(materialProfile.layers.cavityStrength ?? 0),
405
+ );
406
+ materialProfile.layers.exposureStrength = Math.max(
407
+ 0.2,
408
+ Number(materialProfile.layers.exposureStrength ?? 0),
409
+ );
410
+ materialProfile.layers.semanticMaskStrength = Math.max(
411
+ 0.15,
412
+ Number(materialProfile.layers.semanticMaskStrength ?? 0),
413
+ );
414
+ }
415
+ if (selected !== 'none' && !textureSet[selected]?.isTexture) {
416
+ throw new Error(
417
+ `Rock material set ${semantic.materialSetId} has no faithful ${selected} layer input.`,
418
+ );
419
+ }
420
+ }
421
+ }
422
+
309
423
  const sourceAlbedoMode = settings.assetIntegration.sourceAlbedoMode;
310
- const sourceAlbedoStrength = sourceAlbedoMode === 'retain'
424
+ const sourceAlbedoStrength = semantic ? 0 : sourceAlbedoMode === 'retain'
311
425
  ? 1
312
426
  : sourceAlbedoMode === 'blend'
313
427
  ? settings.assetIntegration.sourceAlbedoStrength
@@ -316,11 +430,27 @@ function createMaterialForSource({
316
430
  textureSet.sourceRock = sourceMaterial.map;
317
431
  }
318
432
  if (settings.assetIntegration.sourceNormalStrength > 0 && sourceMaterial?.normalMap) {
319
- textureSet.sourceNormal = sourceMaterial.normalMap;
433
+ const sourceNormals = withoutDegenerateDetailMaps({
434
+ sourceNormal: sourceMaterial.normalMap,
435
+ });
436
+ if (sourceNormals.textures.sourceNormal) {
437
+ textureSet.sourceNormal = sourceNormals.textures.sourceNormal;
438
+ } else if (degenerateMaps) {
439
+ degenerateMaps.push(...sourceNormals.rejected);
440
+ }
320
441
  }
321
442
  if (sourceAlbedoStrength > 0 && sourceMaterial?.roughnessMap) {
322
443
  textureSet.sourceRoughness = sourceMaterial.roughnessMap;
323
444
  }
445
+ if (sourceAlbedoStrength > 0 && sourceMaterial?.metalnessMap) {
446
+ textureSet.sourceMetalness = sourceMaterial.metalnessMap;
447
+ }
448
+ if (sourceAlbedoStrength > 0 && sourceMaterial?.specularIntensityMap) {
449
+ textureSet.sourceSpecular = sourceMaterial.specularIntensityMap;
450
+ }
451
+ if (sourceAlbedoStrength > 0 && sourceMaterial?.emissiveMap) {
452
+ textureSet.sourceEmissive = sourceMaterial.emissiveMap;
453
+ }
324
454
  if (settings.assetIntegration.sourceAoStrength > 0 && sourceMaterial?.aoMap) {
325
455
  textureSet.sourceAo = sourceMaterial.aoMap;
326
456
  }
@@ -329,8 +459,34 @@ function createMaterialForSource({
329
459
  assetIntegration: {
330
460
  ...settings.assetIntegration,
331
461
  sourceAlbedoStrength,
462
+ sourceColor: sourceMaterial?.color?.toArray?.() ?? [1, 1, 1],
463
+ sourceEmissive: sourceMaterial?.emissive?.toArray?.() ?? [0, 0, 0],
464
+ sourceEmissiveIntensity: Number(sourceMaterial?.emissiveIntensity ?? 1),
465
+ sourceOpacity: Number(sourceMaterial?.opacity ?? 1),
466
+ sourceAlphaTest: Number(sourceMaterial?.alphaTest ?? 0),
467
+ sourceTransparent: Boolean(sourceMaterial?.transparent),
468
+ sourceDepthWrite: sourceMaterial?.depthWrite !== false,
469
+ sourceSide: Number.isInteger(sourceMaterial?.side) ? sourceMaterial.side : null,
470
+ sourceMetalnessFactor: Number(sourceMaterial?.metalness ?? 0),
471
+ sourceRoughnessFactor: Number(sourceMaterial?.roughness ?? 1),
472
+ sourceSpecularFactor: Number(sourceMaterial?.specularIntensity ?? 1),
473
+ primaryNormalStrength: Number(
474
+ semantic?.binding?.materialVariation?.normalGain ?? 1,
475
+ ),
476
+ primarySpecularStrength: Number(
477
+ semantic?.binding?.materialVariation?.specularGain ?? 1,
478
+ ),
479
+ semanticMaskAttributes: Boolean(semantic),
480
+ rockRegionAttributes: rockRegionEnabled,
481
+ regionTintStrength: settings.assetIntegration.regionTintStrength,
482
+ regionBaseTint: settings.assetIntegration.regionBaseTint,
483
+ regionShaftTint: settings.assetIntegration.regionShaftTint,
484
+ regionNeckTint: settings.assetIntegration.regionNeckTint,
485
+ regionCapTint: settings.assetIntegration.regionCapTint,
486
+ regionNeckOverlayStrength: settings.assetIntegration.regionNeckOverlayStrength,
487
+ vertexCavityStrength,
332
488
  },
333
- profile,
489
+ profile: materialProfile,
334
490
  textures: textureSet,
335
491
  name,
336
492
  });
@@ -338,21 +494,78 @@ function createMaterialForSource({
338
494
  rockMaterial.userData.toonLabRockShaderPreset = settings.preset;
339
495
  rockMaterial.userData.toonLabRockTextureSource = textures?.rock
340
496
  ? 'provided'
341
- : 'first-party-generated';
497
+ : semantic
498
+ ? 'shared-semantic-material-set'
499
+ : 'first-party-generated';
342
500
  rockMaterial.userData.toonlabSourceTextureIds = [...new Set([
501
+ ...Object.values(semantic?.textures ?? {}),
343
502
  textureSet.sourceRock,
344
503
  textureSet.sourceNormal,
345
504
  textureSet.sourceRoughness,
505
+ textureSet.sourceMetalness,
506
+ textureSet.sourceSpecular,
507
+ textureSet.sourceEmissive,
346
508
  textureSet.sourceAo,
347
509
  ].filter((texture) => texture?.isTexture && texture.uuid).map((texture) => texture.uuid))];
348
510
  rockMaterial.userData.toonLabRockTextureComposition = {
349
- base: textures?.rock ? 'provided' : 'first-party-generated',
511
+ base: textures?.rock
512
+ ? 'provided'
513
+ : semantic
514
+ ? 'shared-semantic-material-set'
515
+ : 'first-party-generated',
350
516
  sourceAlbedoMode,
351
517
  sourceAlbedoStrength,
352
518
  sourceNormalStrength: settings.assetIntegration.sourceNormalStrength,
353
519
  sourceAoStrength: settings.assetIntegration.sourceAoStrength,
520
+ sourcePbrChannels: {
521
+ primaryAo: Boolean(textureSet.rockAo),
522
+ albedo: Boolean(textureSet.sourceRock),
523
+ alphaCutout: Boolean(textureSet.sourceRock && sourceMaterial?.alphaTest > 0),
524
+ emissive: Boolean(textureSet.sourceEmissive),
525
+ metallicRoughness: Boolean(textureSet.sourceMetalness || textureSet.sourceRoughness),
526
+ normal: Boolean(textureSet.sourceNormal),
527
+ occlusion: Boolean(textureSet.sourceAo),
528
+ specular: Boolean(textureSet.sourceSpecular),
529
+ },
354
530
  sourceTextureCount: rockMaterial.userData.toonlabSourceTextureIds.length,
355
531
  };
532
+ if (semantic) {
533
+ rockMaterial.userData.toonLabRockTextureComposition.materialSetId = semantic.materialSetId;
534
+ rockMaterial.userData.toonLabRockTextureComposition.surfaceState = surfaceState;
535
+ rockMaterial.userData.toonLabRockTextureComposition.sourcePbrChannels = {
536
+ albedo: Boolean(textureSet.rock),
537
+ normal: Boolean(textureSet.rockNormal),
538
+ smoothness: Boolean(textureSet.smoothness),
539
+ topMask: Boolean(textureSet.topMask),
540
+ grass: Boolean(textureSet.grass),
541
+ grassRoughness: Boolean(textureSet.grassRoughness),
542
+ sand: Boolean(textureSet.sand),
543
+ sandNormal: Boolean(textureSet.sandNormal),
544
+ snow: Boolean(textureSet.snow),
545
+ moss: Boolean(textureSet.moss),
546
+ };
547
+ const activeLayer = ['grass', 'snow', 'sand']
548
+ .filter((role) => materialProfile.layers?.[role]?.enabled)
549
+ .at(-1) ?? 'rock';
550
+ rockMaterial.userData.toonlabRockActiveSurfaceResponse = {
551
+ activeLayer,
552
+ color: activeLayer === 'rock' ? textureSet.rock?.uuid : textureSet[activeLayer]?.uuid,
553
+ normal: activeLayer === 'sand' && materialProfile.layers.sand.normalStrength > 0
554
+ ? textureSet.sandNormal?.uuid
555
+ : activeLayer === 'rock'
556
+ ? textureSet.rockNormal?.uuid
557
+ : null,
558
+ roughness: activeLayer === 'grass' && textureSet.grassRoughness
559
+ ? textureSet.grassRoughness.uuid
560
+ : materialProfile.layers?.[activeLayer]?.roughness ?? null,
561
+ };
562
+ rockMaterial.userData.toonlabRockProjectionContract = {
563
+ normalBlend: 'whiteout-world-to-tangent',
564
+ primary: 'world-space-triplanar',
565
+ scaleCompensation: 'absolute-world-position',
566
+ texelDensityInvariantUnderNonUniformScale: true,
567
+ };
568
+ }
356
569
  rockMaterial.userData.environmentShaderExclude = true;
357
570
  return markFactoryStyleMaterial(rockMaterial, 'RockSurface');
358
571
  }
@@ -362,6 +575,8 @@ export function createRockShaderMaterial({
362
575
  textures = {},
363
576
  sourceMaterial = null,
364
577
  name = 'Call Me Sensei Rock',
578
+ variation = 0,
579
+ surfaceState = 'source',
365
580
  } = {}) {
366
581
  const settings = createRockShaderSettings(input);
367
582
  return createMaterialForSource({
@@ -369,6 +584,8 @@ export function createRockShaderMaterial({
369
584
  settings,
370
585
  profile: rockShaderSettingsToProfile(settings),
371
586
  textures,
587
+ variation,
588
+ surfaceState,
372
589
  name,
373
590
  });
374
591
  }
@@ -384,19 +601,65 @@ export function applyRockShader(root, input = {}, {
384
601
  name = 'Call Me Sensei Rock',
385
602
  castShadow = true,
386
603
  receiveShadow = true,
604
+ variation = 0,
605
+ surfaceState = 'source',
606
+ detail = null,
387
607
  } = {}) {
388
608
  const settings = createRockShaderSettings(input);
609
+ const rockRegionEnabled = settings.assetIntegration.regionTintStrength > 0;
610
+ if (rockRegionEnabled && detail) {
611
+ throw new Error('Rock-region styling cannot run with geometry detail; rebuild semantic masks after geometry changes.');
612
+ }
613
+ const rockRegionInspection = rockRegionEnabled
614
+ ? inspectRockRegionBindings(root, { requireEveryMesh: true, sampleValues: true })
615
+ : null;
616
+ // Geometry detail runs before anything reads or writes vertex attributes:
617
+ // subdivision replaces every buffer, so a color/AO attribute built first
618
+ // would be sized to the old vertex count. Opt-in — most placements are not
619
+ // close enough to need it, and it multiplies triangles by 4^subdivisions.
620
+ const geometryDetail = detail
621
+ ? applyRockGeometryDetail(root, { variation, ...(detail === true ? {} : detail) })
622
+ : null;
623
+ // A present-but-zero tangent attribute is worse than no tangent attribute:
624
+ // TSL trusts it, builds an invalid TBN frame, and normal-mapped facets become
625
+ // literal black. Removing only invalid tangent attributes lets Three derive
626
+ // its guarded derivative frame while leaving geometry and UVs unchanged.
627
+ const tangentIntegrity = sanitizeRockTangents(root);
628
+ // Only bind the moss cavity channel when EVERY enriched mesh actually carries
629
+ // the attribute. A material sampling an attribute that some geometry lacks is
630
+ // a hard shader failure, so this stays off unless the write is complete.
631
+ const vertexCavityStrength = geometryDetail
632
+ && geometryDetail.meshes > 0
633
+ && geometryDetail.cavity === geometryDetail.meshes
634
+ ? ROCK_MOSS_CAVITY_STRENGTH
635
+ : 0;
389
636
  const profile = rockShaderSettingsToProfile(settings);
390
637
  const retainedSourceTextureIds = new Set();
638
+ const degenerateMaps = [];
391
639
  const report = {
392
640
  preset: settings.preset,
393
641
  matched: 0,
394
642
  applied: 0,
395
643
  skipped: 0,
396
644
  textureSource: textures?.rock ? 'provided' : 'first-party-generated',
397
- usedGeneratedTextures: !textures?.rock,
645
+ usedGeneratedTextures: false,
398
646
  shadowDefaultsApplied: 0,
399
647
  retainedSourceTextures: 0,
648
+ variation,
649
+ // Slots dropped because the supplied map was too small to carry detail.
650
+ // Surfaced rather than silenced: a dropped map changes the render.
651
+ rejectedTextures: degenerateMaps,
652
+ // Tessellation/displacement actually applied, or null when not requested.
653
+ // Reported because it changes the triangle budget by up to 64x.
654
+ geometryDetail,
655
+ tangentIntegrity,
656
+ rockRegions: rockRegionInspection ? {
657
+ meshCount: rockRegionInspection.meshCount,
658
+ totalVertices: rockRegionInspection.totalVertices,
659
+ totalBytes: rockRegionInspection.totalBytes,
660
+ attribute: '_tl_rock_region',
661
+ } : null,
662
+ surfaceState,
400
663
  };
401
664
 
402
665
  root?.traverse?.((object) => {
@@ -428,13 +691,24 @@ export function applyRockShader(root, input = {}, {
428
691
  disposeOwnedMaterial(object.material);
429
692
  const sourceMaterials = Array.isArray(originalMaterial) ? originalMaterial : [originalMaterial];
430
693
  const nextMaterials = sourceMaterials.map((sourceMaterial, index) => createMaterialForSource({
694
+ degenerateMaps,
695
+ vertexCavityStrength,
696
+ rockRegionEnabled,
431
697
  sourceMaterial,
432
698
  settings,
433
699
  profile,
434
700
  textures,
701
+ variation,
702
+ surfaceState,
435
703
  name: sourceMaterials.length > 1 ? `${name} ${index + 1}` : name,
436
704
  }));
437
705
  for (const material of nextMaterials) {
706
+ const baseSource = material.userData.toonLabRockTextureComposition?.base;
707
+ if (baseSource === 'first-party-generated') {
708
+ report.usedGeneratedTextures = true;
709
+ } else if (baseSource === 'shared-semantic-material-set') {
710
+ report.textureSource = 'shared-semantic-material-set';
711
+ }
438
712
  for (const textureId of material.userData.toonlabSourceTextureIds ?? []) {
439
713
  retainedSourceTextureIds.add(textureId);
440
714
  }
@@ -472,7 +746,7 @@ export function restoreRockShader(root) {
472
746
  }
473
747
 
474
748
  /** Updates current scene inputs without changing the portable rock preset. */
475
- export function setRockShaderSceneState(root, { waterLevel } = {}) {
749
+ export function setRockShaderSceneState(root, { skyColor, waterLevel } = {}) {
476
750
  let updated = 0;
477
751
  root?.traverse?.((object) => {
478
752
  if (!object?.isMesh) return;
@@ -480,15 +754,28 @@ export function setRockShaderSceneState(root, { waterLevel } = {}) {
480
754
  for (const material of materials) {
481
755
  const state = material?.userData?.toonLabRockSceneState;
482
756
  if (!state) continue;
757
+ if (skyColor !== undefined) state.setSkyColor(skyColor);
483
758
  if (Number.isFinite(Number(waterLevel))) state.setWaterLevel(Number(waterLevel));
484
759
  updated += 1;
485
760
  }
486
761
  });
487
- return { updated, waterLevel: Number.isFinite(Number(waterLevel)) ? Number(waterLevel) : null };
762
+ const resolvedSkyColor = Array.isArray(skyColor) || ArrayBuffer.isView(skyColor)
763
+ ? Array.from(skyColor).slice(0, 3).map(Number)
764
+ : skyColor?.isColor
765
+ ? [skyColor.r, skyColor.g, skyColor.b]
766
+ : null;
767
+ return {
768
+ skyColor: resolvedSkyColor?.length === 3 && resolvedSkyColor.every(Number.isFinite)
769
+ ? resolvedSkyColor
770
+ : null,
771
+ updated,
772
+ waterLevel: Number.isFinite(Number(waterLevel)) ? Number(waterLevel) : null,
773
+ };
488
774
  }
489
775
 
490
776
  export function disposeDefaultRockShaderTextures() {
491
- if (!defaultTextureSet) return;
492
- for (const texture of Object.values(defaultTextureSet)) texture.dispose();
493
- defaultTextureSet = null;
777
+ for (const set of textureSetsByVariation.values()) {
778
+ for (const texture of Object.values(set)) texture.dispose();
779
+ }
780
+ textureSetsByVariation.clear();
494
781
  }