@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
@@ -118,6 +118,8 @@ export const TOONLAB_SURFACE_LIGHTING_CONTRACT = Object.freeze({
118
118
  directDiffuse: 'radiance * BRDFData.diffuse (no 1/PI)',
119
119
  indirectDiffuse: 'bakedGI * BRDFData.diffuse (no 1/PI)',
120
120
  directSpecular: 'TOONLAB DirectBRDFSpecular optimized GGX',
121
+ shadowFill:
122
+ 'authored fraction of the sun retained where the package sun-shadow pass reports occlusion (0 = TOONLAB literal)',
121
123
  defaultInputAdapter: DEFAULT_INPUT_ADAPTER.id,
122
124
  inputNormalization:
123
125
  'direct radiance and cosine-convolved indirect irradiance are normalized independently',
@@ -149,6 +151,7 @@ const frozenColor = (value) => Object.freeze([...value]);
149
151
  /** CPU oracle for the diffuse-only direct/indirect input decomposition. */
150
152
  export function evaluateToonLabSurfaceDiffuseDecomposition({
151
153
  brdfDiffuse = [1, 1, 1],
154
+ directStrength = 1,
152
155
  directInput = [0, 0, 0],
153
156
  indirectInput = [0, 0, 0],
154
157
  inputAdapter = DEFAULT_INPUT_ADAPTER.id,
@@ -161,8 +164,9 @@ export function evaluateToonLabSurfaceDiffuseDecomposition({
161
164
  const sourceCosine = Number(nDotL);
162
165
  if (!Number.isFinite(sourceCosine)) throw new TypeError('nDotL must be finite.');
163
166
  const cosine = Math.min(1, Math.max(0, sourceCosine));
167
+ const resolvedDirectStrength = Math.max(Number(directStrength) || 0, 0);
164
168
  const directRadiance = direct.map(
165
- (channel) => channel * adapter.directNormalization * cosine,
169
+ (channel) => channel * adapter.directNormalization * resolvedDirectStrength * cosine,
166
170
  );
167
171
  const indirectBakedGi = indirect.map(
168
172
  (channel) => channel * adapter.indirectNormalization,
@@ -224,21 +228,46 @@ function toonLabBrdfInputs(workflow, specularF0Node, diffuseAlphaNode = float(1)
224
228
  export class ToonLabSurfaceLightingModel extends PhysicalLightingModel {
225
229
  constructor({
226
230
  diffuseAlphaNode = float(1),
231
+ directStrength = 1,
227
232
  indirectStrength = 1,
228
233
  indirectTint = [1, 1, 1],
234
+ indirectTintNode = null,
235
+ indirectReplaceTintNode = null,
236
+ indirectTintBlendNode = null,
237
+ indirectTintMode = 'multiply',
229
238
  inputAdapter = DEFAULT_INPUT_ADAPTER.id,
230
239
  perceptualRoughnessNode = materialRoughness,
240
+ shadowFill = 0,
241
+ shadowFillTint = [1, 1, 1],
242
+ useSharedSunShadow = true,
231
243
  specularF0Node = materialSpecularColor,
232
244
  workflow = 'metallic',
233
245
  } = {}) {
234
246
  super(false, false, false, false, false, false);
235
247
  this.diffuseAlphaNode = diffuseAlphaNode;
248
+ this.directStrength = float(Math.max(Number(directStrength) || 0, 0));
236
249
  this.indirectStrength = float(Math.max(Number(indirectStrength) || 0, 0));
237
- this.indirectTint = vec3(...indirectTint);
250
+ // A static tint is sufficient for authored indoor materials, but outdoor
251
+ // surfaces need the active sky colour to remain live as time-of-day and
252
+ // weather change. Accepting a node here lets callers bind that scene state
253
+ // without rebuilding the material or baking a permanent daylight-blue cast.
254
+ this.indirectTint = indirectTintNode
255
+ ? vec3(indirectTintNode)
256
+ : vec3(...indirectTint);
257
+ this.indirectReplaceTint = indirectReplaceTintNode
258
+ ? vec3(indirectReplaceTintNode)
259
+ : this.indirectTint;
260
+ this.indirectTintBlend = indirectTintBlendNode ?? float(0);
261
+ this.indirectTintMode = ['replace-chroma', 'sky-adaptive'].includes(indirectTintMode)
262
+ ? indirectTintMode
263
+ : 'multiply';
238
264
  this.inputAdapter = resolveToonLabSurfaceInputAdapter(inputAdapter);
239
265
  this.perceptualRoughnessNode = perceptualRoughnessNode;
240
- this.specularF0Node = specularF0Node;
266
+ this.shadowFill = Math.min(Math.max(Number(shadowFill) || 0, 0), 1);
267
+ this.shadowFillTint = shadowFillTint;
268
+ this.useSharedSunShadow = useSharedSunShadow !== false;
241
269
  this.workflow = workflow === 'specular' ? 'specular' : 'metallic';
270
+ this.specularF0Node = specularF0Node;
242
271
  }
243
272
 
244
273
  direct({ lightDirection, lightColor, lightNode, reflectedLight }) {
@@ -248,12 +277,33 @@ export class ToonLabSurfaceLightingModel extends PhysicalLightingModel {
248
277
  // TOONLAB's no-PI BRDF convention.
249
278
  // Three can carry analytic light attenuation as RGBA for transmitted
250
279
  // coloured shadows. TOONLAB's Light.color is RGB, so consume RGB explicitly.
251
- const sharedSunVisibility = lightNode?.light?.shadow?.toonLabLightingContract
280
+ // The package sun-shadow sample is a hard 0/1 mask. Applied raw it removes
281
+ // the ENTIRE direct term on an occluded fragment, and a Call Me Sensei rig
282
+ // has no ambient light behind it — only the SH sky probe, whose measured
283
+ // radiance is R:G:B ~ 1 : 2.2 : 5.3. A fully occluded ToonLab surface
284
+ // therefore renders as saturated navy with no value structure at all
285
+ // (D19-062). `shadowFill` keeps an authored, tintable fraction of the sun
286
+ // in shadow, which is the shadow-lift the environment node material has
287
+ // always had and this bridge did not. Default 0 — an existing caller
288
+ // resolves to exactly the previous expression.
289
+ const hasToonLabSunContract = Boolean(
290
+ lightNode?.light?.shadow?.toonLabLightingContract,
291
+ );
292
+ const sunVisibility = hasToonLabSunContract && this.useSharedSunShadow
252
293
  ? sampleEnvironmentSunShadow(positionWorld)
253
- .mul(sampleEnvironmentCloudShadow(positionWorld, 1))
254
294
  : float(1);
295
+ // Cloud transmittance remains shared even when a material relies on the
296
+ // native directional shadow. This avoids double/self-shadowing while every
297
+ // outdoor surface still sees the same moving cloud field.
298
+ const rawSunVisibility = hasToonLabSunContract
299
+ ? sunVisibility.mul(sampleEnvironmentCloudShadow(positionWorld, 1))
300
+ : float(1);
301
+ const sharedSunVisibility = this.shadowFill > 0
302
+ ? mix(float(this.shadowFill).mul(vec3(...this.shadowFillTint)), vec3(1), rawSunVisibility)
303
+ : rawSunVisibility;
255
304
  const toonLabLightColor = vec3(lightColor)
256
305
  .mul(this.inputAdapter.directNormalization)
306
+ .mul(this.directStrength)
257
307
  .mul(sharedSunVisibility);
258
308
  const nDotL = clamp(dot(normalView, lightDirection), 0, 1);
259
309
  // Materialize the shadowed light once. CSM attenuation contains mutable
@@ -307,12 +357,24 @@ export class ToonLabSurfaceLightingModel extends PhysicalLightingModel {
307
357
  this.specularF0Node,
308
358
  this.diffuseAlphaNode,
309
359
  );
360
+ const adaptedIrradiance = irradiance
361
+ .mul(this.inputAdapter.indirectNormalization)
362
+ .mul(this.indirectStrength);
363
+ // Outdoor probes already contain colour. Multiplying a cool sunset probe
364
+ // by an orange horizon collapses both toward dark purple. Rocks instead
365
+ // retain the probe's directional luminance and receive chroma from the live
366
+ // visible sky, which is the scene state the player actually sees.
367
+ const multipliedIrradiance = adaptedIrradiance.mul(this.indirectTint);
368
+ const replacedIrradiance = vec3(
369
+ dot(adaptedIrradiance, vec3(0.2126, 0.7152, 0.0722)),
370
+ ).mul(this.indirectReplaceTint);
371
+ const tintedIrradiance = this.indirectTintMode === 'replace-chroma'
372
+ ? replacedIrradiance
373
+ : this.indirectTintMode === 'sky-adaptive'
374
+ ? mix(multipliedIrradiance, replacedIrradiance, this.indirectTintBlend)
375
+ : multipliedIrradiance;
310
376
  reflectedLight.indirectDiffuse.addAssign(
311
- irradiance
312
- .mul(this.inputAdapter.indirectNormalization)
313
- .mul(this.indirectStrength)
314
- .mul(this.indirectTint)
315
- .mul(brdf.diffuse),
377
+ tintedIrradiance.mul(brdf.diffuse),
316
378
  );
317
379
  }
318
380
 
@@ -334,6 +396,9 @@ export function installToonLabSurfaceLighting(material, options = {}) {
334
396
  const workflow = options.workflow ?? prior?.workflow ?? 'metallic';
335
397
  const resolvedWorkflow = workflow === 'specular' ? 'specular' : 'metallic';
336
398
  const diffuseAlphaNode = options.diffuseAlphaNode ?? float(1);
399
+ const directStrength = Math.max(Number(
400
+ options.directStrength ?? prior?.directStrength ?? 1,
401
+ ) || 0, 0);
337
402
  const indirectStrength = Math.max(Number(
338
403
  options.indirectStrength ?? prior?.indirectStrength ?? 1,
339
404
  ) || 0, 0);
@@ -341,12 +406,37 @@ export function installToonLabSurfaceLighting(material, options = {}) {
341
406
  options.indirectTint ?? prior?.indirectTint ?? [1, 1, 1],
342
407
  'indirectTint',
343
408
  );
409
+ const indirectTintNode = options.indirectTintNode ?? null;
410
+ const indirectReplaceTintNode = options.indirectReplaceTintNode ?? null;
411
+ const indirectTintBlendNode = options.indirectTintBlendNode ?? null;
412
+ const requestedIndirectTintMode = options.indirectTintMode ?? prior?.indirectTintMode;
413
+ const indirectTintMode = ['replace-chroma', 'sky-adaptive'].includes(requestedIndirectTintMode)
414
+ ? requestedIndirectTintMode
415
+ : 'multiply';
416
+ const shadowFill = Math.min(Math.max(Number(
417
+ options.shadowFill ?? prior?.shadowFill ?? 0,
418
+ ) || 0, 0), 1);
419
+ const shadowFillTint = finiteColor(
420
+ options.shadowFillTint ?? prior?.shadowFillTint ?? [1, 1, 1],
421
+ 'shadowFillTint',
422
+ );
423
+ const useSharedSunShadow = options.useSharedSunShadow
424
+ ?? prior?.useSharedSunShadow
425
+ ?? true;
344
426
  material.setupLightingModel = () => new ToonLabSurfaceLightingModel({
345
427
  diffuseAlphaNode,
428
+ directStrength,
346
429
  indirectStrength,
347
430
  indirectTint,
431
+ indirectTintNode,
432
+ indirectReplaceTintNode,
433
+ indirectTintBlendNode,
434
+ indirectTintMode,
348
435
  inputAdapter,
349
436
  perceptualRoughnessNode: material.roughnessNode ?? materialRoughness,
437
+ shadowFill,
438
+ shadowFillTint,
439
+ useSharedSunShadow,
350
440
  specularF0Node: material.specularColorNode ?? materialSpecularColor,
351
441
  workflow: resolvedWorkflow,
352
442
  });
@@ -354,9 +444,17 @@ export function installToonLabSurfaceLighting(material, options = {}) {
354
444
  ...TOONLAB_SURFACE_LIGHTING_CONTRACT,
355
445
  inputAdapter: inputAdapter.id,
356
446
  inputAdapterContract: { ...inputAdapter },
447
+ directStrength,
357
448
  indirectStrength,
358
449
  indirectTint: [...indirectTint],
450
+ indirectTintSource: indirectTintNode ? 'dynamic-node' : 'static',
451
+ indirectReplaceTintSource: indirectReplaceTintNode ? 'dynamic-node' : 'shared',
452
+ indirectTintBlendSource: indirectTintBlendNode ? 'dynamic-node' : 'static-zero',
453
+ indirectTintMode,
359
454
  preserveSpecularAlpha: options.diffuseAlphaNode != null,
455
+ shadowFill,
456
+ shadowFillTint: [...shadowFillTint],
457
+ useSharedSunShadow: useSharedSunShadow !== false,
360
458
  workflow: resolvedWorkflow,
361
459
  };
362
460
  material.needsUpdate = true;
@@ -203,6 +203,27 @@ function surfaceProfile(base, overrides) {
203
203
 
204
204
  const SURFACE_PROFILES = Object.freeze({
205
205
  ...LEGACY_SURFACE_PROFILES,
206
+ // `bareMetal` is the weathered-scrap look: it discards the source albedo's
207
+ // hue AND value (both authority scales 0) and paints the surface from
208
+ // `bareMetalColor`, then piles on rust. That is right for an untextured or
209
+ // scanned prop, and wrong for an authored brushed-stainless map — the
210
+ // anisotropy an artist baked into the albedo simply never reached the frame,
211
+ // so `metal`/`brushed` rendered as one flat panel. `brushed` is its own
212
+ // finish in the contract and now gets its own profile: the metal response
213
+ // (role hue, fresnel, planar sheen, low light cap) is kept, the authored
214
+ // VALUE structure is honoured, and the scrapyard wear is dialled out.
215
+ // `raw`/`polished`/`anodized`/`mirror` still resolve to `bareMetal`.
216
+ brushedMetal: surfaceProfile(LEGACY_SURFACE_PROFILES.bareMetal, {
217
+ highlightScale: 0.16,
218
+ lightValueCap: 0.46,
219
+ normalScale: 0.9,
220
+ planarSheenScale: 0.5,
221
+ responseValueCap: 0.78,
222
+ sharpRustBoost: 0.5,
223
+ sourceHueAuthorityScale: 0,
224
+ sourceValueAuthorityScale: 1,
225
+ wearScale: 0.45,
226
+ }),
206
227
  coatedPanel: LEGACY_SURFACE_PROFILES.lid,
207
228
  genericDielectric: surfaceProfile(LEGACY_SURFACE_PROFILES.paintedMetal, {
208
229
  colorLiftScale: 0.42,
@@ -345,6 +366,33 @@ const SURFACE_PROFILES = Object.freeze({
345
366
  planarSheenScale: 0,
346
367
  viewReflectionScale: 0.01,
347
368
  }),
369
+ // A diffusing sheet — shoji paper, a paper lantern, a fabric shade — lit
370
+ // from the far side. `MANUFACTURED_RENDER_MODES` has carried `translucent`
371
+ // and `transmissive` since v1 and the manifest validates them, but nothing
372
+ // downstream did anything with them: a paper screen converted to the same
373
+ // opaque toon surface as a poster, and the single read that makes a teahouse
374
+ // look like a teahouse — warm interior light through paper, with the lattice
375
+ // dark against it — was not expressible. See D19-079.
376
+ //
377
+ // The response is deliberately NOT physical transmission. A diffuser is not
378
+ // a window: you do not see through it, you see it lit. So the term is a
379
+ // view-independent glow, tinted by `translucencyColor`, modulated by the
380
+ // paper's own albedo (a stain or a fibre inclusion must read darker when
381
+ // backlit, which is exactly what makes washi look like washi), softened
382
+ // toward grazing angles, and NOT gated by the sun shadow, because the light
383
+ // behind the screen is not the sun.
384
+ paperTranslucent: surfaceProfile(LEGACY_SURFACE_PROFILES.graphicPanel, {
385
+ fallbackMetalness: 0,
386
+ fresnelScale: 0.04,
387
+ highlightScale: 0.02,
388
+ lightValueCap: 1.16,
389
+ materialResponseScale: 0.08,
390
+ normalScale: 0.3,
391
+ planarSheenScale: 0,
392
+ translucencyScale: 1,
393
+ viewReflectionScale: 0.01,
394
+ wearScale: 0.04,
395
+ }),
348
396
  composite: surfaceProfile(LEGACY_SURFACE_PROFILES.technicalSurface, {
349
397
  fallbackMetalness: 0.08,
350
398
  fresnelScale: 0.58,
@@ -737,6 +785,11 @@ export function createUrbanPropShaderControls(palette = 'source') {
737
785
  sourceAuthorityEnabled: numberControl(1),
738
786
  sourceAuthorityStrength: numberControl(1),
739
787
  trimColor: colorControl(selected.trimColor),
788
+ // Diffusing-sheet response (shoji, paper lantern, fabric shade). Only
789
+ // profiles that declare `translucencyScale` read these.
790
+ translucencyColor: colorControl(0xffd7a0),
791
+ translucencyEnabled: numberControl(1),
792
+ translucencyStrength: numberControl(0.85),
740
793
  viewReflectionEnabled: numberControl(1),
741
794
  viewReflectionStrength: numberControl(0.62),
742
795
  wearEnabled: numberControl(1),
@@ -1042,7 +1095,10 @@ export function resolveUrbanMaterialProfile(classification) {
1042
1095
  }
1043
1096
  switch (classification?.baseMaterial) {
1044
1097
  case 'metal':
1045
- if (['raw', 'polished', 'brushed', 'anodized', 'mirror'].includes(
1098
+ // Brushed is the one bare-metal finish that normally arrives with an
1099
+ // authored anisotropic map; it keeps that map's value structure.
1100
+ if (classification.finish === 'brushed') return 'brushedMetal';
1101
+ if (['raw', 'polished', 'anodized', 'mirror'].includes(
1046
1102
  classification.finish,
1047
1103
  )) {
1048
1104
  return 'bareMetal';
@@ -1069,7 +1125,12 @@ export function resolveUrbanMaterialProfile(classification) {
1069
1125
  case 'leather':
1070
1126
  return 'leather';
1071
1127
  case 'paper':
1072
- return 'paper';
1128
+ // A backlit diffusing sheet is a different surface from a poster, and
1129
+ // renderMode is where the contract already says so.
1130
+ return classification.renderMode === 'translucent'
1131
+ || classification.renderMode === 'transmissive'
1132
+ ? 'paperTranslucent'
1133
+ : 'paper';
1073
1134
  case 'composite':
1074
1135
  return 'composite';
1075
1136
  case 'fluid':
@@ -2602,6 +2663,24 @@ export function createUrbanAnimePropMaterial(sourceMaterial, {
2602
2663
  emissive: 0x020508,
2603
2664
  gradientMap: createLockedGradientMap(),
2604
2665
  });
2666
+ // WebGL counterpart of the WebGPU diffusing-sheet term. Without a node graph
2667
+ // the view easing is not available, so this is the flat form: the sheet's own
2668
+ // colour tinted by translucencyColor, added as emission.
2669
+ const translucencyScale = Number(profile.translucencyScale ?? 0);
2670
+ const applyTranslucency = () => {
2671
+ if (!(translucencyScale > 0)) return;
2672
+ const amount = THREE.MathUtils.clamp(
2673
+ Number(shared.translucencyEnabled?.value ?? 0)
2674
+ * Number(shared.translucencyStrength?.value ?? 0)
2675
+ * translucencyScale,
2676
+ 0,
2677
+ 2,
2678
+ );
2679
+ material.emissive
2680
+ .copy(shared.translucencyColor?.value ?? new THREE.Color(0xffd7a0))
2681
+ .multiply(material.color)
2682
+ .multiplyScalar(amount);
2683
+ };
2605
2684
  material.name = `Locked urban · ${profileId} · ${source?.name ?? 'material'}`;
2606
2685
  material.map = source?.map ?? null;
2607
2686
  material.normalMap = source?.normalMap ?? null;
@@ -2637,6 +2716,7 @@ export function createUrbanAnimePropMaterial(sourceMaterial, {
2637
2716
  material.gradientMap = shared.celLightingEnabled.value > 0.5
2638
2717
  ? createLockedGradientMap()
2639
2718
  : createSmoothGradientMap();
2719
+ applyTranslucency();
2640
2720
  };
2641
2721
  syncLookControls();
2642
2722
  material.onBeforeRender = syncLookControls;
@@ -2810,6 +2890,27 @@ export function createUrbanAnimePropNodeMaterial(sourceMaterial, {
2810
2890
  castShadowAmount.mul(0.72),
2811
2891
  );
2812
2892
 
2893
+ // Diffusing-sheet response. A backlit paper screen is not a window: the read
2894
+ // is the sheet GLOWING, not the room behind it, so this is a view-independent
2895
+ // lift rather than a transmission sample. It is modulated by the sheet's own
2896
+ // albedo so a fibre inclusion or a stain darkens when backlit — the thing
2897
+ // that makes washi read as washi — and eased off toward grazing angles,
2898
+ // where a real sheet presents more thickness and goes opaque. It is NOT
2899
+ // multiplied by `sceneShadow`, because the light behind a shoji is the
2900
+ // interior, not the sun, and a screen that stopped glowing when a cloud
2901
+ // crossed would be exactly wrong.
2902
+ const translucencyScale = Number(profile.translucencyScale ?? 0);
2903
+ const translucency = translucencyScale > 0
2904
+ ? colorControlNode(shared.translucencyColor, 0xffd7a0)
2905
+ .mul(urbanColor)
2906
+ .mul(
2907
+ scalarControl(shared.translucencyEnabled)
2908
+ .mul(scalarControl(shared.translucencyStrength))
2909
+ .mul(translucencyScale)
2910
+ .mul(mix(0.35, 1, facing)),
2911
+ )
2912
+ : null;
2913
+
2813
2914
  const material = new MeshToonNodeMaterial();
2814
2915
  material.name = `Locked urban WebGPU · ${profileId} · ${
2815
2916
  source?.name ?? 'material'
@@ -2819,6 +2920,7 @@ export function createUrbanAnimePropNodeMaterial(sourceMaterial, {
2819
2920
  // Authored emission remains visible in shade; view-dependent manufactured
2820
2921
  // highlights are direct-light cues and must disappear with the sun.
2821
2922
  material.emissiveNode = sourceEmissionNode.add(stylizedResponse.mul(sceneShadow));
2923
+ if (translucency) material.emissiveNode = material.emissiveNode.add(translucency);
2822
2924
  material.normalNode = sourceNormalNode;
2823
2925
  material.gradientMap = createLockedGradientMap();
2824
2926
  material.side = source?.side ?? THREE.FrontSide;
@@ -224,6 +224,7 @@ export function createLightingSystem({
224
224
  let hour = clamp(finite(timeOfDay, 12), 0, 24);
225
225
  let frame = sampleLightingStyle(styleSettings, hour);
226
226
  let modulation = { ...DEFAULT_MODULATION };
227
+ let sunDirectionOverride = null;
227
228
  let clock = 0;
228
229
  let placementCounter = 0;
229
230
  let disposed = false;
@@ -382,6 +383,15 @@ export function createLightingSystem({
382
383
  const shadowFocusForward = new THREE.Vector3();
383
384
  const shadowFocusTarget = new THREE.Vector3();
384
385
 
386
+ function effectiveSunSourceRatios() {
387
+ if (!sunDirectionOverride) return frame.sunSourceRatios;
388
+ return {
389
+ x: sunDirectionOverride[0],
390
+ y: sunDirectionOverride[1],
391
+ z: sunDirectionOverride[2],
392
+ };
393
+ }
394
+
385
395
  function updateOwnSunShadowFocus(nextCamera) {
386
396
  const sun = attachment.ownSun;
387
397
  const contract = CALL_ME_SENSEI_LIGHTING_CONTRACT.sun.shadow;
@@ -395,11 +405,8 @@ export function createLightingSystem({
395
405
  // The style runtime cannot infer terrain height. Preserve the host/world
396
406
  // target plane while following the camera horizontally.
397
407
  shadowFocusTarget.y = sun.target.position.y;
398
- shadowFocusDirection.set(
399
- frame.sunSourceRatios.x,
400
- frame.sunSourceRatios.y,
401
- frame.sunSourceRatios.z,
402
- ).normalize();
408
+ const sourceRatios = effectiveSunSourceRatios();
409
+ shadowFocusDirection.set(sourceRatios.x, sourceRatios.y, sourceRatios.z).normalize();
403
410
  sun.target.position.copy(shadowFocusTarget);
404
411
  sun.position.copy(shadowFocusTarget)
405
412
  .addScaledVector(shadowFocusDirection, attachment.sunDistance);
@@ -440,7 +447,8 @@ export function createLightingSystem({
440
447
  if (modulation.sunColorTint) {
441
448
  for (let i = 0; i < 3; i += 1) sunColor[i] = clamp(sunColor[i] * modulation.sunColorTint[i], 0, 4);
442
449
  }
443
- const sunDirection = [frame.sunSourceRatios.x, frame.sunSourceRatios.y, frame.sunSourceRatios.z];
450
+ const sourceRatios = effectiveSunSourceRatios();
451
+ const sunDirection = [sourceRatios.x, sourceRatios.y, sourceRatios.z];
444
452
 
445
453
  if (attachment.sunRig?.setState) {
446
454
  attachment.sunRig.setState({
@@ -450,15 +458,15 @@ export function createLightingSystem({
450
458
  intensity: frame.sunIntensity * sunScale,
451
459
  shaftOpacity: 0.1 * frame.accentScale * sunScale,
452
460
  spillOpacity: 0.3 * frame.accentScale * sunScale,
453
- ...(attachment.driveSunPosition ? { sourceRatios: frame.sunSourceRatios } : {}),
461
+ ...(attachment.driveSunPosition ? { sourceRatios } : {}),
454
462
  });
455
463
  } else if (attachment.ownSun) {
456
464
  attachment.ownSun.color.setRGB(sunColor[0], sunColor[1], sunColor[2]);
457
465
  attachment.ownSun.intensity = frame.sunIntensity * sunScale;
458
466
  attachment.ownSun.position.set(
459
- frame.sunSourceRatios.x,
460
- frame.sunSourceRatios.y,
461
- frame.sunSourceRatios.z,
467
+ sourceRatios.x,
468
+ sourceRatios.y,
469
+ sourceRatios.z,
462
470
  ).multiplyScalar(attachment.sunDistance);
463
471
  attachment.ownSun.target.position.set(0, 0, 0);
464
472
  attachment.ownSun.target.updateMatrixWorld();
@@ -543,6 +551,26 @@ export function createLightingSystem({
543
551
  attachment.setSunDirection?.(sunDirection);
544
552
  }
545
553
 
554
+ // Rock material color remains geological; this transient input colors its
555
+ // small readability floor from the same visible sky that drives the rest
556
+ // of the world. Traverse only when the lighting frame changes, not per
557
+ // render frame, and deduplicate shared materials.
558
+ if (scene?.traverse) {
559
+ const rockMaterials = new Set();
560
+ scene.traverse((object) => {
561
+ if (!object?.isMesh || !object.material) return;
562
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
563
+ for (const material of materials) {
564
+ if (material?.userData?.toonLabRockSceneState?.setSkyColor) {
565
+ rockMaterials.add(material);
566
+ }
567
+ }
568
+ });
569
+ for (const material of rockMaterials) {
570
+ material.userData.toonLabRockSceneState.setSkyColor(skyFillColor);
571
+ }
572
+ }
573
+
546
574
  if (attachment.water) {
547
575
  const waterFrame = {
548
576
  skyHorizonColor: renderedSky?.horizonColor ?? frame.skyHorizonColor,
@@ -717,6 +745,29 @@ export function createLightingSystem({
717
745
  return frame;
718
746
  },
719
747
 
748
+ /**
749
+ * Overrides the scene-space direction toward the sun without replacing
750
+ * the active time-of-day palette. Pass null to return direction ownership
751
+ * to the day curve.
752
+ */
753
+ setSunDirection(direction = null) {
754
+ if (direction == null) {
755
+ sunDirectionOverride = null;
756
+ } else {
757
+ const values = Array.isArray(direction)
758
+ ? direction.slice(0, 3).map(Number)
759
+ : [Number(direction.x), Number(direction.y), Number(direction.z)];
760
+ if (values.length < 3 || !values.every(Number.isFinite)) {
761
+ throw new TypeError('Sun direction must contain three finite components.');
762
+ }
763
+ const length = Math.hypot(...values);
764
+ if (length < 1e-6) throw new RangeError('Sun direction cannot be zero length.');
765
+ sunDirectionOverride = values.map((value) => value / length);
766
+ }
767
+ applyFrame();
768
+ return api.sunDirection;
769
+ },
770
+
720
771
  /** Advances the day cycle; use from update loops for live cycles. */
721
772
  advanceTime(hoursDelta) {
722
773
  return api.setTimeOfDay(hour + finite(hoursDelta, 0));
@@ -898,6 +949,10 @@ export function createLightingSystem({
898
949
  },
899
950
 
900
951
  get frame() { return frame; },
952
+ get sunDirection() {
953
+ const ratios = effectiveSunSourceRatios();
954
+ return [ratios.x, ratios.y, ratios.z];
955
+ },
901
956
  get manager() { return manager; },
902
957
  get quality() { return cloneJson(manager.quality); },
903
958
  get skyLightProbe() { return skyLightProbe; },
@@ -7,3 +7,10 @@ export {
7
7
  detectToonLabRendererBackend,
8
8
  stabilizeToonLabWebGPUResourceLifetime,
9
9
  } from './rendererConfiguration.js';
10
+ export {
11
+ STYLE_COMPARISON_AXES,
12
+ STYLE_COMPARISON_GENERATED_NODE_FLAGS,
13
+ captureComparisonFrameState,
14
+ createStyleComparison,
15
+ verifyStyleComparisonIdentity,
16
+ } from './styleComparison.js';