@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
@@ -45,7 +45,7 @@ export const DEFAULT_CHARACTER_ANIMATION_ROLES = Object.freeze([
45
45
  ]);
46
46
 
47
47
  function stage(callback, name, detail = {}) {
48
- callback?.({ detail, stage: name });
48
+ return callback?.({ detail, stage: name });
49
49
  }
50
50
 
51
51
  function throwIfAborted(signal) {
@@ -137,6 +137,116 @@ function prepareNeutralCharacterSource(root, renderer) {
137
137
  return replacements.size;
138
138
  }
139
139
 
140
+ export const CHARACTER_MATERIAL_MODES = Object.freeze({
141
+ neutral: 'neutral',
142
+ toon: 'toon',
143
+ });
144
+
145
+ function normalizeCharacterMaterialMode(mode) {
146
+ return mode === CHARACTER_MATERIAL_MODES.neutral
147
+ ? CHARACTER_MATERIAL_MODES.neutral
148
+ : CHARACTER_MATERIAL_MODES.toon;
149
+ }
150
+
151
+ function isToonHelperMesh(object) {
152
+ return object?.userData?.isToonOutline === true || object?.userData?.isToonFurShell === true;
153
+ }
154
+
155
+ function collectCharacterBodyMeshes(root) {
156
+ const meshes = [];
157
+ root.traverse((object) => {
158
+ if (!object?.isMesh || !object.material || isToonHelperMesh(object)) return;
159
+ meshes.push(object);
160
+ });
161
+ return meshes;
162
+ }
163
+
164
+ /**
165
+ * Builds a second, neutral material set over an already-converted character so
166
+ * a single load can present both looks.
167
+ *
168
+ * `prepareNeutralCharacterSource` mounts its neutral materials destructively and
169
+ * disposes the imported ones, which is right for a one-look runtime and wrong for
170
+ * a comparison: §11-style shader wipes must share one skeleton, one mixer and one
171
+ * set of geometry buffers, so the two looks can only differ by which `mesh.material`
172
+ * reference is bound at draw time. This builds the neutral set from the retained
173
+ * imported materials without mounting or disposing anything, so both sets stay live
174
+ * and swapping is a reference assignment rather than a rebuild.
175
+ */
176
+ function createCharacterMaterialModeState({
177
+ integration,
178
+ originalsByMesh,
179
+ renderer,
180
+ root,
181
+ }) {
182
+ const usesNodeMaterials = renderer?.isWebGPURenderer === true;
183
+ const neutralReplacements = new Map();
184
+ const neutralByMesh = new Map();
185
+ const toonByMesh = new Map();
186
+ const neutralBeforeRender = new Map();
187
+ const toonBeforeRender = new Map();
188
+ const helpers = [];
189
+
190
+ root.traverse((object) => {
191
+ if (isToonHelperMesh(object)) helpers.push(object);
192
+ });
193
+
194
+ for (const [mesh, originalMaterial] of originalsByMesh) {
195
+ const replace = (source) => {
196
+ if (!source) return source;
197
+ if (!usesNodeMaterials) return source;
198
+ if (!neutralReplacements.has(source)) {
199
+ neutralReplacements.set(source, createNeutralCharacterMaterial(source));
200
+ }
201
+ return neutralReplacements.get(source);
202
+ };
203
+ neutralByMesh.set(mesh, Array.isArray(originalMaterial)
204
+ ? originalMaterial.map(replace)
205
+ : replace(originalMaterial));
206
+ toonByMesh.set(mesh, mesh.material);
207
+ toonBeforeRender.set(mesh, mesh.onBeforeRender);
208
+ neutralBeforeRender.set(
209
+ mesh,
210
+ usesNodeMaterials && mesh.isSkinnedMesh
211
+ ? () => updateToonStorageSkinning(mesh)
212
+ : THREE.Object3D.prototype.onBeforeRender,
213
+ );
214
+ }
215
+
216
+ let mode = CHARACTER_MATERIAL_MODES.toon;
217
+
218
+ return {
219
+ disposeNeutral() {
220
+ for (const material of neutralReplacements.values()) material.dispose?.();
221
+ neutralReplacements.clear();
222
+ },
223
+ get mode() {
224
+ return mode;
225
+ },
226
+ neutralMaterialCount: usesNodeMaterials ? neutralReplacements.size : originalsByMesh.size,
227
+ set(nextMode) {
228
+ const resolved = normalizeCharacterMaterialMode(nextMode);
229
+ if (resolved === mode) return mode;
230
+ const neutral = resolved === CHARACTER_MATERIAL_MODES.neutral;
231
+ const source = neutral ? neutralByMesh : toonByMesh;
232
+ const beforeRender = neutral ? neutralBeforeRender : toonBeforeRender;
233
+ for (const [mesh, material] of source) {
234
+ mesh.material = material;
235
+ mesh.onBeforeRender = beforeRender.get(mesh);
236
+ }
237
+ // Outline and fur shells are separate child meshes, never removed — the
238
+ // toon look owns them and the neutral look must not draw them.
239
+ for (const helper of helpers) helper.visible = !neutral;
240
+ mode = resolved;
241
+ // The depth prepass and self-shadow target bind concrete materials; they
242
+ // must follow the swap or the neutral half shades against toon buffers.
243
+ integration?.refresh?.();
244
+ return mode;
245
+ },
246
+ usesNodeMaterials,
247
+ };
248
+ }
249
+
140
250
  function attachCharacterStyleMetadata(carrier, {
141
251
  managed,
142
252
  styleTarget,
@@ -181,6 +291,7 @@ function attachCharacterStyleMetadata(carrier, {
181
291
  export async function createCharacterRuntime({
182
292
  animation = true,
183
293
  carrier = null,
294
+ materialModes = false,
184
295
  materialUrl = null,
185
296
  name = 'ToonLab Character',
186
297
  onStage = null,
@@ -203,12 +314,12 @@ export async function createCharacterRuntime({
203
314
  characterCarrier.name ||= name;
204
315
  let attached = false;
205
316
 
206
- stage(onStage, CHARACTER_RUNTIME_STAGE.MODEL, { url });
317
+ await stage(onStage, CHARACTER_RUNTIME_STAGE.MODEL, { url });
207
318
  const asset = await loadModelAsset(url, { materialUrl, renderer });
208
319
  throwIfAborted(signal);
209
320
 
210
321
  try {
211
- stage(onStage, CHARACTER_RUNTIME_STAGE.TEXTURES, { asset, url });
322
+ await stage(onStage, CHARACTER_RUNTIME_STAGE.TEXTURES, { asset, url });
212
323
  await waitForObjectTextures(asset.root);
213
324
  throwIfAborted(signal);
214
325
  setObjectTextureColorSpaces(asset.root);
@@ -218,7 +329,14 @@ export async function createCharacterRuntime({
218
329
  : null;
219
330
  bakeSolidBaseColorTextures(asset.root);
220
331
 
221
- stage(onStage, CHARACTER_RUNTIME_STAGE.STYLE, { asset, bounds, url });
332
+ await stage(onStage, CHARACTER_RUNTIME_STAGE.STYLE, { asset, bounds, url });
333
+ // A dual-mode runtime keeps the imported materials alive so the neutral set
334
+ // can be rebuilt from them after conversion; the single-mode path stays
335
+ // exactly as before, including the destructive neutral swap.
336
+ const wantsMaterialModes = materialModes === true && toon !== false;
337
+ const originalsByMesh = wantsMaterialModes
338
+ ? new Map(collectCharacterBodyMeshes(asset.root).map((mesh) => [mesh, mesh.material]))
339
+ : null;
222
340
  const neutralSourceMaterialCount = toon === false
223
341
  ? prepareNeutralCharacterSource(asset.root, renderer)
224
342
  : 0;
@@ -231,7 +349,7 @@ export async function createCharacterRuntime({
231
349
  let animationSource = 'none';
232
350
 
233
351
  if (animationOptions.enabled) {
234
- stage(onStage, CHARACTER_RUNTIME_STAGE.ANIMATION, { asset, rig, targetMesh, url });
352
+ await stage(onStage, CHARACTER_RUNTIME_STAGE.ANIMATION, { asset, rig, targetMesh, url });
235
353
  const nativeRoles = resolveNativeLocomotionClips(asset.clips) ?? {};
236
354
  clipRoles = keepRequestedRoles(nativeRoles, animationOptions.roles);
237
355
  animationSource = Object.keys(clipRoles).length ? 'native' : 'none';
@@ -243,6 +361,11 @@ export async function createCharacterRuntime({
243
361
  retargetMode: animationOptions.retargetMode,
244
362
  rootMotion: animationOptions.rootMotion,
245
363
  sourceUrl: animationOptions.fallbackSourceUrl,
364
+ // This runtime owns one mixer rooted at the imported model scene,
365
+ // so packaged clips must bind named bone nodes just like native
366
+ // glTF clips. The lower-level loader keeps its skeleton-rooted
367
+ // default for callers that mix directly on a SkinnedMesh.
368
+ trackNameStyle: 'node',
246
369
  });
247
370
  throwIfAborted(signal);
248
371
  for (const role of missingRoles) {
@@ -295,6 +418,15 @@ export async function createCharacterRuntime({
295
418
  };
296
419
  characterCarrier.userData.toonlabCharacterStyleIntegration = characterStyleIntegration;
297
420
 
421
+ const materialModeState = originalsByMesh
422
+ ? createCharacterMaterialModeState({
423
+ integration: characterStyleIntegration,
424
+ originalsByMesh,
425
+ renderer,
426
+ root: asset.root,
427
+ })
428
+ : null;
429
+
298
430
  const mixer = Object.keys(clipRoles).length
299
431
  ? new THREE.AnimationMixer(asset.root)
300
432
  : null;
@@ -317,9 +449,32 @@ export async function createCharacterRuntime({
317
449
  rig,
318
450
  targetMesh,
319
451
  toonState,
320
- neutralSourceMaterialCount,
452
+ neutralSourceMaterialCount: materialModeState
453
+ ? materialModeState.neutralMaterialCount
454
+ : neutralSourceMaterialCount,
321
455
  url,
322
456
 
457
+ /**
458
+ * `'neutral' | 'toon'` when the runtime was created with
459
+ * `materialModes: true`, otherwise `null`. Reading it is cheap; it is the
460
+ * authoritative record of which material set is currently bound.
461
+ */
462
+ get materialMode() {
463
+ return materialModeState?.mode ?? null;
464
+ },
465
+
466
+ /**
467
+ * Binds one of the two material sets over the same meshes, skeleton and
468
+ * mixer. Returns the resolved mode, or `null` on a single-mode runtime.
469
+ *
470
+ * Cheap enough to call between two `renderer.render` calls in one frame,
471
+ * which is what a §11-style scissor wipe needs.
472
+ */
473
+ setMaterialMode(mode) {
474
+ if (disposed || !materialModeState) return null;
475
+ return materialModeState.set(mode);
476
+ },
477
+
323
478
  applyToonSettings(settings) {
324
479
  if (disposed || !toonState) return null;
325
480
  return applyToonSettingsToMaterial(asset.root, settings);
@@ -327,6 +482,19 @@ export async function createCharacterRuntime({
327
482
 
328
483
  dispose({ disposeResources = true } = {}) {
329
484
  if (disposed) return;
485
+ // Rebind the toon set so the unmounted half is the one this method
486
+ // disposes explicitly and `disposeLoadedRoot` still sees a complete
487
+ // mounted material set.
488
+ materialModeState?.set(CHARACTER_MATERIAL_MODES.toon);
489
+ if (disposeResources && materialModeState) {
490
+ // The neutral bridge materials, then the imported ones they were built
491
+ // from. When the renderer needs no bridge the two are the same objects
492
+ // and the second pass is a no-op re-dispose.
493
+ materialModeState.disposeNeutral();
494
+ for (const material of originalsByMesh.values()) {
495
+ for (const entry of Array.isArray(material) ? material : [material]) entry?.dispose?.();
496
+ }
497
+ }
330
498
  disposed = true;
331
499
  mixer?.stopAllAction();
332
500
  mixer?.uncacheRoot(asset.root);
@@ -363,7 +531,7 @@ export async function createCharacterRuntime({
363
531
  },
364
532
  };
365
533
 
366
- stage(onStage, CHARACTER_RUNTIME_STAGE.READY, { runtime, url });
534
+ await stage(onStage, CHARACTER_RUNTIME_STAGE.READY, { runtime, url });
367
535
  return runtime;
368
536
  } catch (error) {
369
537
  if (attached) {
@@ -33,6 +33,7 @@ import {
33
33
  setEnvironmentDebugOutput,
34
34
  } from './environmentShaderMaterials.js';
35
35
  import { resolveEnvironmentPreset } from './environmentPresets.js';
36
+ import { installToonLabSurfaceLighting } from './toonLabSurfaceLighting.js';
36
37
  import {
37
38
  resolveEnvironmentTextureSet,
38
39
  } from './environmentTextureResolver.js';
@@ -213,6 +214,23 @@ export async function applyEnvironmentShader(root, {
213
214
  scenario = undefined,
214
215
  settings = {},
215
216
  shaderMode = 'anime',
217
+ // Surface-lighting model options (D19-150). A material this adapter converts
218
+ // now JOINS the ToonLab surface-lighting model instead of being left outside
219
+ // it. Before this, `applyEnvironmentShader` reported a material as converted
220
+ // while never setting `userData.toonLabSurfaceLighting` — the exact flag every
221
+ // scene guards on when adopting its own shadow fill:
222
+ //
223
+ // if (!material?.userData?.toonLabSurfaceLighting) continue; // missed it
224
+ //
225
+ // so a scene's shadow fill silently reached every first-party surface and
226
+ // skipped every dropped-in asset. Nothing warned; the adoption loop simply
227
+ // reported a lower count. The divergence grows with how much fill the scene
228
+ // adopts, which is what made it severe rather than cosmetic.
229
+ //
230
+ // Defaults are the installer's own defaults (shadowFill 0), so installing
231
+ // here changes no existing capture — it only makes the material present in
232
+ // the model, which is what lets a scene's adoption loop find it afterwards.
233
+ surfaceLighting = {},
216
234
  openWindows = false,
217
235
  vertexAoOptions = {},
218
236
  } = {}) {
@@ -258,6 +276,8 @@ export async function applyEnvironmentShader(root, {
258
276
  : 'anime';
259
277
  const resolvedDebugMode = normalizeEnvironmentDebugMode(debugOutputMode);
260
278
  let convertedMeshCount = 0;
279
+ // D19-150: how many converted materials actually joined the lighting model.
280
+ let surfaceLightingMaterialCount = 0;
261
281
  let aoOverlayMeshCount = 0;
262
282
  let windowCutoutMaterialCount = 0;
263
283
  let shadowMeshCount = 0;
@@ -409,6 +429,15 @@ export async function applyEnvironmentShader(root, {
409
429
 
410
430
  for (const mat of toMaterialArray(obj.material)) {
411
431
  if (mat?.userData?.windowCutout) windowCutoutMaterialCount += 1;
432
+ // D19-150: enrol every converted environment material in the surface
433
+ // lighting model. `installToonLabSurfaceLighting` is a no-op on anything
434
+ // that is not a NodeMaterial and preserves prior options when re-run, so
435
+ // this is safe both for materials that already carry it and for the
436
+ // scene-side adoption pass that re-installs with an authored shadowFill.
437
+ if (mat?.isNodeMaterial && mat.userData?.environmentMaterial) {
438
+ installToonLabSurfaceLighting(mat, surfaceLighting);
439
+ if (mat.userData?.toonLabSurfaceLighting) surfaceLightingMaterialCount += 1;
440
+ }
412
441
  }
413
442
  if (toMaterialArray(obj.material).some((mat) => mat?.userData?.environmentMaterial)) {
414
443
  obj.onBeforeRender = environmentNodeLightSync;
@@ -430,6 +459,7 @@ export async function applyEnvironmentShader(root, {
430
459
  aoOverlayMeshCount,
431
460
  classification,
432
461
  convertedMeshCount,
462
+ surfaceLightingMaterialCount,
433
463
  manifestWarnings: manifestResult?.warnings ?? [],
434
464
  manufacturedAssetId: resolvedAssetId,
435
465
  manufacturedObjectClass: resolvedObjectClass,
@@ -660,6 +660,96 @@ registerEnvironmentPreset('call_me_sensei', {
660
660
  },
661
661
  },
662
662
  },
663
+ // The finish axis is the one an audience actually reads at a glance:
664
+ // brushed stainless next to charcoal powder coat is the same base material
665
+ // and must not look like the same surface. Without these profiles the
666
+ // finish was validated by the contract, stored on the material, and then
667
+ // had no effect on the frame, so `metal`/`brushed` converted to the same
668
+ // response as `mineral`/`raw`. Resolution order puts finishes AFTER
669
+ // baseMaterials, so a finish refines its base rather than fighting it.
670
+ finishes: {
671
+ anodized: {
672
+ parameters: {
673
+ specularShininess: 70,
674
+ specularSoftness: 0.3,
675
+ specularStrength: 0.34,
676
+ },
677
+ },
678
+ brushed: {
679
+ // Anisotropic metal: a broad, smeared highlight (low shininess, wide
680
+ // softness) rather than a point hit, plus a real sky pickup — that
681
+ // cool skylight in the mid-tones is what says "metal" in an anime
682
+ // frame, not the metalness channel the toon shader does not have.
683
+ parameters: {
684
+ skyTintStrength: 0.34,
685
+ specularColor: [0.86, 0.9, 1],
686
+ specularShininess: 26,
687
+ specularSoftness: 0.42,
688
+ specularStrength: 0.55,
689
+ },
690
+ },
691
+ clearCoated: {
692
+ parameters: {
693
+ specularShininess: 140,
694
+ specularSoftness: 0.14,
695
+ specularStrength: 0.42,
696
+ },
697
+ },
698
+ glazed: {
699
+ parameters: {
700
+ specularShininess: 120,
701
+ specularSoftness: 0.16,
702
+ specularStrength: 0.46,
703
+ },
704
+ },
705
+ matte: {
706
+ parameters: {
707
+ specularShininess: 10,
708
+ specularSoftness: 0.55,
709
+ specularStrength: 0.03,
710
+ },
711
+ },
712
+ mirror: {
713
+ parameters: {
714
+ skyTintStrength: 0.6,
715
+ specularShininess: 240,
716
+ specularSoftness: 0.08,
717
+ specularStrength: 0.9,
718
+ },
719
+ },
720
+ // Powder coat and enamel are dielectric films: a soft, broad sheen that
721
+ // must stay clearly below `brushed` so painted metal and bare metal
722
+ // separate at a glance.
723
+ painted: {
724
+ parameters: {
725
+ specularShininess: 34,
726
+ specularSoftness: 0.34,
727
+ specularStrength: 0.14,
728
+ },
729
+ },
730
+ polished: {
731
+ parameters: {
732
+ skyTintStrength: 0.45,
733
+ specularShininess: 180,
734
+ specularSoftness: 0.1,
735
+ specularStrength: 0.7,
736
+ },
737
+ },
738
+ raw: {
739
+ parameters: {
740
+ specularShininess: 12,
741
+ specularSoftness: 0.5,
742
+ specularStrength: 0.04,
743
+ },
744
+ },
745
+ varnished: {
746
+ parameters: {
747
+ specularShininess: 90,
748
+ specularSoftness: 0.2,
749
+ specularStrength: 0.3,
750
+ },
751
+ },
752
+ },
663
753
  objectClasses: {
664
754
  buildingExterior: {
665
755
  parameters: { normalMapStrength: 0.66 },
@@ -61,6 +61,13 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
61
61
  const warmedCasterSources = new WeakMap();
62
62
  let cachedSunLight = null;
63
63
  let renderCount = 0;
64
+ // Real health, not "the object exists". D19-041 shipped a Gate 3 check
65
+ // written as `Boolean(runtime.shadowPass)`, which is true from construction
66
+ // and therefore cannot fail. These are the three things that actually have
67
+ // to be true for a receiver to see a cast shadow.
68
+ let lastCasterCount = 0;
69
+ let lastSkippedNonCasterCount = 0;
70
+ let lastFailure = 'pass has not run';
64
71
  let casterDepthSamplePoints = Object.freeze([]);
65
72
  let casterCoverage = Object.freeze({
66
73
  byDomain: Object.freeze({}),
@@ -345,6 +352,8 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
345
352
  if (!sun) {
346
353
  environmentSunShadow.ready.value = false;
347
354
  environmentSunShadow.farReady.value = false;
355
+ lastCasterCount = 0;
356
+ lastFailure = 'no visible DirectionalLight with castShadow in the scene';
348
357
  return;
349
358
  }
350
359
 
@@ -359,6 +368,7 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
359
368
  if (needsPrimaryRenderWarmup) {
360
369
  // The host's normal scene render follows this scheduled pass. Retain any
361
370
  // existing map for one frame and include the new caster on the next one.
371
+ lastFailure = 'warming up a newly added caster';
362
372
  return;
363
373
  }
364
374
 
@@ -557,6 +567,9 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
557
567
  renderer.shadowMap.enabled = previousShadowAutoUpdate;
558
568
  scene.background = previousBackground;
559
569
 
570
+ lastCasterCount = materialRestores.length;
571
+ lastSkippedNonCasterCount = visibilityRestores.length;
572
+ lastFailure = lastCasterCount > 0 ? null : 'no visible castShadow mesh was drawn into the map';
560
573
  for (const { material, mesh } of materialRestores) mesh.material = material;
561
574
  for (const obj of visibilityRestores) obj.visible = true;
562
575
  materialRestores.length = 0;
@@ -607,12 +620,41 @@ export function createEnvironmentSunShadowPass({ renderer, scene } = {}) {
607
620
  get ready() {
608
621
  return environmentSunShadow.ready.value === true;
609
622
  },
623
+ /**
624
+ * Whether the pass is actually producing a usable shadow map, and why not
625
+ * when it is not. Hosts gating a review on shadows must read `ok` here
626
+ * rather than testing that the pass object exists (D19-041).
627
+ */
628
+ get health() {
629
+ const ready = environmentSunShadow.ready.value === true;
630
+ const ok = ready && renderCount > 0 && lastCasterCount > 0;
631
+ return Object.freeze({
632
+ backend: isNodeBackend ? 'node' : 'classic-webgl-unused',
633
+ casterCount: lastCasterCount,
634
+ farReady: environmentSunShadow.farReady.value === true,
635
+ hiddenNonCasterCount: lastSkippedNonCasterCount,
636
+ ok,
637
+ ready,
638
+ reason: ok ? null : (lastFailure ?? (ready ? null : 'pass has not published a map')),
639
+ renderCount,
640
+ sunName: cachedSunLight?.name ?? null,
641
+ });
642
+ },
610
643
  get renderCount() {
611
644
  return renderCount;
612
645
  },
613
646
  get shadowTexture() {
614
647
  return shadowTarget?.texture ?? null;
615
648
  },
649
+ // Render targets, for diagnostics that need to read the depth the pass
650
+ // actually wrote (D19-041/D19-062). `inspectDepthContent` only samples
651
+ // labelled style targets; these let a caller probe any world point.
652
+ get nearShadowTarget() {
653
+ return shadowTarget;
654
+ },
655
+ get farShadowTarget() {
656
+ return farShadowTarget;
657
+ },
616
658
  get shadowMatrix() {
617
659
  return shadowMatrix;
618
660
  },