@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
@@ -138,7 +138,7 @@ const GROUP_DEFINITIONS = Object.freeze({
138
138
  fields: Object.freeze({
139
139
  exposure: field({
140
140
  defaultValue: 1,
141
- description: 'HDR albedo exposure before scene lighting and tone mapping.',
141
+ description: 'Rock-specific direct-sun calibration. Base Color and indirect sky light remain unchanged.',
142
142
  label: 'Exposure',
143
143
  range: { max: 4, min: 0, step: 0.01 },
144
144
  }),
@@ -148,6 +148,12 @@ const GROUP_DEFINITIONS = Object.freeze({
148
148
  label: 'Ambient Floor',
149
149
  range: { max: 0.4, min: 0, step: 0.005 },
150
150
  }),
151
+ skyColorInfluence: field({
152
+ defaultValue: 0.35,
153
+ description: 'Fraction of active sky chroma admitted into the albedo-relative readability floor.',
154
+ label: 'Sky Color Influence',
155
+ range: { max: 1, min: 0, step: 0.01 },
156
+ }),
151
157
  skyFillStrength: field({
152
158
  defaultValue: 1,
153
159
  description: 'Rock-specific strength of the scene sky probe; direct sunlight is unchanged.',
@@ -160,6 +166,18 @@ const GROUP_DEFINITIONS = Object.freeze({
160
166
  label: 'Sky Fill Tint',
161
167
  type: 'color',
162
168
  }),
169
+ shadowFill: field({
170
+ defaultValue: 0,
171
+ description: 'Fraction of direct sun retained where the shared low-angle shadow mask occludes the rock. Normal-facing response is preserved.',
172
+ label: 'Direct Shadow Fill',
173
+ range: { max: 1, min: 0, step: 0.01 },
174
+ }),
175
+ shadowFillTint: field({
176
+ defaultValue: [1, 1, 1],
177
+ description: 'RGB weighting for retained direct light inside shared sun-shadow coverage.',
178
+ label: 'Direct Shadow Fill Tint',
179
+ type: 'color',
180
+ }),
163
181
  }),
164
182
  }),
165
183
  shoreline: Object.freeze({
@@ -315,7 +333,11 @@ const GROUP_DEFINITIONS = Object.freeze({
315
333
  defaultValue: 1.6,
316
334
  description: 'Strength of the projected moss coverage pattern.',
317
335
  label: 'Coverage Gain',
318
- range: { max: 6, min: 0, step: 0.01 },
336
+ // Ceiling raised from 6. The shader squares this term against a moss
337
+ // albedo whose linear luminance is near 0.13 (D19-032), so 6 is not
338
+ // enough to reach full coverage on a surface where moss is the
339
+ // subject rather than incidental weathering. The default is unchanged.
340
+ range: { max: 24, min: 0, step: 0.01 },
319
341
  }),
320
342
  colorPower: field({
321
343
  defaultValue: 1.25,
@@ -335,6 +357,152 @@ const GROUP_DEFINITIONS = Object.freeze({
335
357
  label: 'High Color',
336
358
  type: 'color',
337
359
  }),
360
+ // --- moss as a material, not a tint --------------------------------
361
+ // New capability. Every default is the previous behaviour, so a style
362
+ // that does not set them renders exactly as before.
363
+ roughness: field({
364
+ defaultValue: -1,
365
+ description: 'Surface roughness under full moss; -1 keeps the stone value.',
366
+ label: 'Moss Roughness',
367
+ range: { max: 1, min: -1, step: 0.01 },
368
+ }),
369
+ relief: field({
370
+ defaultValue: 0,
371
+ description: 'Flattens the rock detail normal under moss, as a cushion would.',
372
+ label: 'Moss Relief Fill',
373
+ range: { max: 1, min: 0, step: 0.01 },
374
+ }),
375
+ fringe: field({
376
+ defaultValue: 0,
377
+ description: 'Ragged break-up of the moss coverage boundary.',
378
+ label: 'Edge Fringe',
379
+ range: { max: 1, min: 0, step: 0.01 },
380
+ }),
381
+ fringeScale: field({
382
+ defaultValue: 0.35,
383
+ // Now a fraction of PATCH scale, not of the moss projection size. The
384
+ // fringe field is world-space fBm rather than a resample of the moss
385
+ // albedo (D19-211), and what it has to break up is the patch boundary,
386
+ // so the patch period is the only period it can be meaningfully
387
+ // relative to. `fringe` defaults to 0, so nothing shipped observes the
388
+ // change.
389
+ description: 'Fringe noise period as a fraction of the colonisation patch scale.',
390
+ label: 'Fringe Scale',
391
+ range: { max: 1, min: 0.02, step: 0.01 },
392
+ }),
393
+ formDriven: field({
394
+ defaultValue: 0,
395
+ description: 'Blends coverage from the moss albedo toward the geometric moisture field.',
396
+ label: 'Form-Driven Coverage',
397
+ range: { max: 1, min: 0, step: 0.01 },
398
+ }),
399
+ // --- coverage mask -------------------------------------------------
400
+ // `coverage` is the switch: -1 keeps the shipped squared-clamp curve,
401
+ // whose transition band is so narrow that moss resolves as a horizontal
402
+ // waterline. 0..1 selects the patch-mask path, where this value IS the
403
+ // fraction of supported surface moss takes.
404
+ coverage: field({
405
+ defaultValue: -1,
406
+ description: 'Target moss coverage of supported surface; -1 keeps the legacy coverage curve.',
407
+ label: 'Coverage',
408
+ range: { max: 1, min: -1, step: 0.01 },
409
+ }),
410
+ band: field({
411
+ defaultValue: 0.18,
412
+ description: 'Width of the coverage transition. Narrow bands read as a dyed waterline.',
413
+ label: 'Transition Band',
414
+ range: { max: 0.5, min: 0.001, step: 0.001 },
415
+ }),
416
+ patchScale: field({
417
+ defaultValue: 0.6,
418
+ description: 'World-space period of the colonisation noise, metres.',
419
+ label: 'Patch Scale',
420
+ range: { max: 20, min: 0.01, step: 0.01 },
421
+ }),
422
+ patchStrength: field({
423
+ defaultValue: 0.8,
424
+ description: 'How strongly colonisation noise gates coverage. 0 gives an unbroken cap.',
425
+ label: 'Patch Strength',
426
+ range: { max: 1, min: 0, step: 0.01 },
427
+ }),
428
+ patchOctaves: field({
429
+ defaultValue: 3,
430
+ description: 'Octaves of colonisation noise.',
431
+ label: 'Patch Octaves',
432
+ range: { max: 6, min: 1, step: 1 },
433
+ }),
434
+ patchContrast: field({
435
+ defaultValue: 1.6,
436
+ description: 'Separates the noise into distinct cushions rather than a smooth cloud.',
437
+ label: 'Patch Contrast',
438
+ range: { max: 6, min: 0.1, step: 0.01 },
439
+ }),
440
+ // --- value, form and depth -----------------------------------------
441
+ // The four defects that made a working patch mask still read as
442
+ // camouflage rather than as moss (D19-210 … D19-213). Every default is
443
+ // inert: -1 and 0 reproduce the shipped render exactly.
444
+ //
445
+ // `patternFloor` / `patternCeiling` remap the moss map's own luminance
446
+ // range onto the colour ramp. The shipped path feeds the RAW rgb sample
447
+ // into `mix(lowColor, highColor)`, and the shipped moss albedo occupies a
448
+ // linear luminance of 0.092-0.279 with 99.3% of its texels inside a
449
+ // single decile. The ramp coordinate therefore never exceeds ~0.09: moss
450
+ // renders as `lowColor` everywhere, `highColor` is dead, and the five
451
+ // authored palette stops collapse to one very dark green. Setting a floor
452
+ // switches the ramp to a scalar remap of that measured range, which both
453
+ // reaches the light end of the palette and stops the per-channel sample
454
+ // from skewing hue. See D19-210.
455
+ patternFloor: field({
456
+ defaultValue: -1,
457
+ description: 'Moss-map luminance that maps to Low Color; -1 keeps the shipped per-channel ramp.',
458
+ label: 'Pattern Floor',
459
+ range: { max: 1, min: -1, step: 0.001 },
460
+ }),
461
+ patternCeiling: field({
462
+ defaultValue: 1,
463
+ description: 'Moss-map luminance that maps to High Color. Ignored when Pattern Floor is -1.',
464
+ label: 'Pattern Ceiling',
465
+ range: { max: 1, min: 0, step: 0.001 },
466
+ }),
467
+ // Moss and stone read at a close value in life because moss grows where
468
+ // the stone stays WET, and wet stone is darker than dry stone. Without
469
+ // this the only way to close the value gap is to lift the moss or drop
470
+ // the stone globally — the first fights the shared palette, the second is
471
+ // the scene's grade to make. This closes it from the physically correct
472
+ // side and leaves a damp halo just beyond each cushion. See D19-210.
473
+ damp: field({
474
+ defaultValue: 0,
475
+ description: 'Darkens the stone where the moss support field is wet, including just beyond the moss.',
476
+ label: 'Damp Stone',
477
+ range: { max: 1, min: 0, step: 0.01 },
478
+ }),
479
+ // Crowns, ridges and shoulders shed water and dry first, so they stay
480
+ // bare. The support term could express "hollow" via `rockCavity` but had
481
+ // no term for "exposed", and its slope half saturates across the whole
482
+ // upper hemisphere — leaving the colonisation noise as the only thing
483
+ // distinguishing one upper face from another. See D19-212.
484
+ exposure: field({
485
+ defaultValue: 0,
486
+ description: 'How strongly convex, weather-exposed form (crowns and ridges) suppresses moss.',
487
+ label: 'Exposure Penalty',
488
+ range: { max: 2, min: 0, step: 0.01 },
489
+ }),
490
+ // A moss cushion is a raised soft mass, not a decal. `relief` flattens the
491
+ // stone's own detail under the moss but nothing ever raised the moss
492
+ // itself, so the patch stayed geometrically identical to the rock it sat
493
+ // on. See D19-213.
494
+ cushion: field({
495
+ defaultValue: 0,
496
+ description: 'Raises the moss into a soft cushion by tilting the shading normal along the patch field.',
497
+ label: 'Cushion Depth',
498
+ range: { max: 1, min: 0, step: 0.01 },
499
+ }),
500
+ contact: field({
501
+ defaultValue: 0,
502
+ description: 'Contact shading where a cushion meets bare stone — the occlusion a raised mass casts.',
503
+ label: 'Contact Shade',
504
+ range: { max: 1, min: 0, step: 0.01 },
505
+ }),
338
506
  }),
339
507
  }),
340
508
  layerMask: Object.freeze({
@@ -540,6 +708,42 @@ const GROUP_DEFINITIONS = Object.freeze({
540
708
  label: 'Vertex AO Strength',
541
709
  range: { max: 2, min: 0, step: 0.01 },
542
710
  }),
711
+ regionTintStrength: field({
712
+ defaultValue: 0,
713
+ description: 'Influence of the fail-closed _TL_ROCK_REGION base, shaft, neck, and cap palette. Zero does not require the attribute.',
714
+ label: 'Geology Region Tint',
715
+ range: { max: 1, min: 0, step: 0.01 },
716
+ }),
717
+ regionBaseTint: field({
718
+ defaultValue: [1, 1, 1],
719
+ description: 'Multiplier applied to the geological support/base region.',
720
+ label: 'Base Region Tint',
721
+ type: 'color',
722
+ }),
723
+ regionShaftTint: field({
724
+ defaultValue: [1, 1, 1],
725
+ description: 'Multiplier applied to the main hoodoo shaft region.',
726
+ label: 'Shaft Region Tint',
727
+ type: 'color',
728
+ }),
729
+ regionNeckTint: field({
730
+ defaultValue: [1, 1, 1],
731
+ description: 'Independent overlay multiplier around the constricted neck.',
732
+ label: 'Neck Region Tint',
733
+ type: 'color',
734
+ }),
735
+ regionCapTint: field({
736
+ defaultValue: [1, 1, 1],
737
+ description: 'Multiplier applied to the resistant caprock region.',
738
+ label: 'Cap Region Tint',
739
+ type: 'color',
740
+ }),
741
+ regionNeckOverlayStrength: field({
742
+ defaultValue: 0,
743
+ description: 'How strongly the overlapping neck mask modifies the primary base/shaft/cap partition.',
744
+ label: 'Neck Overlay Strength',
745
+ range: { max: 1, min: 0, step: 0.01 },
746
+ }),
543
747
  }),
544
748
  }),
