@call-me-sensei/toonlab 0.4.19 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -0,0 +1,185 @@
1
+ export const ROCK_REGION_BINDING_SCHEMA = 'toonlab.rock-region-binding';
2
+ export const ROCK_REGION_BINDING_VERSION = 1;
3
+ export const ROCK_REGION_PROFILE = 'hoodoo-caprock-normalized-height-v1';
4
+ export const ROCK_REGION_GLTF_ATTRIBUTE = '_TL_ROCK_REGION';
5
+ export const ROCK_REGION_THREE_ATTRIBUTE = '_tl_rock_region';
6
+ export const ROCK_REGION_CHANNELS = Object.freeze(['base', 'shaft', 'neck', 'cap']);
7
+ export const ROCK_REGION_BINDING_KEY = 'toonlabRockRegionBinding';
8
+ export const ROCK_REGION_BANDS = Object.freeze({
9
+ base: Object.freeze([0.12, 0.30]),
10
+ cap: Object.freeze([0.72, 0.86]),
11
+ neckEnter: Object.freeze([0.60, 0.70]),
12
+ neckExit: Object.freeze([0.80, 0.89]),
13
+ });
14
+
15
+ function fail(reason, details = null) {
16
+ const error = new Error(`Invalid ToonLab rock-region binding: ${reason}`);
17
+ error.code = 'TOONLAB_ROCK_REGION_BINDING_INVALID';
18
+ error.details = details;
19
+ throw error;
20
+ }
21
+
22
+ function sameChannels(value) {
23
+ return Array.isArray(value)
24
+ && value.length === ROCK_REGION_CHANNELS.length
25
+ && value.every((channel, index) => channel === ROCK_REGION_CHANNELS[index]);
26
+ }
27
+
28
+ function sameBands(value) {
29
+ if (!value || typeof value !== 'object') return false;
30
+ return Object.entries(ROCK_REGION_BANDS).every(([key, expected]) => (
31
+ Array.isArray(value[key])
32
+ && value[key].length === expected.length
33
+ && value[key].every((entry, index) => entry === expected[index])
34
+ ));
35
+ }
36
+
37
+ function smoothstep(edge0, edge1, value) {
38
+ const t = Math.min(1, Math.max(0, (value - edge0) / (edge1 - edge0)));
39
+ return t * t * (3 - (2 * t));
40
+ }
41
+
42
+ function expectedRegionBytes(normalizedHeight) {
43
+ const base = 1 - smoothstep(...ROCK_REGION_BANDS.base, normalizedHeight);
44
+ const cap = smoothstep(...ROCK_REGION_BANDS.cap, normalizedHeight);
45
+ const neck = smoothstep(...ROCK_REGION_BANDS.neckEnter, normalizedHeight)
46
+ * (1 - smoothstep(...ROCK_REGION_BANDS.neckExit, normalizedHeight));
47
+ const baseByte = Math.round(base * 255);
48
+ const capByte = Math.round(cap * 255);
49
+ return [baseByte, 255 - baseByte - capByte, Math.round(neck * 255), capByte];
50
+ }
51
+
52
+ /**
53
+ * Validates one loaded GLB mesh without adding aliases or mutating geometry.
54
+ * GLTFLoader lower-cases the custom `_TL_ROCK_REGION` semantic and exposes it
55
+ * as `_tl_rock_region`, which TSL can consume directly.
56
+ */
57
+ export function inspectRockRegionBinding(mesh, { sampleValues = true } = {}) {
58
+ if (!mesh?.isMesh || !mesh.geometry?.isBufferGeometry) {
59
+ fail('target must be a THREE.Mesh with BufferGeometry');
60
+ }
61
+ const binding = mesh.userData?.[ROCK_REGION_BINDING_KEY];
62
+ if (!binding || typeof binding !== 'object') fail(`missing ${ROCK_REGION_BINDING_KEY}`);
63
+ if (binding.schema !== ROCK_REGION_BINDING_SCHEMA) fail(`schema must be ${ROCK_REGION_BINDING_SCHEMA}`);
64
+ if (binding.version !== ROCK_REGION_BINDING_VERSION) fail(`version must be ${ROCK_REGION_BINDING_VERSION}`);
65
+ if (binding.profile !== ROCK_REGION_PROFILE) fail(`profile must be ${ROCK_REGION_PROFILE}`);
66
+ if (binding.attribute !== ROCK_REGION_GLTF_ATTRIBUTE) fail(`attribute must be ${ROCK_REGION_GLTF_ATTRIBUTE}`);
67
+ if (binding.encoding !== 'unorm8') fail('encoding must be unorm8');
68
+ if (binding.space !== 'mesh-local-normalized-height') fail('unsupported coordinate space');
69
+ if (binding.failClosed !== true) fail('failClosed must be true');
70
+ if (!sameChannels(binding.channels)) fail('channel order must be base, shaft, neck, cap');
71
+ if (!sameBands(binding.bands)) fail('height bands do not match the immutable hoodoo-caprock-normalized-height-v1 profile');
72
+
73
+ const position = mesh.geometry.getAttribute('position');
74
+ const region = mesh.geometry.getAttribute(ROCK_REGION_THREE_ATTRIBUTE);
75
+ if (!position) fail('mesh has no position attribute');
76
+ if (!region) fail(`mesh has no ${ROCK_REGION_THREE_ATTRIBUTE} attribute`);
77
+ if (region.itemSize !== 4) fail('region attribute must be VEC4');
78
+ if (region.count !== position.count) fail('region and position accessor counts differ');
79
+ if (region.normalized !== true) fail('region attribute must be normalized');
80
+ if (!(region.array instanceof Uint8Array)) fail('region attribute must use UNSIGNED_BYTE storage');
81
+
82
+ let primaryPartitionFailures = 0;
83
+ let compilerProfileMismatches = 0;
84
+ let neckSamples = 0;
85
+ const nonzeroSamples = [0, 0, 0, 0];
86
+ const channelMinUnorm8 = [255, 255, 255, 255];
87
+ const channelMaxUnorm8 = [0, 0, 0, 0];
88
+ if (sampleValues) {
89
+ let minY = Number.POSITIVE_INFINITY;
90
+ let maxY = Number.NEGATIVE_INFINITY;
91
+ for (let index = 0; index < position.count; index += 1) {
92
+ const y = position.getY(index);
93
+ if (!Number.isFinite(y)) fail('position attribute contains a non-finite height value');
94
+ minY = Math.min(minY, y);
95
+ maxY = Math.max(maxY, y);
96
+ }
97
+ const height = maxY - minY;
98
+ if (!(height > 1e-8)) fail('mesh height range is too small for hoodoo-caprock-normalized-height-v1 regions');
99
+ for (let index = 0; index < region.count; index += 1) {
100
+ const offset = index * 4;
101
+ const base = region.array[offset];
102
+ const shaft = region.array[offset + 1];
103
+ const neck = region.array[offset + 2];
104
+ const cap = region.array[offset + 3];
105
+ if (base + shaft + cap !== 255) primaryPartitionFailures += 1;
106
+ if (neck > 0) neckSamples += 1;
107
+ const channels = [base, shaft, neck, cap];
108
+ const expected = expectedRegionBytes((position.getY(index) - minY) / height);
109
+ for (let channel = 0; channel < channels.length; channel += 1) {
110
+ const value = channels[channel];
111
+ if (value > 0) nonzeroSamples[channel] += 1;
112
+ channelMinUnorm8[channel] = Math.min(channelMinUnorm8[channel], value);
113
+ channelMaxUnorm8[channel] = Math.max(channelMaxUnorm8[channel], value);
114
+ if (value !== expected[channel]) compilerProfileMismatches += 1;
115
+ }
116
+ }
117
+ if (primaryPartitionFailures > 0) {
118
+ fail('base + shaft + cap must equal one at every quantized vertex', { primaryPartitionFailures });
119
+ }
120
+ if (neckSamples === 0) fail('neck overlay has no nonzero samples');
121
+ if (nonzeroSamples[0] === 0 || nonzeroSamples[1] === 0 || nonzeroSamples[3] === 0) {
122
+ fail('base, shaft, and cap must each have nonzero coverage', { nonzeroSamples });
123
+ }
124
+ if (channelMaxUnorm8.some((value) => value !== 255)) {
125
+ fail('base, shaft, neck, and cap must each reach full authored coverage', { channelMaxUnorm8 });
126
+ }
127
+ if (compilerProfileMismatches > 0) {
128
+ fail('region bytes do not match the immutable hoodoo-caprock-normalized-height-v1 compiler profile', {
129
+ compilerProfileMismatches,
130
+ });
131
+ }
132
+ }
133
+
134
+ return Object.freeze({
135
+ binding,
136
+ attribute: region,
137
+ attributeName: ROCK_REGION_THREE_ATTRIBUTE,
138
+ count: region.count,
139
+ bytes: region.array.byteLength,
140
+ primaryPartitionFailures,
141
+ compilerProfileMismatches,
142
+ neckSamples,
143
+ nonzeroSamples: Object.freeze([...nonzeroSamples]),
144
+ channelMinUnorm8: Object.freeze([...channelMinUnorm8]),
145
+ channelMaxUnorm8: Object.freeze([...channelMaxUnorm8]),
146
+ passed: true,
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Audits all visual meshes. Missing bindings are failures by default because
152
+ * silently applying a partial palette to a multi-primitive rock changes its
153
+ * geological reading.
154
+ */
155
+ export function inspectRockRegionBindings(root, {
156
+ requireEveryMesh = true,
157
+ sampleValues = true,
158
+ } = {}) {
159
+ const records = [];
160
+ const failures = [];
161
+ root?.traverse?.((object) => {
162
+ if (!object?.isMesh || object.userData?.rockShaderExclude === true) return;
163
+ const hasBinding = Boolean(object.userData?.[ROCK_REGION_BINDING_KEY]);
164
+ if (!hasBinding && !requireEveryMesh) return;
165
+ try {
166
+ records.push({ mesh: object, ...inspectRockRegionBinding(object, { sampleValues }) });
167
+ } catch (error) {
168
+ failures.push({ mesh: object, error });
169
+ }
170
+ });
171
+ if (!records.length && !failures.length) fail('root has no visual meshes');
172
+ if (failures.length) {
173
+ fail(`${failures.length} mesh binding(s) failed validation`, failures.map(({ mesh, error }) => ({
174
+ mesh: mesh.name,
175
+ reason: error.message,
176
+ })));
177
+ }
178
+ return Object.freeze({
179
+ passed: true,
180
+ meshCount: records.length,
181
+ totalVertices: records.reduce((sum, record) => sum + record.count, 0),
182
+ totalBytes: records.reduce((sum, record) => sum + record.bytes, 0),
183
+ records: Object.freeze(records),
184
+ });
185
+ }
@@ -0,0 +1,337 @@
1
+ import {
2
+ LinearFilter,
3
+ LinearMipmapLinearFilter,
4
+ NoColorSpace,
5
+ RepeatWrapping,
6
+ SRGBColorSpace,
7
+ TextureLoader,
8
+ } from 'three';
9
+
10
+ export const ROCK_MATERIAL_BINDING_SCHEMA = 'toonlab.rock-material-binding';
11
+ export const ROCK_SEMANTIC_MATERIAL_SET_SCHEMA = 'toonlab.rock-semantic-material-set';
12
+
13
+ const jsonPromises = new Map();
14
+ const texturePromises = new Map();
15
+
16
+ function absoluteUrl(value, base) {
17
+ const fallback = globalThis.location?.href ?? 'http://toonlab.invalid/';
18
+ return new URL(String(value), new URL(String(base), fallback)).href;
19
+ }
20
+
21
+ async function loadJson(url, fetchImpl) {
22
+ if (!jsonPromises.has(url)) {
23
+ const promise = fetchImpl(url).then(async (response) => {
24
+ if (!response.ok) throw new Error(`Rock material dependency is unavailable (${response.status}): ${url}`);
25
+ return response.json();
26
+ }).catch((error) => {
27
+ jsonPromises.delete(url);
28
+ throw error;
29
+ });
30
+ jsonPromises.set(url, promise);
31
+ }
32
+ return jsonPromises.get(url);
33
+ }
34
+
35
+ function configureTexture(map, descriptor) {
36
+ map.name = `ToonLab shared rock texture ${descriptor.hash}`;
37
+ map.colorSpace = descriptor.colorSpace === 'srgb' ? SRGBColorSpace : NoColorSpace;
38
+ map.flipY = false;
39
+ map.wrapS = RepeatWrapping;
40
+ map.wrapT = RepeatWrapping;
41
+ map.magFilter = LinearFilter;
42
+ map.minFilter = LinearMipmapLinearFilter;
43
+ map.generateMipmaps = true;
44
+ map.userData.toonlabContentHash = descriptor.hash;
45
+ map.userData.toonLabImportSettings = {
46
+ colorSpace: descriptor.colorSpace,
47
+ flipGreenChannel: Boolean(descriptor.flipGreenChannel),
48
+ textureFlipY: false,
49
+ };
50
+ map.needsUpdate = true;
51
+ return map;
52
+ }
53
+
54
+ async function loadTexture(url, descriptor, textureLoader) {
55
+ const key = `${descriptor.hash}|${url}`;
56
+ if (!texturePromises.has(key)) {
57
+ const promise = textureLoader.loadAsync(url)
58
+ .then((map) => configureTexture(map, descriptor))
59
+ .catch((error) => {
60
+ texturePromises.delete(key);
61
+ throw error;
62
+ });
63
+ texturePromises.set(key, promise);
64
+ }
65
+ return texturePromises.get(key);
66
+ }
67
+
68
+ const RUNTIME_TEXTURE_ROLES = Object.freeze({
69
+ grassAlbedo: 'grass',
70
+ grassBaseColor: 'grassBaseColor',
71
+ grassRoughness: 'grassRoughness',
72
+ mossPattern: 'moss',
73
+ noise: 'noise',
74
+ primaryAlbedo: 'rock',
75
+ primaryNormal: 'rockNormal',
76
+ primarySmoothness: 'smoothness',
77
+ sandAlbedo: 'sand',
78
+ sandNormal: 'sandNormal',
79
+ snowAlbedo: 'snow',
80
+ stripe: 'stripe',
81
+ stylizedNormal: 'stylizedNormal',
82
+ topMask: 'topMask',
83
+ });
84
+
85
+ function finite(value, fallback) {
86
+ const number = Number(value);
87
+ return Number.isFinite(number) ? number : fallback;
88
+ }
89
+
90
+ function clamp(value, minimum, maximum) {
91
+ return Math.min(maximum, Math.max(minimum, finite(value, minimum)));
92
+ }
93
+
94
+ function multiplyColor(value, gain, maximum = 2) {
95
+ const source = Array.isArray(value) ? value : [1, 1, 1];
96
+ const multiplier = Array.isArray(gain) ? gain : [1, 1, 1];
97
+ return source.slice(0, 3).map((channel, index) => clamp(
98
+ finite(channel, 1) * finite(multiplier[index], 1),
99
+ 0,
100
+ maximum,
101
+ ));
102
+ }
103
+
104
+ function sourceProfileForRuntime(set, binding) {
105
+ const source = structuredClone(set.profile);
106
+ const variation = binding.materialVariation ?? {};
107
+ source.id = set.id;
108
+ source.sourceName = `semantic-set:${set.id}`;
109
+ source.sourcePath = null;
110
+ source.sourceGuid = set.id;
111
+ source.base ??= {};
112
+ source.base.tint = multiplyColor(source.base.tint, variation.colorGain);
113
+ source.base.brightness = clamp(
114
+ finite(source.base.brightness, 0) + finite(variation.brightnessOffset, 0),
115
+ -1,
116
+ 1,
117
+ );
118
+ source.base.saturation = clamp(
119
+ finite(source.base.saturation, 1) * finite(variation.saturationGain, 1),
120
+ 0,
121
+ 2,
122
+ );
123
+ source.base.contrast = clamp(
124
+ finite(source.base.contrast, 1) * finite(variation.contrastGain, 1),
125
+ 0,
126
+ 3,
127
+ );
128
+ source.base.scale = clamp(
129
+ finite(source.base.scale, 1.6) * finite(variation.projectionScaleGain, 1),
130
+ 0.05,
131
+ 256,
132
+ );
133
+ source.base.projectionContrast = clamp(
134
+ finite(source.base.projectionContrast, 0.62) * finite(variation.projectionBlendGain, 1),
135
+ 0.05,
136
+ 4,
137
+ );
138
+ const sourceRoughness = 1 - Number(source.base.smoothness ?? 0);
139
+ source.base.smoothness = 1 - Math.min(1, Math.max(
140
+ 0,
141
+ sourceRoughness * finite(variation.roughnessGain, 1),
142
+ ));
143
+ source.base.metallic = Math.min(1, Math.max(
144
+ 0,
145
+ Number(source.base.metallic ?? 0) * finite(variation.metallicGain, 1),
146
+ ));
147
+ source.layers ??= {};
148
+ source.layers.maskEnabled = Number(source.layerMask?.semanticMaskStrength ?? 0) > 0;
149
+ source.layers.sharpness = clamp(
150
+ finite(source.layerMask?.sharpness, 0.8) * finite(variation.layerSharpnessGain, 1),
151
+ 0,
152
+ 8,
153
+ );
154
+ source.layers.offset = clamp(
155
+ finite(source.layerMask?.offset, 0.3) + finite(variation.layerSlopeOffset, 0),
156
+ -1,
157
+ 1,
158
+ );
159
+ const sourceHeightStart = finite(source.layerMask?.heightStart, 0.48);
160
+ const sourceHeightEnd = finite(source.layerMask?.heightEnd, 0.88);
161
+ const heightShift = clamp(
162
+ finite(variation.layerHeightOffset, 0),
163
+ -sourceHeightStart,
164
+ 1 - sourceHeightEnd,
165
+ );
166
+ source.layers.heightStart = sourceHeightStart + heightShift;
167
+ source.layers.heightEnd = sourceHeightEnd + heightShift;
168
+ source.layers.cavityStrength = Number(source.layerMask?.cavityStrength ?? 0);
169
+ source.layers.exposureStrength = Number(source.layerMask?.exposureStrength ?? 0);
170
+ source.layers.semanticMaskStrength = Number(source.layerMask?.semanticMaskStrength ?? 0);
171
+ for (const role of ['grass', 'snow', 'sand']) {
172
+ source.layers[role] = { ...(source.layers[role] ?? {}) };
173
+ source.layers[role].scale = clamp(
174
+ finite(source.layers[role].scale, 1) * finite(variation.layerScaleGain, 1),
175
+ 0.05,
176
+ 50,
177
+ );
178
+ source.layers[role].tint = multiplyColor(
179
+ source.layers[role].tint,
180
+ variation.layerTintGain,
181
+ );
182
+ }
183
+ source.layers.sand.normalScale = clamp(
184
+ finite(source.layers.sand.normalScale, source.layers.sand.scale)
185
+ * finite(variation.layerScaleGain, 1),
186
+ 0.05,
187
+ 50,
188
+ );
189
+ source.moss ??= {};
190
+ source.moss.size = Math.max(
191
+ 0.000001,
192
+ finite(source.moss.size, 25) * finite(variation.mossScaleGain, 1),
193
+ );
194
+ source.moss.offset = finite(source.moss.offset, -0.15)
195
+ + finite(variation.mossCoverageOffset, 0);
196
+ source.base.striping = { ...(source.striping ?? {}), ...(source.base.striping ?? {}) };
197
+ return source;
198
+ }
199
+
200
+ function bindingMaterials(root) {
201
+ const materials = new Set();
202
+ root?.traverse?.((object) => {
203
+ if (!object?.isMesh) return;
204
+ for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
205
+ if (material?.userData?.toonlabRockMaterialBinding) materials.add(material);
206
+ }
207
+ });
208
+ return [...materials];
209
+ }
210
+
211
+ function restoreSemanticMaskAttributeNames(root) {
212
+ root?.traverse?.((object) => {
213
+ if (!object?.isMesh) return;
214
+ const bound = (Array.isArray(object.material) ? object.material : [object.material])
215
+ .some((material) => material?.userData?.toonlabRockMaterialBinding);
216
+ if (!bound) return;
217
+ for (const name of ['rockCavity', 'rockExposure', 'rockHeight']) {
218
+ const exportedName = `_${name.toLowerCase()}`;
219
+ const attribute = object.geometry?.getAttribute(name)
220
+ ?? object.geometry?.getAttribute(exportedName);
221
+ if (!attribute) {
222
+ throw new Error(`${object.name || 'Semantic rock mesh'} is missing required ${name} data.`);
223
+ }
224
+ if (!object.geometry.getAttribute(name)) object.geometry.setAttribute(name, attribute);
225
+ }
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Resolves every fail-closed GLB material binding before the asset is exposed.
231
+ * Throws on any missing manifest, set, or source texture; there is no fallback.
232
+ *
233
+ * @param {import('three').Object3D} root
234
+ * @param {{
235
+ * fetchImpl?: typeof globalThis.fetch,
236
+ * modelUrl?: string | URL,
237
+ * textureLoader?: TextureLoader,
238
+ * }} [options]
239
+ * @returns {Promise<{ materialSets: number, textures: number }>}
240
+ */
241
+ export async function prepareRockSemanticMaterialBindings(root, {
242
+ fetchImpl = globalThis.fetch?.bind(globalThis),
243
+ modelUrl,
244
+ textureLoader = new TextureLoader(),
245
+ } = {}) {
246
+ const materials = bindingMaterials(root);
247
+ if (materials.length === 0) return { materialSets: 0, textures: 0 };
248
+ // glTF reserves custom vertex semantics with an underscore and GLTFLoader
249
+ // exposes them lower-cased. Publish the shader-facing aliases only after a
250
+ // semantic binding is identified, and fail rather than inventing masks.
251
+ restoreSemanticMaskAttributeNames(root);
252
+ if (typeof fetchImpl !== 'function') throw new Error('Semantic rock materials require fetch().');
253
+ if (!modelUrl) throw new Error('Semantic rock materials require the geometry model URL.');
254
+
255
+ const loadedHashes = new Set();
256
+ const loadedSets = new Set();
257
+ for (const material of materials) {
258
+ const binding = material.userData.toonlabRockMaterialBinding;
259
+ if (binding.schema !== ROCK_MATERIAL_BINDING_SCHEMA || binding.failClosed !== true) {
260
+ throw new Error('Rock geometry contains an invalid or non-fail-closed material binding.');
261
+ }
262
+ const manifestUrl = absoluteUrl(binding.manifest, modelUrl);
263
+ const manifest = await loadJson(manifestUrl, fetchImpl);
264
+ if (manifest.schema !== 'toonlab.rock-semantic-material-library') {
265
+ throw new Error(`Invalid semantic rock material library: ${manifestUrl}`);
266
+ }
267
+ const descriptor = manifest.sets?.find((entry) => entry.id === binding.materialSetId);
268
+ if (!descriptor) throw new Error(`Rock material set ${binding.materialSetId} is absent.`);
269
+ const setUrl = absoluteUrl(descriptor.file, manifestUrl);
270
+ const set = await loadJson(setUrl, fetchImpl);
271
+ if (set.schema !== ROCK_SEMANTIC_MATERIAL_SET_SCHEMA || set.id !== binding.materialSetId) {
272
+ throw new Error(`Rock material set ${binding.materialSetId} failed identity validation.`);
273
+ }
274
+ const textures = {};
275
+ const semanticTextures = {
276
+ ...(set.semanticTextures ?? {}),
277
+ ...(binding.semanticTextureOverrides ?? {}),
278
+ };
279
+ for (const [semanticRole, textureDescriptor] of Object.entries(semanticTextures)) {
280
+ const runtimeRole = RUNTIME_TEXTURE_ROLES[semanticRole];
281
+ if (!runtimeRole) continue;
282
+ if (!textureDescriptor?.hash || !textureDescriptor?.file) {
283
+ throw new Error(`${set.id}/${semanticRole} has no content-addressed source texture.`);
284
+ }
285
+ const libraryTexture = manifest.textures?.find((entry) => entry.hash === textureDescriptor.hash);
286
+ if (
287
+ !libraryTexture
288
+ || libraryTexture.file !== textureDescriptor.file
289
+ || libraryTexture.colorSpace !== textureDescriptor.colorSpace
290
+ || Boolean(libraryTexture.normalMap) !== Boolean(textureDescriptor.normalMap)
291
+ ) {
292
+ throw new Error(`${set.id}/${semanticRole} is not registered by the material library.`);
293
+ }
294
+ const textureUrl = absoluteUrl(textureDescriptor.file, manifestUrl);
295
+ textures[runtimeRole] = await loadTexture(textureUrl, textureDescriptor, textureLoader);
296
+ loadedHashes.add(textureDescriptor.hash);
297
+ }
298
+ if (!textures.rock?.isTexture) throw new Error(`${set.id} has no primary rock albedo.`);
299
+ material.userData.toonlabRockSemanticMaterial = Object.freeze({
300
+ binding: structuredClone(binding),
301
+ materialSetId: set.id,
302
+ profile: sourceProfileForRuntime(set, binding),
303
+ sourceChannels: structuredClone(set.sourceChannels ?? {}),
304
+ textures: Object.freeze(textures),
305
+ });
306
+ // The serialized descriptor is invisible so an unresolved asset cannot
307
+ // masquerade as a usable material. Once every dependency has loaded and
308
+ // validated, make the transient live descriptor opaque so strict scene
309
+ // material auditing can hand it to the rock adapter. It is replaced before
310
+ // the acquired asset enters a rendered scene.
311
+ material.opacity = 1;
312
+ material.transparent = false;
313
+ material.depthWrite = true;
314
+ material.needsUpdate = true;
315
+ loadedSets.add(set.id);
316
+ }
317
+ return { materialSets: loadedSets.size, textures: loadedHashes.size };
318
+ }
319
+
320
+ /** Preserves cache-owned Texture instances when official assets clone materials. */
321
+ export function copyRockSemanticMaterialBinding(source, target) {
322
+ const semantic = source?.userData?.toonlabRockSemanticMaterial;
323
+ if (semantic) target.userData.toonlabRockSemanticMaterial = semantic;
324
+ return target;
325
+ }
326
+
327
+ export function rockSemanticMaterialTextures(material) {
328
+ return material?.userData?.toonlabRockSemanticMaterial?.textures ?? null;
329
+ }
330
+
331
+ export function clearRockSemanticMaterialCaches({ disposeTextures = false } = {}) {
332
+ if (disposeTextures) {
333
+ for (const promise of texturePromises.values()) promise.then((texture) => texture.dispose()).catch(() => {});
334
+ }
335
+ texturePromises.clear();
336
+ jsonPromises.clear();
337
+ }