545
749
  });
@@ -589,9 +793,12 @@ export const DEFAULT_ROCK_SHADER_SETTINGS = Object.freeze(Object.fromEntries(
589
793
  export const CALL_ME_SENSEI_ROCK_SHADER_SETTINGS = Object.freeze({
590
794
  projection: Object.freeze({
591
795
  scale: 48,
592
- saturation: 0.72,
593
- contrast: 0.72,
594
- brightness: 0.04,
796
+ // Geological maps already own lithology color and value structure. The
797
+ // previous 0.72 saturation/contrast pair washed every family toward the
798
+ // same pale grey before lighting, most visibly red sandstone.
799
+ saturation: 1,
800
+ contrast: 1,
801
+ brightness: 0,
595
802
  projectionContrast: 2,
596
803
  sideOnly: false,
597
804
  nearDetailScale: 1.2,
@@ -599,10 +806,11 @@ export const CALL_ME_SENSEI_ROCK_SHADER_SETTINGS = Object.freeze({
599
806
  nearDetailDistance: 70,
600
807
  }),
601
808
  material: Object.freeze({
602
- // Daylight stone is blue-white rather than neutral grey. Keep this bias
603
- // subtle: the shared sky probe still owns the stronger blue response in
604
- // recesses and on back faces.
605
- tint: Object.freeze([0.97, 0.99, 1]),
809
+ // Geological Base Color stays neutral at the material boundary. Daylight,
810
+ // golden hour, weather, and night color belong to the shared scene sun and
811
+ // sky probe; baking a blue-white cast here washed warm rocks such as
812
+ // sandstone out even before they saw the active environment.
813
+ tint: Object.freeze([1, 1, 1]),
606
814
  metallic: 0,
607
815
  smoothness: 0.07,
608
816
  useSmoothnessTexture: false,
@@ -610,14 +818,23 @@ export const CALL_ME_SENSEI_ROCK_SHADER_SETTINGS = Object.freeze({
610
818
  emissiveStrength: 0,
611
819
  }),
612
820
  lighting: Object.freeze({
613
- // Match the accepted high-key response under the package's white
614
- // intensity-8 daylight sun. This is material exposure, not a scene-local
615
- // light override: sun faces retain chalk-white headroom while the probe
616
- // owns the blue shaded face.
617
- exposure: 0.9,
618
- ambientFloor: 0.01,
821
+ // Preserve Base Color headroom under the package's intensity-8 daylight
822
+ // sun. This is material exposure, not a scene-local light override.
823
+ exposure: 0.95,
824
+ // Keep the readability floor small: the main shadow-side value and colour
825
+ // must come from the scene's live sky, not a neutral emissive wash.
826
+ ambientFloor: 0.04,
827
+ skyColorInfluence: 1,
828
+ // Restore the strong open-sky fill that gave the accepted rocks their blue
829
+ // daylight backs. Its colour is now dynamic rather than baked blue, so the
830
+ // same channel becomes pink/orange at sunset and deep blue at night.
619
831
  skyFillStrength: 0.72,
620
- skyFillTint: Object.freeze([0.72, 0.86, 1]),
832
+ skyFillTint: Object.freeze([1, 1, 1]),
833
+ // Shadowed faces belong to the sky probe. Retaining any fraction of the
834
+ // sunset sun here paints orange into the cool side and destroys the
835
+ // warm-key/cool-fill separation.
836
+ shadowFill: 0,
837
+ shadowFillTint: Object.freeze([1, 1, 1]),
621
838
  }),
622
839
  shoreline: Object.freeze({
623
840
  wetBandWidth: 1,
@@ -652,6 +869,19 @@ export const CALL_ME_SENSEI_ROCK_SHADER_SETTINGS = Object.freeze({
652
869
  colorPower: 1.3,
653
870
  lowColor: Object.freeze([0.24, 0.42, 0.12]),
654
871
  highColor: Object.freeze([0.46, 0.68, 0.24]),
872
+ // New capability, neutral defaults. No authored value above is changed.
873
+ roughness: -1,
874
+ relief: 0,
875
+ fringe: 0,
876
+ fringeScale: 0.35,
877
+ formDriven: 0,
878
+ // Inert: `coverage: -1` keeps the shipped coverage curve.
879
+ coverage: -1,
880
+ band: 0.18,
881
+ patchScale: 0.6,
882
+ patchStrength: 0.8,
883
+ patchOctaves: 3,
884
+ patchContrast: 1.6,
655
885
  }),
656
886
  layerMask: Object.freeze({
657
887
  useAssetMask: true,
@@ -0,0 +1,136 @@
1
+ const DEFAULT_EPSILON = 1e-6;
2
+ const DEFAULT_ORTHOGONALITY_TOLERANCE = 0.1;
3
+
4
+ function finite(value) {
5
+ return Number.isFinite(Number(value));
6
+ }
7
+
8
+ /**
9
+ * Audits a geometry tangent attribute before a normal-mapped material can
10
+ * consume it. A missing tangent attribute is valid: Three.js derives a
11
+ * derivative TBN frame. A present but zero/invalid attribute is not valid and
12
+ * produces literal black facets in WebGPU/TSL normal mapping.
13
+ */
14
+ export function inspectRockGeometryTangents(geometry, {
15
+ epsilon = DEFAULT_EPSILON,
16
+ orthogonalityTolerance = DEFAULT_ORTHOGONALITY_TOLERANCE,
17
+ } = {}) {
18
+ const position = geometry?.getAttribute?.('position');
19
+ const normal = geometry?.getAttribute?.('normal');
20
+ const tangent = geometry?.getAttribute?.('tangent');
21
+ const vertices = position?.count ?? 0;
22
+ const report = {
23
+ vertices,
24
+ hasTangents: Boolean(tangent),
25
+ tangentVertices: tangent?.count ?? 0,
26
+ invalidVertices: 0,
27
+ zeroLength: 0,
28
+ nonFinite: 0,
29
+ invalidHandedness: 0,
30
+ nonOrthogonal: 0,
31
+ countMismatch: Boolean(tangent && tangent.count !== vertices),
32
+ itemSizeInvalid: Boolean(tangent && tangent.itemSize < 4),
33
+ valid: true,
34
+ };
35
+ if (!tangent) return report;
36
+
37
+ const epsilonSquared = Math.max(Number(epsilon) || DEFAULT_EPSILON, 1e-12) ** 2;
38
+ const orthogonality = Math.max(
39
+ Number(orthogonalityTolerance) || DEFAULT_ORTHOGONALITY_TOLERANCE,
40
+ 1e-6,
41
+ );
42
+ const count = Math.min(tangent.count, vertices || tangent.count);
43
+ for (let index = 0; index < count; index += 1) {
44
+ const x = tangent.getX(index);
45
+ const y = tangent.getY(index);
46
+ const z = tangent.getZ(index);
47
+ const w = tangent.itemSize > 3 ? tangent.getW(index) : 0;
48
+ const lengthSquared = (x * x) + (y * y) + (z * z);
49
+ let invalid = false;
50
+ if (![x, y, z, w].every(finite)) {
51
+ report.nonFinite += 1;
52
+ invalid = true;
53
+ }
54
+ if (!finite(lengthSquared) || lengthSquared <= epsilonSquared) {
55
+ report.zeroLength += 1;
56
+ invalid = true;
57
+ }
58
+ if (!finite(w) || Math.abs(w) < 0.5) {
59
+ report.invalidHandedness += 1;
60
+ invalid = true;
61
+ }
62
+ if (normal && normal.count > index && finite(lengthSquared) && lengthSquared > epsilonSquared) {
63
+ const nx = normal.getX(index);
64
+ const ny = normal.getY(index);
65
+ const nz = normal.getZ(index);
66
+ const normalLengthSquared = (nx * nx) + (ny * ny) + (nz * nz);
67
+ if (normalLengthSquared > epsilonSquared) {
68
+ const cosine = Math.abs((x * nx) + (y * ny) + (z * nz))
69
+ / Math.sqrt(lengthSquared * normalLengthSquared);
70
+ if (!finite(cosine) || cosine > orthogonality) {
71
+ report.nonOrthogonal += 1;
72
+ invalid = true;
73
+ }
74
+ }
75
+ }
76
+ if (invalid) report.invalidVertices += 1;
77
+ }
78
+ if (report.countMismatch || report.itemSizeInvalid) {
79
+ report.invalidVertices = Math.max(report.invalidVertices, tangent.count);
80
+ }
81
+ report.valid = report.invalidVertices === 0
82
+ && !report.countMismatch
83
+ && !report.itemSizeInvalid;
84
+ return report;
85
+ }
86
+
87
+ /**
88
+ * Removes only invalid tangent attributes. Three.js then uses its derivative
89
+ * tangent frame, which is the safe fallback for degenerate UV islands. Vertex
90
+ * positions, normals, UVs, indices, materials, and texture files are untouched.
91
+ */
92
+ export function sanitizeRockGeometryTangents(geometry, options = {}) {
93
+ const inspection = inspectRockGeometryTangents(geometry, options);
94
+ const removed = inspection.hasTangents && !inspection.valid;
95
+ if (removed) {
96
+ geometry.deleteAttribute('tangent');
97
+ geometry.userData ??= {};
98
+ geometry.userData.toonLabRemovedInvalidTangents = {
99
+ invalidVertices: inspection.invalidVertices,
100
+ tangentVertices: inspection.tangentVertices,
101
+ };
102
+ }
103
+ return { ...inspection, removed };
104
+ }
105
+
106
+ /** Audits and repairs every mesh below a rock root. */
107
+ export function sanitizeRockTangents(root, options = {}) {
108
+ const issues = [];
109
+ const report = {
110
+ meshes: 0,
111
+ meshesWithTangents: 0,
112
+ repairedMeshes: 0,
113
+ vertices: 0,
114
+ tangentVertices: 0,
115
+ invalidVertices: 0,
116
+ issues,
117
+ };
118
+ root?.traverse?.((object) => {
119
+ if (!object?.isMesh || !object.geometry) return;
120
+ report.meshes += 1;
121
+ const result = sanitizeRockGeometryTangents(object.geometry, options);
122
+ report.vertices += result.vertices;
123
+ report.tangentVertices += result.tangentVertices;
124
+ report.invalidVertices += result.invalidVertices;
125
+ if (result.hasTangents) report.meshesWithTangents += 1;
126
+ if (result.removed) {
127
+ report.repairedMeshes += 1;
128
+ issues.push({
129
+ mesh: object.name || `mesh-${report.meshes}`,
130
+ invalidVertices: result.invalidVertices,
131
+ tangentVertices: result.tangentVertices,
132
+ });
133
+ }
134
+ });
135
+ return report;
136
+ }
@@ -0,0 +1,115 @@
1
+ // Integrity checks for textures handed to the rock shader.
2
+ //
3
+ // A detail map only earns its slot if it can actually carry detail. Published
4
+ // catalog artifacts sometimes ship a placeholder in a real texture slot — a
5
+ // 4x4 normal map, for instance — which is materially worse than shipping
6
+ // nothing: the shader's own deterministic fallback is a usable surface, and a
7
+ // placeholder silently displaces it. Every consumer that harvests maps off an
8
+ // imported material needs the same predicate, so it lives here rather than in
9
+ // any one call site.
10
+ //
11
+ // This is deliberately a *resolution* test, not a content test. Reading pixels
12
+ // back is not possible for GPU-compressed formats (KTX2/Basis) without a
13
+ // decode, and resolution alone is sufficient to catch the placeholder class.
14
+
15
+ /**
16
+ * Minimum edge length, in texels, for a map to be treated as carrying detail.
17
+ *
18
+ * A triplanar detail map is projected at metre scale, so its texel density is
19
+ * what produces fracture and erosion relief. Below 16x16 there is no spatial
20
+ * frequency left to project and the map is indistinguishable from a constant.
21
+ */
22
+ export const MIN_DETAIL_MAP_EDGE_TEXELS = 16;
23
+
24
+ /** Texture slots treated as detail maps for integrity purposes. */
25
+ export const ROCK_DETAIL_MAP_SLOTS = Object.freeze([
26
+ 'rockNormal',
27
+ 'sandNormal',
28
+ 'sourceNormal',
29
+ 'stylizedNormal',
30
+ 'smoothness',
31
+ 'topMask',
32
+ ]);
33
+
34
+ /**
35
+ * Reads a texture's pixel dimensions across the shapes three.js uses:
36
+ * `DataTexture`/`Texture` carry `image`, compressed KTX2 textures carry
37
+ * `mipmaps[0]`, and loaders that went through `Source` carry `source.data`.
38
+ *
39
+ * @returns {{width: number, height: number} | null} null when undeterminable.
40
+ */
41
+ export function resolveTextureSize(texture) {
42
+ if (!texture?.isTexture) return null;
43
+ const candidates = [
44
+ texture.image,
45
+ texture.source?.data,
46
+ Array.isArray(texture.mipmaps) ? texture.mipmaps[0] : null,
47
+ ];
48
+ for (const candidate of candidates) {
49
+ const width = Number(candidate?.width);
50
+ const height = Number(candidate?.height);
51
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
52
+ return { width, height };
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * True when a texture is too small to carry the detail its slot implies.
60
+ *
61
+ * Undeterminable sizes return `false` — this guard removes maps only when it
62
+ * can prove they are degenerate, so an unusual-but-valid texture is never
63
+ * silently dropped.
64
+ */
65
+ export function isDegenerateDetailMap(texture, {
66
+ minEdgeTexels = MIN_DETAIL_MAP_EDGE_TEXELS,
67
+ } = {}) {
68
+ const size = resolveTextureSize(texture);
69
+ if (!size) return false;
70
+ return size.width < minEdgeTexels || size.height < minEdgeTexels;
71
+ }
72
+
73
+ /**
74
+ * Describes a texture for diagnostics without assuming it is loaded.
75
+ */
76
+ export function describeTexture(texture) {
77
+ const size = resolveTextureSize(texture);
78
+ return {
79
+ name: texture?.name || null,
80
+ resolution: size ? `${size.width}x${size.height}` : 'unknown',
81
+ uuid: texture?.uuid ?? null,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Removes degenerate detail maps from a texture set.
87
+ *
88
+ * Returns a new object plus the list of rejected slots so callers can report
89
+ * the substitution rather than hiding it — a dropped map changes the rendered
90
+ * result and should be visible in an asset report.
91
+ *
92
+ * @param {Record<string, unknown>} textures
93
+ * @param {{minEdgeTexels?: number, slots?: readonly string[]}} [options]
94
+ * @returns {{textures: Record<string, unknown>, rejected: Array<{slot: string, reason: string, texture: object}>}}
95
+ */
96
+ export function withoutDegenerateDetailMaps(textures, {
97
+ minEdgeTexels = MIN_DETAIL_MAP_EDGE_TEXELS,
98
+ slots = ROCK_DETAIL_MAP_SLOTS,
99
+ } = {}) {
100
+ const result = { ...(textures ?? {}) };
101
+ const rejected = [];
102
+ for (const slot of slots) {
103
+ const texture = result[slot];
104
+ if (!texture?.isTexture) continue;
105
+ if (!isDegenerateDetailMap(texture, { minEdgeTexels })) continue;
106
+ const described = describeTexture(texture);
107
+ rejected.push({
108
+ reason: `below the ${minEdgeTexels}x${minEdgeTexels} minimum for a detail map`,
109
+ slot,
110
+ texture: described,
111
+ });
112
+ delete result[slot];
113
+ }
114
+ return { rejected, textures: result };
115
+ }
@@ -13,3 +13,4 @@ export * from './sdf/sculptEdits.js';
13
13
  export * from './mesh/meshDocument.js';
14
14
  export * from './lod/index.js';
15
15
  export * from './export/glbExport.js';
16
+ export * from './surface/c7GeologySurface.js';
@@ -24,7 +24,7 @@ import { compileDocument } from './sdf/fieldCompiler.js';
24
24
  export const ROCKGEN_PROJECT_DOCUMENT_TYPE = 'toonlab/rockgen-project';
25
25
 
26
26
  /** Current schema version for rockgen project documents. */
27
- export const ROCKGEN_PROJECT_SCHEMA_VERSION = 5;
27
+ export const ROCKGEN_PROJECT_SCHEMA_VERSION = 6;
28
28
 
29
29
  /** Sparse source-mesh edits are packed into the portable document so normal
30
30
  * sculpting remains below the hosted creation-document limit. Runtime state
@@ -70,6 +70,21 @@ function lodTrianglesOption(value, targetTriangles, lodRatios) {
70
70
  : [];
71
71
  }
72
72
 
73
+ function surfacePackageOption(value) {
74
+ if (!value || typeof value !== 'object') return null;
75
+ if (value.schema !== 'toonlab/c7-geology-surface' || Number(value.version) !== 1) return null;
76
+ const geology = String(value.geology ?? '').trim();
77
+ if (!geology) return null;
78
+ const mapResolution = Math.max(32, Math.min(2048, Math.round(Number(value.mapResolution) || 512)));
79
+ return {
80
+ geology,
81
+ mapResolution,
82
+ schema: 'toonlab/c7-geology-surface',
83
+ seed: Math.round(Number(value.seed) || 0) >>> 0,
84
+ version: 1,
85
+ };
86
+ }
87
+
73
88
  function bytesToBase64(bytes) {
74
89
  let binary = '';
75
90
  const chunkSize = 0x8000;
@@ -219,6 +234,7 @@ export function createRockReferenceIdentity(options = null) {
219
234
  role: String(options.role ?? '').trim(),
220
235
  series: String(options.series ?? '').trim(),
221
236
  sourceMode: 'mesh-template',
237
+ surfacePackage: surfacePackageOption(options.surfacePackage),
222
238
  surfaceMode: options.surfaceMode === 'generated' ? 'generated' : 'source',
223
239
  targetTriangles: positiveInteger(options.targetTriangles, lodTriangles[0] ?? 0),
224
240
  topFinish,
@@ -508,8 +524,9 @@ export function serializeRockDocument(document, { pretty = false } = {}) {
508
524
  // v3 adds optional descriptor-only reference identity and its LOD contract.
509
525
  // v4 adds sparse vertex deltas for editable source-mesh projects. v5 packs
510
526
  // those deltas as float32 binary in portable JSON so normal sculpt sessions
511
- // fit the hosted creation limit. Existing preset/custom projects remain
512
- // reference-free.
527
+ // fit the hosted creation limit. v6 adds the optional deterministic C7 surface
528
+ // package identity used by the 480 source-mesh catalog. Existing
529
+ // preset/custom projects remain reference-free.
513
530
  const MIGRATIONS = Object.freeze([
514
531
  (document) => ({
515
532
  ...document,
@@ -547,6 +564,13 @@ const MIGRATIONS = Object.freeze([
547
564
  schemaVersion: 5,
548
565
  };
549
566
  },
567
+ (document) => ({
568
+ ...document,
569
+ reference: document.reference && typeof document.reference === 'object'
570
+ ? { ...document.reference, surfacePackage: document.reference.surfacePackage ?? null }
571
+ : null,
572
+ schemaVersion: 6,
573
+ }),
550
574
  ]);
551
575
 
552
576
  /**