@marble.fun/track-catalog 3.0.0

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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/assets.json +346 -0
  4. package/package.json +32 -0
  5. package/release.json +135 -0
  6. package/src/audio/game-audio-preference.ts +9 -0
  7. package/src/authoritative.ts +6 -0
  8. package/src/catalog.ts +1 -0
  9. package/src/index.ts +14 -0
  10. package/src/legacy-stock-catalog.ts +130 -0
  11. package/src/pages/lobby/lobby-types.ts +140 -0
  12. package/src/pages/lobby/marble-factory.ts +931 -0
  13. package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
  14. package/src/pages/play/race/egypt-portal-frame.ts +382 -0
  15. package/src/pages/play/race/finish-detection.ts +1 -0
  16. package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
  17. package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
  18. package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
  19. package/src/pages/play/race/jungle-portal-frame.ts +308 -0
  20. package/src/pages/play/race/jungle-temple-lift.ts +366 -0
  21. package/src/pages/play/race/marble-entity.ts +257 -0
  22. package/src/pages/play/race/marble-material.ts +20 -0
  23. package/src/pages/play/race/marble-race-engine.ts +2652 -0
  24. package/src/pages/play/race/path-pattern-layout.ts +142 -0
  25. package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
  26. package/src/pages/play/race/physics-debug.ts +1 -0
  27. package/src/pages/play/race/physics-dry-run.ts +90 -0
  28. package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
  29. package/src/pages/play/race/portal-pair-color.ts +20 -0
  30. package/src/pages/play/race/portal-proportions.ts +25 -0
  31. package/src/pages/play/race/portal-visual.ts +1108 -0
  32. package/src/pages/play/race/race-camera-director.ts +586 -0
  33. package/src/pages/play/race/race-camera-obstruction.ts +37 -0
  34. package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
  35. package/src/pages/play/race/race-camera.ts +206 -0
  36. package/src/pages/play/race/race-completion.ts +5 -0
  37. package/src/pages/play/race/race-configuration.ts +57 -0
  38. package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
  39. package/src/pages/play/race/race-physics-profile.ts +7 -0
  40. package/src/pages/play/race/race-physics.ts +16 -0
  41. package/src/pages/play/race/race-playback.ts +41 -0
  42. package/src/pages/play/race/race-rolling-audio.ts +616 -0
  43. package/src/pages/play/race/race-simulation-renderer.ts +221 -0
  44. package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
  45. package/src/pages/play/race/race-transform-interpolation.ts +50 -0
  46. package/src/pages/play/race/race-types.ts +12 -0
  47. package/src/pages/play/race/rebound-visual.ts +70 -0
  48. package/src/pages/play/race/seeded-random.ts +23 -0
  49. package/src/pages/play/race/space-station-portal-frame.ts +275 -0
  50. package/src/pages/play/race/track-appearance-canopy.ts +946 -0
  51. package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
  52. package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
  53. package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
  54. package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
  55. package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
  56. package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
  57. package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
  58. package/src/pages/play/race/track-appearance-support.ts +983 -0
  59. package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
  60. package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
  61. package/src/pages/play/race/track-course.ts +271 -0
  62. package/src/pages/play/race/track-definition.ts +443 -0
  63. package/src/pages/play/race/track-fence-architecture.ts +33 -0
  64. package/src/pages/play/race/track-floor-material-rules.ts +51 -0
  65. package/src/pages/play/race/track-mechanisms.ts +1350 -0
  66. package/src/pages/play/race/track-physics-surface.ts +6 -0
  67. package/src/pages/play/race/track-route-network.ts +9 -0
  68. package/src/pages/play/race/track-shadow-batching.ts +760 -0
  69. package/src/pages/play/race/track-surface-basis.ts +1 -0
  70. package/src/pages/play/race/track-surface-topology.ts +326 -0
  71. package/src/pages/play/race/track-world.ts +10181 -0
  72. package/src/pages/play/race/under-track-support-architecture.ts +122 -0
  73. package/src/pages/play/race/vortex-profile.ts +5 -0
  74. package/src/pages/play/race/vortex-water-effect.ts +863 -0
  75. package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
  76. package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
  77. package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
  78. package/src/pages/track-lab/cone-field-layout.ts +1 -0
  79. package/src/pages/track-lab/conveyor-layout.ts +1 -0
  80. package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
  81. package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
  82. package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
  83. package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
  84. package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
  85. package/src/pages/track-lab/flipper-component.ts +127 -0
  86. package/src/pages/track-lab/rising-wall-layout.ts +1 -0
  87. package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
  88. package/src/pages/track-lab/track-piece-layout.ts +28 -0
  89. package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
  90. package/src/three/geometry-attribute-aliases.ts +24 -0
  91. package/src/three/geometry-detail.ts +36 -0
  92. package/src/three/homepage-marble-geometry.ts +15 -0
  93. package/src/three/japanese-sakura-appearance.ts +252 -0
  94. package/src/three/jungle-flower-hedge-appearance.ts +7 -0
  95. package/src/three/jungle-temple-appearance.ts +5 -0
  96. package/src/three/low-quality-scene-renderer.ts +132 -0
  97. package/src/three/marble-sphere-geometry.ts +13 -0
  98. package/src/three/mobile-rendering-budget.ts +11 -0
  99. package/src/three/painted-valley-appearance.ts +270 -0
  100. package/src/three/railroad-appearance.ts +71 -0
  101. package/src/three/renderer-resize.ts +32 -0
  102. package/src/three/rope-wall-appearance.ts +91 -0
  103. package/src/three/rugged-track-appearance.ts +2 -0
  104. package/src/three/scene-lighting.ts +183 -0
  105. package/src/three/scene-shader-preparation.ts +64 -0
  106. package/src/three/scene-theme-controller.ts +461 -0
  107. package/src/three/shadow-shader-preparation.ts +162 -0
  108. package/src/three/shared-obstacle-metal.ts +44 -0
  109. package/src/three/soft-additive-particles.ts +57 -0
  110. package/src/three/space-station-appearance.ts +213 -0
  111. package/src/three/static-track-batched-mesh.ts +132 -0
  112. package/src/three/stone-track-shell-appearance.ts +12 -0
  113. package/src/three/theme-ambience-controller.ts +226 -0
  114. package/src/three/theme-asset-cache.ts +120 -0
  115. package/src/three/theme-audio-preference.ts +43 -0
  116. package/src/three/theme-catalog.ts +1457 -0
  117. package/src/three/theme-material-assets.ts +258 -0
  118. package/src/three/theme-material-ktx2-manifest.ts +190 -0
  119. package/src/three/theme-material-registry.ts +1952 -0
  120. package/src/three/theme-world-factory.ts +590 -0
  121. package/src/three/track-connector-batch.ts +125 -0
  122. package/src/three/track-material-continuity.ts +442 -0
  123. package/src/three/track-theme.ts +535 -0
  124. package/src/three/vampire-castle-appearance.ts +258 -0
  125. package/src/three/visual-frame-pacing.ts +19 -0
  126. package/src/three/visual-geometry.ts +120 -0
  127. package/src/three/visual-quality.ts +83 -0
  128. package/src/three/water-appearance.ts +29 -0
  129. package/src/three/wild-west-sand-appearance.ts +2 -0
@@ -0,0 +1,1952 @@
1
+ import * as THREE from 'three'
2
+
3
+ import type {
4
+ MaterialPatternRecipe,
5
+ MaterialRecipe,
6
+ ThemeMaterialSlot,
7
+ TrackAppearanceCanopyStyle,
8
+ TrackAppearanceDetailStyle,
9
+ TrackAppearancePreset,
10
+ TrackAppearanceSupportStyle,
11
+ TrackAppearanceSurface,
12
+ TrackThemeDefinition,
13
+ } from './track-theme'
14
+ import {
15
+ createThemeMaterialTexture,
16
+ releaseThemeMaterialTexture,
17
+ themeMaterialRecipeUrls,
18
+ } from './theme-material-assets'
19
+ import { resolvedFloorMaterialIdForTheme } from './railroad-appearance'
20
+
21
+ interface MaterialBinding {
22
+ mesh: THREE.Mesh
23
+ slot: ThemeMaterialSlot
24
+ materialIndex?: number
25
+ }
26
+
27
+ interface AppearanceMaterialBinding {
28
+ mesh: THREE.Mesh
29
+ presetId: string
30
+ fallbackSlot: ThemeMaterialSlot
31
+ materialIndex?: number
32
+ resolveFloorByTheme?: boolean
33
+ }
34
+
35
+ type ThemeVisualBinding = THREE.Object3D & {
36
+ userData: {
37
+ applySemanticTheme?: (theme: TrackThemeDefinition) => void
38
+ }
39
+ }
40
+
41
+ export class ThemeMaterialRegistry {
42
+ private readonly materials = new Map<ThemeMaterialSlot, THREE.Material>()
43
+ private readonly appearanceMaterials = new Map<string, THREE.Material>()
44
+ private readonly bindings = new Set<MaterialBinding>()
45
+ private readonly appearanceBindings = new Set<AppearanceMaterialBinding>()
46
+ private readonly bindingIndex = new WeakMap<THREE.Mesh, Map<number, MaterialBinding>>()
47
+ private readonly appearanceBindingIndex = new WeakMap<THREE.Mesh, Map<number, AppearanceMaterialBinding>>()
48
+ private readonly visibilityBindings = new Set<THREE.Object3D>()
49
+ private readonly semanticThemeBindings = new Set<ThemeVisualBinding>()
50
+ private disposed = false
51
+
52
+ constructor(private theme: TrackThemeDefinition) {}
53
+
54
+ get activeTheme(): TrackThemeDefinition {
55
+ return this.theme
56
+ }
57
+
58
+ get(slot: ThemeMaterialSlot): THREE.Material {
59
+ this.assertActive()
60
+ const current = this.materials.get(slot)
61
+ if (current) return current
62
+ const recipe = this.theme.materials[slot]
63
+ if (!recipe) throw new Error(`Theme "${this.theme.id}" does not define material slot "${slot}".`)
64
+ const material = createMaterial(slot, recipe)
65
+ this.materials.set(slot, material)
66
+ return material
67
+ }
68
+
69
+ requestedAssetUrls(theme: TrackThemeDefinition = this.theme): readonly string[] {
70
+ const urls = new Set<string>()
71
+ for (const slot of this.materials.keys()) {
72
+ themeMaterialRecipeUrls(theme.materials[slot]).forEach(url => urls.add(url))
73
+ }
74
+ for (const key of this.appearanceMaterials.keys()) {
75
+ const appearance = parseAppearanceMaterialKey(key)
76
+ const recipe = findAppearanceRecipe(theme, appearance.presetId) ?? theme.materials[appearance.fallbackSlot]
77
+ themeMaterialRecipeUrls(recipe).forEach(url => urls.add(url))
78
+ }
79
+ return [...urls]
80
+ }
81
+
82
+ get requestedTextures(): readonly THREE.Texture[] {
83
+ const textures = new Set<THREE.Texture>()
84
+ this.materials.forEach(material => collectTextures(material, textures))
85
+ this.appearanceMaterials.forEach(material => collectTextures(material, textures))
86
+ return [...textures]
87
+ }
88
+
89
+ async ready(): Promise<void> {
90
+ await Promise.all(
91
+ this.requestedTextures.map(texture =>
92
+ Promise.resolve(texture.userData.themeAssetReady as Promise<void> | undefined),
93
+ ),
94
+ )
95
+ }
96
+
97
+ async preUpload(renderer: THREE.WebGLRenderer, sliceMilliseconds = 4): Promise<void> {
98
+ await this.ready()
99
+ let sliceStartedAt = performance.now()
100
+ for (const texture of this.requestedTextures) {
101
+ renderer.initTexture(texture)
102
+ if (performance.now() - sliceStartedAt < sliceMilliseconds) continue
103
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
104
+ sliceStartedAt = performance.now()
105
+ }
106
+ }
107
+
108
+ getAppearance(presetId: string | undefined, fallbackSlot: ThemeMaterialSlot): THREE.Material {
109
+ this.assertActive()
110
+ if (!presetId) return this.get(fallbackSlot)
111
+ const recipe = findAppearanceRecipe(this.theme, presetId)
112
+ const key = appearanceMaterialKey(presetId, fallbackSlot)
113
+ const current = this.appearanceMaterials.get(key)
114
+ if (current) return current
115
+ const material = createAppearanceMaterial(
116
+ presetId,
117
+ fallbackSlot,
118
+ recipe ?? this.theme.materials[fallbackSlot],
119
+ findAppearancePreset(this.theme, presetId)?.canopyStyle,
120
+ findAppearancePreset(this.theme, presetId)?.supportStyle,
121
+ )
122
+ this.appearanceMaterials.set(key, material)
123
+ return material
124
+ }
125
+
126
+ getAppearanceRecipe(presetId: string | undefined, fallbackSlot: ThemeMaterialSlot): MaterialRecipe {
127
+ this.assertActive()
128
+ return (presetId ? findAppearanceRecipe(this.theme, presetId) : undefined) ?? this.theme.materials[fallbackSlot]
129
+ }
130
+
131
+ getAppearancePresets(surface: TrackAppearanceSurface): readonly TrackAppearancePreset[] {
132
+ return this.theme.appearancePresets?.[surface] ?? []
133
+ }
134
+
135
+ getAppearanceDetailStyle(presetId: string | undefined): TrackAppearanceDetailStyle | undefined {
136
+ return presetId ? findAppearancePreset(this.theme, presetId)?.detailStyle : undefined
137
+ }
138
+
139
+ getAppearanceCanopyStyle(presetId: string | undefined): TrackAppearanceCanopyStyle | undefined {
140
+ return presetId ? findAppearancePreset(this.theme, presetId)?.canopyStyle : undefined
141
+ }
142
+
143
+ bind(mesh: THREE.Mesh, slot: ThemeMaterialSlot, materialIndex?: number): void {
144
+ this.assertActive()
145
+ assignMaterial(mesh, this.get(slot), materialIndex)
146
+ applyHolographicFloorShadowPolicy(mesh)
147
+ mesh.userData.materialSlot = slot
148
+ applyThemeVisibility(mesh, this.theme.id)
149
+ if (hasThemeVisibility(mesh)) this.visibilityBindings.add(mesh)
150
+ this.rememberBinding({ mesh, slot, materialIndex })
151
+ }
152
+
153
+ bindAppearance(
154
+ mesh: THREE.Mesh,
155
+ presetId: string | undefined,
156
+ fallbackSlot: ThemeMaterialSlot,
157
+ materialIndex?: number,
158
+ ): void {
159
+ if (!presetId) {
160
+ this.bind(mesh, fallbackSlot, materialIndex)
161
+ return
162
+ }
163
+ this.assertActive()
164
+ const appearanceMaterial = this.getAppearance(presetId, fallbackSlot)
165
+ if (appearanceMaterial === this.get(fallbackSlot)) {
166
+ this.bind(mesh, fallbackSlot, materialIndex)
167
+ return
168
+ }
169
+ assignMaterial(mesh, appearanceMaterial, materialIndex)
170
+ applyHolographicFloorShadowPolicy(mesh)
171
+ mesh.userData.appearanceMaterialId = presetId
172
+ // Appearance bindings still occupy a semantic fallback slot. Keeping the
173
+ // slot on the mesh makes editor selection, diagnostics, and material QA
174
+ // independent of whether a theme supplies a dedicated preset recipe.
175
+ mesh.userData.materialSlot = fallbackSlot
176
+ applyThemeVisibility(mesh, this.theme.id)
177
+ if (hasThemeVisibility(mesh)) this.visibilityBindings.add(mesh)
178
+ this.rememberAppearanceBinding({ mesh, presetId, fallbackSlot, materialIndex })
179
+ }
180
+
181
+ captureBindings(root: THREE.Object3D): void {
182
+ this.assertActive()
183
+ const slotsByMaterial = new Map([...this.materials.entries()].map(([slot, material]) => [material, slot]))
184
+ const appearancesByMaterial = new Map(
185
+ [...this.appearanceMaterials.entries()].map(([key, material]) => [material, parseAppearanceMaterialKey(key)]),
186
+ )
187
+
188
+ root.traverse(object => {
189
+ if (typeof object.userData.applySemanticTheme === 'function') {
190
+ this.semanticThemeBindings.add(object as ThemeVisualBinding)
191
+ }
192
+ applyThemeVisibility(object, this.theme.id)
193
+ if (hasThemeVisibility(object)) this.visibilityBindings.add(object)
194
+ if (!(object instanceof THREE.Mesh)) return
195
+ const meshMaterials = Array.isArray(object.material) ? object.material : [object.material]
196
+ meshMaterials.forEach((material, materialIndex) => {
197
+ const appearance = appearancesByMaterial.get(material)
198
+ if (appearance) {
199
+ object.userData.appearanceMaterialId ??= appearance.presetId
200
+ object.userData.materialSlot ??= appearance.fallbackSlot
201
+ this.rememberAppearanceBinding({
202
+ mesh: object,
203
+ ...appearance,
204
+ ...(Array.isArray(object.material) ? { materialIndex } : {}),
205
+ })
206
+ return
207
+ }
208
+ const slot = slotsByMaterial.get(material)
209
+ if (!slot) return
210
+ object.userData.materialSlot ??= slot
211
+ this.rememberBinding({
212
+ mesh: object,
213
+ slot,
214
+ ...(Array.isArray(object.material) ? { materialIndex } : {}),
215
+ })
216
+ })
217
+ applyHolographicFloorShadowPolicy(object)
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Keeps an authored floor choice live across theme changes while assigning
223
+ * the current theme's matching geometry-family recipe. This lets a stale
224
+ * sand/plank id render coherently now and recover its exact authored recipe
225
+ * if that theme is selected later.
226
+ */
227
+ rebindFloorAppearanceForTheme(root: THREE.Object3D, authoredPresetId: string | undefined): void {
228
+ if (!authoredPresetId) return
229
+ this.assertActive()
230
+ const resolvedPresetId = resolvedFloorMaterialIdForTheme(this.theme, authoredPresetId)
231
+ const material = resolvedPresetId
232
+ ? this.getAppearance(resolvedPresetId, 'lane.surface')
233
+ : this.get('lane.surface')
234
+
235
+ root.traverse(object => {
236
+ if (!(object instanceof THREE.Mesh)) return
237
+ const meshMaterials = Array.isArray(object.material) ? object.material : [object.material]
238
+ meshMaterials.forEach((_meshMaterial, materialIndex) => {
239
+ const key = Array.isArray(object.material) ? materialIndex : -1
240
+ const slot = this.bindingIndex.get(object)?.get(key)?.slot
241
+ const appearanceSlot = this.appearanceBindingIndex.get(object)?.get(key)?.fallbackSlot
242
+ if (slot !== 'lane.surface' && appearanceSlot !== 'lane.surface') return
243
+ assignMaterial(object, material, Array.isArray(object.material) ? materialIndex : undefined)
244
+ object.userData.appearanceMaterialId = authoredPresetId
245
+ object.userData.materialSlot = 'lane.surface'
246
+ this.rememberAppearanceBinding({
247
+ mesh: object,
248
+ presetId: authoredPresetId,
249
+ fallbackSlot: 'lane.surface',
250
+ ...(Array.isArray(object.material) ? { materialIndex } : {}),
251
+ resolveFloorByTheme: true,
252
+ })
253
+ })
254
+ applyHolographicFloorShadowPolicy(object)
255
+ })
256
+ }
257
+
258
+ /**
259
+ * Releases every strong mesh reference captured below `root`.
260
+ *
261
+ * Track Lab recreates its preview meshes whenever the document changes. A
262
+ * registry is scene-scoped and outlives those previews, so leaving their
263
+ * bindings registered would retain every disposed document revision and
264
+ * continue rebinding dead meshes on later theme changes.
265
+ */
266
+ releaseBindings(root: THREE.Object3D): void {
267
+ root.traverse(object => {
268
+ this.visibilityBindings.delete(object)
269
+ this.semanticThemeBindings.delete(object as ThemeVisualBinding)
270
+ if (!(object instanceof THREE.Mesh)) return
271
+
272
+ const bindings = this.bindingIndex.get(object)
273
+ if (bindings) {
274
+ bindings.forEach(binding => this.bindings.delete(binding))
275
+ this.bindingIndex.delete(object)
276
+ }
277
+ const appearanceBindings = this.appearanceBindingIndex.get(object)
278
+ if (appearanceBindings) {
279
+ appearanceBindings.forEach(binding => this.appearanceBindings.delete(binding))
280
+ this.appearanceBindingIndex.delete(object)
281
+ }
282
+ })
283
+ }
284
+
285
+ /**
286
+ * Moves a detached visual from a short-lived preparation registry into this
287
+ * scene registry without rebuilding its geometry. Material objects are
288
+ * rebound to this registry's active theme before the source can be disposed.
289
+ */
290
+ adoptBindingsFrom(source: ThemeMaterialRegistry, root: THREE.Object3D): void {
291
+ this.assertActive()
292
+ source.assertActive()
293
+ const adoptedSemanticBindings: ThemeVisualBinding[] = []
294
+ root.traverse(object => {
295
+ if (source.semanticThemeBindings.delete(object as ThemeVisualBinding)) {
296
+ const semantic = object as ThemeVisualBinding
297
+ this.semanticThemeBindings.add(semantic)
298
+ adoptedSemanticBindings.push(semantic)
299
+ }
300
+ if (source.visibilityBindings.delete(object)) this.visibilityBindings.add(object)
301
+ applyThemeVisibility(object, this.theme.id)
302
+ if (!(object instanceof THREE.Mesh)) return
303
+
304
+ const bindings = source.bindingIndex.get(object)
305
+ if (bindings) {
306
+ for (const [key, binding] of bindings) {
307
+ source.bindings.delete(binding)
308
+ const materialIndex = key < 0 ? undefined : key
309
+ assignMaterial(object, this.get(binding.slot), materialIndex)
310
+ this.rememberBinding({ mesh: object, slot: binding.slot, materialIndex })
311
+ }
312
+ source.bindingIndex.delete(object)
313
+ }
314
+ const appearanceBindings = source.appearanceBindingIndex.get(object)
315
+ if (appearanceBindings) {
316
+ for (const [key, binding] of appearanceBindings) {
317
+ source.appearanceBindings.delete(binding)
318
+ const materialIndex = key < 0 ? undefined : key
319
+ const resolvedPresetId = binding.resolveFloorByTheme
320
+ ? resolvedFloorMaterialIdForTheme(this.theme, binding.presetId)
321
+ : binding.presetId
322
+ const material = resolvedPresetId
323
+ ? this.getAppearance(resolvedPresetId, binding.fallbackSlot)
324
+ : this.get(binding.fallbackSlot)
325
+ assignMaterial(object, material, materialIndex)
326
+ this.rememberAppearanceBinding({ ...binding, mesh: object, materialIndex })
327
+ }
328
+ source.appearanceBindingIndex.delete(object)
329
+ }
330
+ applyHolographicFloorShadowPolicy(object)
331
+ })
332
+ // Semantic treatments can configure the material currently assigned to a
333
+ // mesh (for example, the canonical floor shoulder feather shader). Run
334
+ // them only after every source material has been replaced by this
335
+ // registry's live material.
336
+ for (const semantic of adoptedSemanticBindings) {
337
+ semantic.userData.applySemanticTheme?.(this.theme)
338
+ }
339
+ }
340
+
341
+ applyTheme(theme: TrackThemeDefinition): void {
342
+ this.assertActive()
343
+ const obsoleteMaterials: THREE.Material[] = []
344
+
345
+ for (const [slot, current] of this.materials) {
346
+ const recipe = theme.materials[slot]
347
+ if (canUpdateInPlace(current, recipe)) {
348
+ applyRecipe(current, recipe)
349
+ continue
350
+ }
351
+
352
+ const replacement = createMaterial(slot, recipe)
353
+ this.materials.set(slot, replacement)
354
+ for (const binding of this.bindings) {
355
+ if (binding.slot === slot) {
356
+ assignMaterial(binding.mesh, replacement, binding.materialIndex)
357
+ applyHolographicFloorShadowPolicy(binding.mesh)
358
+ }
359
+ }
360
+ obsoleteMaterials.push(current)
361
+ }
362
+
363
+ for (const [key, current] of this.appearanceMaterials) {
364
+ const appearance = parseAppearanceMaterialKey(key)
365
+ const recipe = findAppearanceRecipe(theme, appearance.presetId) ?? theme.materials[appearance.fallbackSlot]
366
+ if (canUpdateInPlace(current, recipe)) {
367
+ applyRecipe(current, recipe)
368
+ const preset = findAppearancePreset(theme, appearance.presetId)
369
+ applyAppearanceMetadata(current, preset?.canopyStyle, preset?.supportStyle)
370
+ continue
371
+ }
372
+ const preset = findAppearancePreset(theme, appearance.presetId)
373
+ const replacement = createAppearanceMaterial(
374
+ appearance.presetId,
375
+ appearance.fallbackSlot,
376
+ recipe,
377
+ preset?.canopyStyle,
378
+ preset?.supportStyle,
379
+ )
380
+ this.appearanceMaterials.set(key, replacement)
381
+ for (const binding of this.appearanceBindings) {
382
+ if (
383
+ !binding.resolveFloorByTheme &&
384
+ binding.presetId === appearance.presetId &&
385
+ binding.fallbackSlot === appearance.fallbackSlot
386
+ ) {
387
+ assignMaterial(binding.mesh, replacement, binding.materialIndex)
388
+ applyHolographicFloorShadowPolicy(binding.mesh)
389
+ }
390
+ }
391
+ obsoleteMaterials.push(current)
392
+ }
393
+
394
+ this.theme = theme
395
+ for (const binding of this.appearanceBindings) {
396
+ if (!binding.resolveFloorByTheme) continue
397
+ const resolvedPresetId = resolvedFloorMaterialIdForTheme(theme, binding.presetId)
398
+ const material = resolvedPresetId
399
+ ? this.getAppearance(resolvedPresetId, binding.fallbackSlot)
400
+ : this.get(binding.fallbackSlot)
401
+ assignMaterial(binding.mesh, material, binding.materialIndex)
402
+ applyHolographicFloorShadowPolicy(binding.mesh)
403
+ }
404
+ for (const object of this.semanticThemeBindings) {
405
+ object.userData.applySemanticTheme?.(theme)
406
+ }
407
+ for (const object of this.visibilityBindings) {
408
+ applyThemeVisibility(object, theme.id)
409
+ }
410
+ for (const binding of this.bindings) {
411
+ if (!this.visibilityBindings.has(binding.mesh)) applyThemeVisibility(binding.mesh, theme.id)
412
+ }
413
+ obsoleteMaterials.forEach(disposeMaterial)
414
+ }
415
+
416
+ dispose(): void {
417
+ if (this.disposed) return
418
+ this.disposed = true
419
+ this.materials.forEach(disposeMaterial)
420
+ this.appearanceMaterials.forEach(disposeMaterial)
421
+ this.materials.clear()
422
+ this.appearanceMaterials.clear()
423
+ this.bindings.clear()
424
+ this.appearanceBindings.clear()
425
+ this.visibilityBindings.clear()
426
+ this.semanticThemeBindings.clear()
427
+ }
428
+
429
+ private assertActive(): void {
430
+ if (this.disposed) throw new Error('ThemeMaterialRegistry has been disposed.')
431
+ }
432
+
433
+ private rememberBinding(binding: MaterialBinding): void {
434
+ const key = binding.materialIndex ?? -1
435
+ const bindings = this.bindingIndex.get(binding.mesh) ?? new Map<number, MaterialBinding>()
436
+ const current = bindings.get(key)
437
+ if (current?.slot === binding.slot) return
438
+ if (current) this.bindings.delete(current)
439
+ this.forgetAppearanceBinding(binding.mesh, key)
440
+ bindings.set(key, binding)
441
+ this.bindingIndex.set(binding.mesh, bindings)
442
+ this.bindings.add(binding)
443
+ }
444
+
445
+ private rememberAppearanceBinding(binding: AppearanceMaterialBinding): void {
446
+ const key = binding.materialIndex ?? -1
447
+ const bindings = this.appearanceBindingIndex.get(binding.mesh) ?? new Map<number, AppearanceMaterialBinding>()
448
+ const current = bindings.get(key)
449
+ if (
450
+ current?.presetId === binding.presetId &&
451
+ current.fallbackSlot === binding.fallbackSlot &&
452
+ current.resolveFloorByTheme === binding.resolveFloorByTheme
453
+ ) {
454
+ return
455
+ }
456
+ if (current) this.appearanceBindings.delete(current)
457
+ this.forgetBinding(binding.mesh, key)
458
+ bindings.set(key, binding)
459
+ this.appearanceBindingIndex.set(binding.mesh, bindings)
460
+ this.appearanceBindings.add(binding)
461
+ }
462
+
463
+ private forgetBinding(mesh: THREE.Mesh, key: number): void {
464
+ const bindings = this.bindingIndex.get(mesh)
465
+ const current = bindings?.get(key)
466
+ if (!bindings || !current) return
467
+ this.bindings.delete(current)
468
+ bindings.delete(key)
469
+ if (bindings.size === 0) this.bindingIndex.delete(mesh)
470
+ }
471
+
472
+ private forgetAppearanceBinding(mesh: THREE.Mesh, key: number): void {
473
+ const bindings = this.appearanceBindingIndex.get(mesh)
474
+ const current = bindings?.get(key)
475
+ if (!bindings || !current) return
476
+ this.appearanceBindings.delete(current)
477
+ bindings.delete(key)
478
+ if (bindings.size === 0) this.appearanceBindingIndex.delete(mesh)
479
+ }
480
+ }
481
+
482
+ function applyHolographicFloorShadowPolicy(mesh: THREE.Mesh): void {
483
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
484
+ const usesHolographicFloor = materials.some(material => material.userData.themeHolographicHoverGrid === true)
485
+ const originalKey = 'themeCastShadowBeforeHolographicFloor'
486
+ if (usesHolographicFloor) {
487
+ if (mesh.userData[originalKey] === undefined) mesh.userData[originalKey] = mesh.castShadow
488
+ mesh.castShadow = false
489
+ return
490
+ }
491
+ const original = mesh.userData[originalKey]
492
+ if (typeof original === 'boolean') mesh.castShadow = original
493
+ delete mesh.userData[originalKey]
494
+ }
495
+
496
+ function appearanceMaterialKey(presetId: string, fallbackSlot: ThemeMaterialSlot): string {
497
+ return `${fallbackSlot}\u0000${presetId}`
498
+ }
499
+
500
+ function parseAppearanceMaterialKey(key: string): { presetId: string; fallbackSlot: ThemeMaterialSlot } {
501
+ const separator = key.indexOf('\u0000')
502
+ return {
503
+ fallbackSlot: key.slice(0, separator) as ThemeMaterialSlot,
504
+ presetId: key.slice(separator + 1),
505
+ }
506
+ }
507
+
508
+ function findAppearancePreset(theme: TrackThemeDefinition, presetId: string): TrackAppearancePreset | undefined {
509
+ return [
510
+ ...(theme.appearancePresets?.floor ?? []),
511
+ ...(theme.appearancePresets?.wall ?? []),
512
+ ...(theme.appearancePresets?.rail ?? []),
513
+ ...(theme.appearancePresets?.support ?? []),
514
+ ].find(preset => preset.id === presetId)
515
+ }
516
+
517
+ function findAppearanceRecipe(theme: TrackThemeDefinition, presetId: string): MaterialRecipe | undefined {
518
+ return theme.detailMaterials?.[presetId] ?? findAppearancePreset(theme, presetId)?.material
519
+ }
520
+
521
+ function createMaterial(slot: ThemeMaterialSlot, recipe: MaterialRecipe): THREE.Material {
522
+ const material =
523
+ recipe.kind === 'physical'
524
+ ? new THREE.MeshPhysicalMaterial()
525
+ : recipe.kind === 'toon'
526
+ ? new THREE.MeshToonMaterial()
527
+ : new THREE.MeshStandardMaterial()
528
+ material.name = `theme:${slot}`
529
+ material.userData.themeRegistryManaged = true
530
+ applyRecipe(material, recipe)
531
+ return material
532
+ }
533
+
534
+ function createAppearanceMaterial(
535
+ presetId: string,
536
+ fallbackSlot: ThemeMaterialSlot,
537
+ recipe: MaterialRecipe,
538
+ canopyStyle?: TrackAppearanceCanopyStyle,
539
+ supportStyle?: TrackAppearanceSupportStyle,
540
+ ): THREE.Material {
541
+ const material = createMaterial(fallbackSlot, recipe)
542
+ material.name = `theme-appearance:${presetId}`
543
+ material.userData.themeAppearancePresetId = presetId
544
+ material.userData.themeAppearanceFallbackSlot = fallbackSlot
545
+ applyAppearanceMetadata(material, canopyStyle, supportStyle)
546
+ return material
547
+ }
548
+
549
+ function applyAppearanceMetadata(
550
+ material: THREE.Material,
551
+ canopyStyle?: TrackAppearanceCanopyStyle,
552
+ supportStyle?: TrackAppearanceSupportStyle,
553
+ ): void {
554
+ if (canopyStyle) material.userData.themeAppearanceCanopyStyle = canopyStyle
555
+ else delete material.userData.themeAppearanceCanopyStyle
556
+ if (supportStyle) {
557
+ material.userData.themeAppearanceSupportStyle = supportStyle
558
+ applyTransparentSupportFadeShader(material, true)
559
+ } else {
560
+ delete material.userData.themeAppearanceSupportStyle
561
+ delete material.userData.transparentSupportFadeShader
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Applies the shared under-track alpha fade. Both direct and instanced support
567
+ * geometry use the same local-depth interval while retaining normal FrontSide
568
+ * culling. Visible transparent fragments do not write depth. Support builders
569
+ * pair them with a color-disabled prepass that seals the nearest exterior
570
+ * throughout the fade. The matching colour pass then uses EqualDepth so rear
571
+ * faces and overlapping internal courses cannot bleed through that exterior.
572
+ */
573
+ export function applyTransparentSupportFadeShader(
574
+ material: THREE.Material,
575
+ force = false,
576
+ opaqueDepthPrepass = false,
577
+ ): void {
578
+ if (!force && material.userData.transparentSupportFadeShader === true) return
579
+ const compileBase = material.onBeforeCompile.bind(material)
580
+ const cacheKeyBase = material.customProgramCacheKey.bind(material)
581
+ const materialWithDefaults = material as THREE.Material & { defaultAttributeValues?: Record<string, number[]> }
582
+ materialWithDefaults.defaultAttributeValues = {
583
+ ...materialWithDefaults.defaultAttributeValues,
584
+ supportFade: [1],
585
+ supportFadeDepthMode: [0],
586
+ supportFadeDepth: [0],
587
+ supportFadeStart: [0],
588
+ supportFadeEnd: [1],
589
+ supportFadeBottom: [0],
590
+ supportFadeTop: [1],
591
+ }
592
+ material.onBeforeCompile = (shader, renderer) => {
593
+ compileBase(shader, renderer)
594
+ shader.vertexShader =
595
+ `attribute float supportFade;
596
+ #ifndef USE_INSTANCING
597
+ attribute float supportFadeDepthMode;
598
+ attribute float supportFadeDepth;
599
+ attribute float supportFadeStart;
600
+ attribute float supportFadeEnd;
601
+ #endif
602
+ varying float vSupportFadeCoverage;
603
+ varying float vSupportFadeHeight;
604
+ varying vec2 vSupportFadeRange;
605
+ #ifdef USE_INSTANCING
606
+ attribute float supportFadeBottom;
607
+ attribute float supportFadeTop;
608
+ #endif
609
+ ` +
610
+ shader.vertexShader.replace(
611
+ '#include <project_vertex>',
612
+ `#include <project_vertex>
613
+ #ifdef USE_INSTANCING
614
+ vec3 supportFadePosition = (instanceMatrix * vec4(transformed, 1.0)).xyz;
615
+ vSupportFadeCoverage = 1.0;
616
+ vSupportFadeHeight = supportFadePosition.y;
617
+ vSupportFadeRange = vec2(supportFadeBottom, supportFadeTop);
618
+ #else
619
+ // Procedural meshes keep their established per-vertex coverage.
620
+ // Authored meshes with faces that cross a fade boundary explicitly
621
+ // opt into interpolating physical depth before fragment evaluation.
622
+ vSupportFadeCoverage = mix(supportFade, 1.0, supportFadeDepthMode);
623
+ vSupportFadeHeight = mix(1.0, -supportFadeDepth, supportFadeDepthMode);
624
+ vSupportFadeRange = mix(
625
+ vec2(0.0, 1.0),
626
+ vec2(-supportFadeEnd, -supportFadeStart),
627
+ supportFadeDepthMode
628
+ );
629
+ #endif`,
630
+ )
631
+ const fadeFragment = opaqueDepthPrepass
632
+ ? `if (supportMaskCoverage < 0.01) discard;
633
+ diffuseColor.a = 1.0;`
634
+ : `diffuseColor.a *= clamp(supportMaskCoverage, 0.0, 1.0);
635
+ if (diffuseColor.a < 0.01) discard;`
636
+ shader.fragmentShader =
637
+ `varying float vSupportFadeCoverage;
638
+ varying float vSupportFadeHeight;
639
+ varying vec2 vSupportFadeRange;
640
+ ` +
641
+ shader.fragmentShader.replace(
642
+ '#include <alphatest_fragment>',
643
+ `#include <alphatest_fragment>
644
+ float supportHeightCoverage = smoothstep(
645
+ vSupportFadeRange.x,
646
+ max(vSupportFadeRange.x + 0.001, vSupportFadeRange.y),
647
+ vSupportFadeHeight
648
+ );
649
+ float supportMaskCoverage = min(vSupportFadeCoverage, supportHeightCoverage);
650
+ ${fadeFragment}`,
651
+ )
652
+ }
653
+ material.customProgramCacheKey = () =>
654
+ `${cacheKeyBase()}:transparent-support-height-fade-v3:${opaqueDepthPrepass ? 'opaque-depth' : 'color'}`
655
+ material.userData.transparentSupportFadeShader = true
656
+ material.userData.supportFadeOpaqueDepthPrepass = opaqueDepthPrepass
657
+ delete material.userData.opaqueSupportFadeShader
658
+ material.transparent = true
659
+ material.depthWrite = false
660
+ material.needsUpdate = true
661
+ }
662
+
663
+ function canUpdateInPlace(material: THREE.Material, recipe: MaterialRecipe): boolean {
664
+ const currentPattern = material.userData.themePatternSignature
665
+ const nextPattern = patternSignature(recipe.pattern)
666
+ if (currentPattern !== nextPattern) return false
667
+
668
+ return (
669
+ (recipe.kind === 'standard' &&
670
+ material instanceof THREE.MeshStandardMaterial &&
671
+ !(material instanceof THREE.MeshPhysicalMaterial)) ||
672
+ (recipe.kind === 'physical' && material instanceof THREE.MeshPhysicalMaterial) ||
673
+ (recipe.kind === 'toon' && material instanceof THREE.MeshToonMaterial)
674
+ )
675
+ }
676
+
677
+ function applyRecipe(material: THREE.Material, recipe: MaterialRecipe): void {
678
+ if (!(material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshToonMaterial)) return
679
+
680
+ material.color.set(recipe.color)
681
+ material.transparent = recipe.transparent ?? false
682
+ material.opacity = recipe.opacity ?? 1
683
+ material.alphaTest = recipe.alphaTest ?? 0
684
+ material.depthWrite = recipe.depthWrite ?? true
685
+ material.blending = THREE.NormalBlending
686
+ material.toneMapped = true
687
+ material.wireframe = false
688
+ const nextPatternSignature = patternSignature(recipe.pattern)
689
+ const nextMapSignature = mapSignature(recipe)
690
+ if (material.userData.themeMapSignature !== nextMapSignature) {
691
+ const previousMap = material.map
692
+ disposeTexture(previousMap)
693
+ disposeNeonNetTexture(material)
694
+ disposeWeatheredMetalResponseTextures(material)
695
+ if (material instanceof THREE.MeshStandardMaterial) {
696
+ for (const texture of new Set([
697
+ material.normalMap,
698
+ material.bumpMap,
699
+ material.displacementMap,
700
+ material.roughnessMap,
701
+ material.aoMap,
702
+ material.emissiveMap,
703
+ ])) {
704
+ if (texture && texture !== previousMap) disposeTexture(texture)
705
+ }
706
+ material.normalMap = null
707
+ material.bumpMap = null
708
+ material.displacementMap = null
709
+ material.roughnessMap = null
710
+ material.aoMap = null
711
+ material.emissiveMap = null
712
+ }
713
+ material.map = createRecipeTexture(recipe)
714
+ material.userData.themeMapSignature = nextMapSignature
715
+ material.userData.themePatternSignature = nextPatternSignature
716
+ if (recipe.pattern?.kind === 'weathered-sheet-metal' && material instanceof THREE.MeshStandardMaterial) {
717
+ const response = createWeatheredMetalResponseTextures(recipe.pattern)
718
+ material.bumpMap = response.height
719
+ material.roughnessMap = response.roughness
720
+ material.metalnessMap = response.metalness
721
+ material.userData.themeWeatheredMetalResponse = response
722
+ } else if (material instanceof THREE.MeshStandardMaterial) {
723
+ material.normalMap = createRecipeResponseTexture(recipe.normalTextureUrl, recipe, 'normal')
724
+ material.bumpMap = createRecipeResponseTexture(recipe.heightTextureUrl, recipe, 'height')
725
+ material.displacementMap = null
726
+ material.roughnessMap = createRecipeResponseTexture(recipe.roughnessTextureUrl, recipe, 'roughness')
727
+ material.aoMap = createRecipeResponseTexture(recipe.aoTextureUrl, recipe, 'ao')
728
+ material.emissiveMap = createRecipeResponseTexture(recipe.emissiveTextureUrl, recipe, 'emissive')
729
+ }
730
+ }
731
+ if (recipe.pattern?.kind !== 'weathered-sheet-metal' && material instanceof THREE.MeshStandardMaterial) {
732
+ if (!recipe.heightTextureUrl) material.bumpMap = null
733
+ material.metalnessMap = null
734
+ material.normalScale.set(...(recipe.normalScale ?? [1, 1]))
735
+ material.aoMapIntensity = recipe.aoMapIntensity ?? 1
736
+ }
737
+ material.bumpScale = recipe.bumpScale ?? 1
738
+ material.displacementMap = null
739
+ material.displacementScale = recipe.visualRelief?.scale ?? 0
740
+ material.displacementBias = recipe.visualRelief?.bias ?? 0
741
+ if (recipe.visualRelief) {
742
+ material.userData.themeVisualRelief = { ...recipe.visualRelief }
743
+ } else {
744
+ delete material.userData.themeVisualRelief
745
+ }
746
+
747
+ material.emissive.set(recipe.emissive ?? '#000000')
748
+ material.emissiveIntensity = recipe.emissiveIntensity ?? 0
749
+
750
+ if (material instanceof THREE.MeshStandardMaterial) {
751
+ material.roughness = recipe.roughness
752
+ material.metalness = recipe.metalness
753
+ material.envMapIntensity = recipe.envMapIntensity ?? 1
754
+ if (recipe.kind === 'physical' && material instanceof THREE.MeshPhysicalMaterial) {
755
+ material.clearcoat = recipe.clearcoat ?? 0
756
+ material.clearcoatRoughness = recipe.clearcoatRoughness ?? 0
757
+ material.transmission = recipe.transmission ?? 0
758
+ material.thickness = recipe.thickness ?? 0
759
+ }
760
+ }
761
+
762
+ if (recipe.kind === 'toon' && material instanceof THREE.MeshToonMaterial) {
763
+ const gradientSignature = `${recipe.gradientSteps ?? 4}`
764
+ if (material.userData.themeGradientSignature !== gradientSignature) {
765
+ material.gradientMap?.dispose()
766
+ material.gradientMap = createToonGradientTexture(recipe.gradientSteps ?? 4)
767
+ material.userData.themeGradientSignature = gradientSignature
768
+ }
769
+ applyToonInk(material, recipe, material.map ? activeMaterialTextureWorldSize(recipe) : undefined)
770
+ } else if (recipe.pattern?.kind === 'neon-net' && material instanceof THREE.MeshStandardMaterial) {
771
+ applyNeonNetShader(
772
+ material,
773
+ recipe.netSpacing ?? 0.82,
774
+ recipe.netLineWidth ?? 0.06,
775
+ recipe.netFillOpacity ?? 0,
776
+ recipe.pattern.primary,
777
+ )
778
+ } else if (recipe.pattern?.kind === 'holographic-hover-grid' && material instanceof THREE.MeshStandardMaterial) {
779
+ applyHolographicHoverGridShader(material, recipe)
780
+ } else if (material.map) {
781
+ applyMetricMapShader(material, activeMaterialTextureWorldSize(recipe), recipe.visualRelief, recipe.emissiveSurface)
782
+ } else {
783
+ material.onBeforeCompile = () => {}
784
+ material.customProgramCacheKey = () => ''
785
+ }
786
+ material.needsUpdate = true
787
+ }
788
+
789
+ function applyHolographicHoverGridShader(material: THREE.MeshStandardMaterial, recipe: MaterialRecipe): void {
790
+ const pattern = recipe.pattern
791
+ if (!pattern || pattern.kind !== 'holographic-hover-grid') return
792
+
793
+ const spacingX = Math.max(0.001, pattern.worldSize[0])
794
+ const spacingY = Math.max(0.001, pattern.worldSize[1])
795
+ const coreWidth = Math.max(0.0001, recipe.hoverGridCoreWidth ?? 0.0045)
796
+ const auraFalloff = Math.max(0.001, recipe.hoverGridAuraFalloff ?? 46)
797
+ const auraStrength = Math.max(0, recipe.hoverGridAuraStrength ?? 0.62)
798
+ const junctionStrength = Math.max(0, recipe.hoverGridJunctionStrength ?? 0.34)
799
+ const upwardOnly = recipe.emissiveSurface === 'upward'
800
+ configureThemeMetricAttribute(material)
801
+
802
+ material.onBeforeCompile = shader => {
803
+ shader.uniforms.themeHoverGridColor = { value: new THREE.Color(pattern.primary) }
804
+ shader.uniforms.themeHoverGridHotColor = { value: new THREE.Color(pattern.secondary) }
805
+ shader.vertexShader =
806
+ 'varying vec3 vThemeHoverGridPosition;\nvarying vec3 vThemeHoverGridNormal;\n' +
807
+ shader.vertexShader.replace(
808
+ '#include <project_vertex>',
809
+ `#include <project_vertex>
810
+ vec3 themeHoverGridPosition = transformed;
811
+ vec3 themeHoverGridNormal = objectNormal;
812
+ #ifdef USE_INSTANCING
813
+ themeHoverGridPosition = (instanceMatrix * vec4(themeHoverGridPosition, 1.0)).xyz;
814
+ mat3 themeHoverGridInstanceNormalMatrix = mat3(instanceMatrix);
815
+ themeHoverGridNormal /= vec3(
816
+ dot(themeHoverGridInstanceNormalMatrix[0], themeHoverGridInstanceNormalMatrix[0]),
817
+ dot(themeHoverGridInstanceNormalMatrix[1], themeHoverGridInstanceNormalMatrix[1]),
818
+ dot(themeHoverGridInstanceNormalMatrix[2], themeHoverGridInstanceNormalMatrix[2])
819
+ );
820
+ themeHoverGridNormal = themeHoverGridInstanceNormalMatrix * themeHoverGridNormal;
821
+ #endif
822
+ vec3 themeHoverGridWorldPosition = (modelMatrix * vec4(themeHoverGridPosition, 1.0)).xyz;
823
+ // Track Lab uniformly scales its complete presentation root to fit
824
+ // the workspace. That is camera staging, not material scale: remove
825
+ // it so a 0.28-unit grid remains 0.28 track units in Lab and races.
826
+ float themeHoverGridPresentationScale = max(
827
+ 0.000001,
828
+ (length(modelMatrix[0].xyz) + length(modelMatrix[1].xyz) + length(modelMatrix[2].xyz)) / 3.0
829
+ );
830
+ vThemeHoverGridPosition = themeHoverGridWorldPosition / themeHoverGridPresentationScale;
831
+ vThemeHoverGridNormal = normalize(mat3(modelMatrix) * themeHoverGridNormal);`,
832
+ )
833
+ shader.fragmentShader =
834
+ 'uniform vec3 themeHoverGridColor;\nuniform vec3 themeHoverGridHotColor;\nvarying vec3 vThemeHoverGridPosition;\nvarying vec3 vThemeHoverGridNormal;\n' +
835
+ shader.fragmentShader
836
+ .replace(
837
+ '#include <alphatest_fragment>',
838
+ `vec3 themeHoverNormal = abs(normalize(vThemeHoverGridNormal));
839
+ // A holographic floor can reuse a closed physical shell without
840
+ // illuminating its vertical thickness or downward underside.
841
+ if (${upwardOnly ? 'vThemeHoverGridNormal.y <= 0.001' : 'false'}) discard;
842
+ // One world-metric field is the source of truth for this hologram.
843
+ // Clipping it with independent floor meshes cannot introduce a seam,
844
+ // including at branches, loops, rotations, or editor/runtime roots.
845
+ vec2 themeHoverCoordinate =
846
+ themeHoverNormal.y >= themeHoverNormal.x && themeHoverNormal.y >= themeHoverNormal.z
847
+ ? vThemeHoverGridPosition.xz
848
+ : themeHoverNormal.x >= themeHoverNormal.z
849
+ ? vThemeHoverGridPosition.zy
850
+ : vThemeHoverGridPosition.xy;
851
+ vec2 themeHoverSpacing = vec2(${spacingX.toFixed(6)}, ${spacingY.toFixed(6)});
852
+ vec2 themeHoverDistanceToLine = abs(fract(themeHoverCoordinate / themeHoverSpacing + 0.5) - 0.5) * themeHoverSpacing;
853
+ float themeHoverEdgeDistance = min(themeHoverDistanceToLine.x, themeHoverDistanceToLine.y);
854
+ float themeHoverAntialias = max(fwidth(themeHoverEdgeDistance) * 1.15, 0.0012);
855
+ float themeHoverCore = 1.0 - smoothstep(${coreWidth.toFixed(6)} - themeHoverAntialias, ${coreWidth.toFixed(6)} + themeHoverAntialias, themeHoverEdgeDistance);
856
+ float themeHoverAura = exp(-themeHoverEdgeDistance * ${auraFalloff.toFixed(6)}) * ${auraStrength.toFixed(6)};
857
+ float themeHoverJunction = (1.0 - smoothstep(0.013, 0.026, themeHoverDistanceToLine.x))
858
+ * (1.0 - smoothstep(0.013, 0.026, themeHoverDistanceToLine.y));
859
+ float themeHoverIntensity = themeHoverCore * 1.72 + themeHoverAura * 1.18 + themeHoverJunction * ${junctionStrength.toFixed(6)};
860
+ if (themeHoverIntensity < 0.014) discard;
861
+ vec3 themeHoverHotColor = mix(
862
+ themeHoverGridColor,
863
+ themeHoverGridHotColor,
864
+ clamp(themeHoverCore * 0.72 + themeHoverJunction * 0.3, 0.0, 0.84)
865
+ );
866
+ float themeHoverAlpha = min(0.98, themeHoverCore * 0.94 + themeHoverAura * 0.68 + themeHoverJunction * 0.2);
867
+ diffuseColor = vec4(themeHoverHotColor * themeHoverIntensity, themeHoverAlpha);
868
+ #include <alphatest_fragment>`,
869
+ )
870
+ .replace('#include <opaque_fragment>', 'gl_FragColor = vec4(diffuseColor.rgb, diffuseColor.a);')
871
+ }
872
+ material.customProgramCacheKey = () =>
873
+ `holographic-hover-grid-v5-track-metric:${upwardOnly ? 'upward' : 'all'}:${spacingX.toFixed(6)}:${spacingY.toFixed(6)}:${coreWidth.toFixed(6)}:${auraFalloff.toFixed(3)}:${auraStrength.toFixed(3)}:${junctionStrength.toFixed(3)}`
874
+ material.transparent = true
875
+ material.depthWrite = false
876
+ material.blending = THREE.AdditiveBlending
877
+ material.toneMapped = false
878
+ material.userData.themeHolographicHoverGrid = true
879
+ const materialWithAttributeDefaults = material as THREE.MeshStandardMaterial & {
880
+ defaultAttributeValues?: Record<string, number[]>
881
+ }
882
+ materialWithAttributeDefaults.defaultAttributeValues = {
883
+ ...materialWithAttributeDefaults.defaultAttributeValues,
884
+ themeHoverGridPhase: [0],
885
+ }
886
+ }
887
+
888
+ interface ThemeShaderSource {
889
+ uniforms: Record<string, { value: unknown }>
890
+ vertexShader: string
891
+ fragmentShader: string
892
+ }
893
+
894
+ function configureThemeMetricAttribute(material: THREE.Material): void {
895
+ const materialWithAttributeDefaults = material as THREE.Material & {
896
+ defaultAttributeValues?: Record<string, number[]>
897
+ }
898
+ materialWithAttributeDefaults.defaultAttributeValues = {
899
+ ...materialWithAttributeDefaults.defaultAttributeValues,
900
+ themeNetUv: [0, 0, 0, 0],
901
+ themeRelief: [0],
902
+ themeMaterialPhase: [0],
903
+ themeMaterialTransversePhase: [0],
904
+ themeMaterialLongitudinalAxis: [1],
905
+ }
906
+ }
907
+
908
+ function injectMetricMapShader(
909
+ shader: ThemeShaderSource,
910
+ worldSize: readonly [number, number],
911
+ visualRelief?: MaterialRecipe['visualRelief'],
912
+ reliefMap: THREE.Texture | null = null,
913
+ emissiveSurface: MaterialRecipe['emissiveSurface'] = 'all',
914
+ ): void {
915
+ const safeWorldSize: readonly [number, number] = [Math.max(0.001, worldSize[0]), Math.max(0.001, worldSize[1])]
916
+ shader.uniforms.themeTextureScale = {
917
+ value: new THREE.Vector2(1 / safeWorldSize[0], 1 / safeWorldSize[1]),
918
+ }
919
+ if (visualRelief) {
920
+ shader.uniforms.themeReliefMap = { value: reliefMap }
921
+ shader.uniforms.themeReliefScale = { value: visualRelief.scale }
922
+ shader.uniforms.themeReliefBias = { value: visualRelief.bias }
923
+ }
924
+ shader.vertexShader =
925
+ `${visualRelief ? 'attribute float themeRelief;\nuniform sampler2D themeReliefMap;\nuniform float themeReliefScale;\nuniform float themeReliefBias;\n' : ''}attribute vec4 themeNetUv;\nattribute float themeMaterialPhase;\nattribute float themeMaterialTransversePhase;\nattribute float themeMaterialLongitudinalAxis;\nuniform vec2 themeTextureScale;\nvarying vec4 vThemeMetricUv;\nvarying float vThemeMaterialPhase;\nvarying float vThemeMaterialTransversePhase;\nvarying float vThemeMaterialLongitudinalAxis;\nvarying vec3 vThemeMetricPosition;\nvarying vec3 vThemeMetricNormal;\n#ifdef USE_NORMALMAP\nvarying vec3 vThemeMetricAxisX;\nvarying vec3 vThemeMetricAxisY;\nvarying vec3 vThemeMetricAxisZ;\n#endif\n` +
926
+ shader.vertexShader
927
+ .replace(
928
+ '#include <begin_vertex>',
929
+ visualRelief
930
+ ? `#include <begin_vertex>
931
+ #ifdef USE_BUMPMAP
932
+ transformed += normalize(objectNormal) *
933
+ (texture2D(
934
+ themeReliefMap,
935
+ vBumpMapUv + mix(
936
+ vec2(themeMaterialPhase * themeTextureScale.x, themeMaterialTransversePhase * themeTextureScale.y),
937
+ vec2(themeMaterialTransversePhase * themeTextureScale.x, themeMaterialPhase * themeTextureScale.y),
938
+ themeMaterialLongitudinalAxis
939
+ )
940
+ ).x * themeReliefScale + themeReliefBias) *
941
+ themeRelief;
942
+ #endif`
943
+ : '#include <begin_vertex>',
944
+ )
945
+ .replace(
946
+ '#include <project_vertex>',
947
+ `#include <project_vertex>
948
+ vThemeMetricUv = themeNetUv;
949
+ vThemeMaterialPhase = themeMaterialPhase;
950
+ vThemeMaterialTransversePhase = themeMaterialTransversePhase;
951
+ vThemeMaterialLongitudinalAxis = themeMaterialLongitudinalAxis;
952
+ vec3 themeMetricPosition = transformed;
953
+ vec3 themeMetricNormal = objectNormal;
954
+ #ifdef USE_NORMALMAP
955
+ vec3 themeMetricAxisX = vec3(1.0, 0.0, 0.0);
956
+ vec3 themeMetricAxisY = vec3(0.0, 1.0, 0.0);
957
+ vec3 themeMetricAxisZ = vec3(0.0, 0.0, 1.0);
958
+ #endif
959
+ #ifdef USE_INSTANCING
960
+ themeMetricPosition = (instanceMatrix * vec4(themeMetricPosition, 1.0)).xyz;
961
+ mat3 themeInstanceNormalMatrix = mat3(instanceMatrix);
962
+ vec3 themeInstanceScaleSquared = vec3(
963
+ dot(themeInstanceNormalMatrix[0], themeInstanceNormalMatrix[0]),
964
+ dot(themeInstanceNormalMatrix[1], themeInstanceNormalMatrix[1]),
965
+ dot(themeInstanceNormalMatrix[2], themeInstanceNormalMatrix[2])
966
+ );
967
+ themeMetricNormal /= themeInstanceScaleSquared;
968
+ themeMetricNormal = themeInstanceNormalMatrix * themeMetricNormal;
969
+ #ifdef USE_NORMALMAP
970
+ themeMetricAxisX = themeInstanceNormalMatrix * (themeMetricAxisX / themeInstanceScaleSquared);
971
+ themeMetricAxisY = themeInstanceNormalMatrix * (themeMetricAxisY / themeInstanceScaleSquared);
972
+ themeMetricAxisZ = themeInstanceNormalMatrix * (themeMetricAxisZ / themeInstanceScaleSquared);
973
+ #endif
974
+ #endif
975
+ vThemeMetricPosition = themeMetricPosition;
976
+ vThemeMetricNormal = normalize(themeMetricNormal);
977
+ #ifdef USE_NORMALMAP
978
+ vThemeMetricAxisX = normalize(normalMatrix * themeMetricAxisX);
979
+ vThemeMetricAxisY = normalize(normalMatrix * themeMetricAxisY);
980
+ vThemeMetricAxisZ = normalize(normalMatrix * themeMetricAxisZ);
981
+ #endif`,
982
+ )
983
+ shader.fragmentShader =
984
+ 'uniform vec2 themeTextureScale;\nvarying vec4 vThemeMetricUv;\nvarying float vThemeMaterialPhase;\nvarying float vThemeMaterialTransversePhase;\nvarying float vThemeMaterialLongitudinalAxis;\nvarying vec3 vThemeMetricPosition;\nvarying vec3 vThemeMetricNormal;\n#ifdef USE_NORMALMAP\nvarying vec3 vThemeMetricAxisX;\nvarying vec3 vThemeMetricAxisY;\nvarying vec3 vThemeMetricAxisZ;\n#endif\n' +
985
+ `vec3 themeMetricBlendWeights() {
986
+ vec3 weights = pow(abs(normalize(vThemeMetricNormal)), vec3(6.0));
987
+ return weights / max(weights.x + weights.y + weights.z, 0.0001);
988
+ }
989
+ vec2 themeAuthoredPhaseOffset() {
990
+ return mix(
991
+ vec2(vThemeMaterialPhase * themeTextureScale.x, vThemeMaterialTransversePhase * themeTextureScale.y),
992
+ vec2(vThemeMaterialTransversePhase * themeTextureScale.x, vThemeMaterialPhase * themeTextureScale.y),
993
+ vThemeMaterialLongitudinalAxis
994
+ );
995
+ }
996
+ vec2 themeOrientedSurfaceUv(vec2 authoredUv) {
997
+ // vMapUv and the response-map UV varyings already include each
998
+ // texture's repeat transform. Directional metric geometry therefore
999
+ // supplies world-unit UVs directly; multiplying by themeTextureScale
1000
+ // here would apply physical scale twice and enlarge the pattern.
1001
+ return authoredUv + themeAuthoredPhaseOffset();
1002
+ }
1003
+ vec4 themeSampleSurfaceMap(sampler2D themeSampler, vec2 authoredUv) {
1004
+ if (vThemeMetricUv.z > 0.5) {
1005
+ return texture2D(themeSampler, themeOrientedSurfaceUv(authoredUv));
1006
+ }
1007
+ vec3 weights = themeMetricBlendWeights();
1008
+ vec4 sampleX = texture2D(themeSampler, vThemeMetricPosition.zy * themeTextureScale);
1009
+ vec4 sampleY = texture2D(themeSampler, vThemeMetricPosition.xz * themeTextureScale);
1010
+ vec4 sampleZ = texture2D(themeSampler, vThemeMetricPosition.xy * themeTextureScale);
1011
+ return sampleX * weights.x + sampleY * weights.y + sampleZ * weights.z;
1012
+ }
1013
+ vec4 themeSampleSurfaceResponseMap(sampler2D themeSampler, vec2 authoredUv) {
1014
+ if (vThemeMetricUv.z > 0.5) {
1015
+ return texture2D(themeSampler, themeOrientedSurfaceUv(authoredUv));
1016
+ }
1017
+ vec3 weights = themeMetricBlendWeights();
1018
+ vec4 sampleX = texture2D(themeSampler, vThemeMetricPosition.zy * themeTextureScale);
1019
+ vec4 sampleY = texture2D(themeSampler, vThemeMetricPosition.xz * themeTextureScale);
1020
+ vec4 sampleZ = texture2D(themeSampler, vThemeMetricPosition.xy * themeTextureScale);
1021
+ return sampleX * weights.x + sampleY * weights.y + sampleZ * weights.z;
1022
+ }
1023
+ #ifdef USE_NORMALMAP
1024
+ vec3 themeSampleMetricNormal(sampler2D themeNormalSampler, vec2 themeNormalScale) {
1025
+ vec3 weights = themeMetricBlendWeights();
1026
+ vec3 axisSign = mix(vec3(-1.0), vec3(1.0), step(vec3(0.0), vThemeMetricNormal));
1027
+ vec3 sampleX = texture2D(themeNormalSampler, vThemeMetricPosition.zy * themeTextureScale).xyz * 2.0 - 1.0;
1028
+ vec3 sampleY = texture2D(themeNormalSampler, vThemeMetricPosition.xz * themeTextureScale).xyz * 2.0 - 1.0;
1029
+ vec3 sampleZ = texture2D(themeNormalSampler, vThemeMetricPosition.xy * themeTextureScale).xyz * 2.0 - 1.0;
1030
+ sampleX.xy *= themeNormalScale;
1031
+ sampleY.xy *= themeNormalScale;
1032
+ sampleZ.xy *= themeNormalScale;
1033
+ vec3 normalX = normalize(vec3(sampleX.z * axisSign.x, sampleX.y, sampleX.x * axisSign.x));
1034
+ vec3 normalY = normalize(vec3(sampleY.x, sampleY.z * axisSign.y, sampleY.y * axisSign.y));
1035
+ vec3 normalZ = normalize(vec3(sampleZ.x * axisSign.z, sampleZ.y, sampleZ.z * axisSign.z));
1036
+ return normalize(normalX * weights.x + normalY * weights.y + normalZ * weights.z);
1037
+ }
1038
+ #endif
1039
+ ` +
1040
+ shader.fragmentShader
1041
+ .replace(
1042
+ '#include <map_fragment>',
1043
+ `#ifdef USE_MAP
1044
+ diffuseColor *= themeSampleSurfaceMap(map, vMapUv);
1045
+ #endif`,
1046
+ )
1047
+ .replace(
1048
+ '#include <roughnessmap_fragment>',
1049
+ `float roughnessFactor = roughness;
1050
+ #ifdef USE_ROUGHNESSMAP
1051
+ roughnessFactor *= themeSampleSurfaceResponseMap(roughnessMap, vRoughnessMapUv).g;
1052
+ #endif`,
1053
+ )
1054
+ .replace(
1055
+ '#include <metalnessmap_fragment>',
1056
+ `float metalnessFactor = metalness;
1057
+ #ifdef USE_METALNESSMAP
1058
+ metalnessFactor *= themeSampleSurfaceResponseMap(metalnessMap, vMetalnessMapUv).b;
1059
+ #endif`,
1060
+ )
1061
+ .replace(
1062
+ '#include <normal_fragment_maps>',
1063
+ `#ifdef USE_NORMALMAP_OBJECTSPACE
1064
+ vec3 mapN = themeSampleSurfaceMap(normalMap, vNormalMapUv).xyz * 2.0 - 1.0;
1065
+ #ifdef FLIP_SIDED
1066
+ mapN = -mapN;
1067
+ #endif
1068
+ #ifdef DOUBLE_SIDED
1069
+ mapN = mapN * faceDirection;
1070
+ #endif
1071
+ normal = normalize(normalMatrix * mapN);
1072
+ #elif defined(USE_NORMALMAP_TANGENTSPACE)
1073
+ if (vThemeMetricUv.z > 0.5) {
1074
+ vec3 mapN = texture2D(normalMap, themeOrientedSurfaceUv(vNormalMapUv)).xyz * 2.0 - 1.0;
1075
+ #if defined(USE_PACKED_NORMALMAP)
1076
+ mapN = vec3(mapN.xy, sqrt(saturate(1.0 - dot(mapN.xy, mapN.xy))));
1077
+ #endif
1078
+ mapN.xy *= normalScale;
1079
+ normal = normalize(tbn * mapN);
1080
+ } else {
1081
+ vec3 themeMetricNormalSample = themeSampleMetricNormal(normalMap, normalScale);
1082
+ normal = normalize(
1083
+ vThemeMetricAxisX * themeMetricNormalSample.x +
1084
+ vThemeMetricAxisY * themeMetricNormalSample.y +
1085
+ vThemeMetricAxisZ * themeMetricNormalSample.z
1086
+ );
1087
+ }
1088
+ #elif defined(USE_BUMPMAP)
1089
+ normal = perturbNormalArb(-vViewPosition, normal, dHdxy_fwd(), faceDirection);
1090
+ #endif`,
1091
+ )
1092
+ .replace(
1093
+ '#include <aomap_fragment>',
1094
+ `#ifdef USE_AOMAP
1095
+ float ambientOcclusion =
1096
+ (themeSampleSurfaceResponseMap(aoMap, vAoMapUv).r - 1.0) * aoMapIntensity + 1.0;
1097
+ reflectedLight.indirectDiffuse *= ambientOcclusion;
1098
+ #if defined(USE_CLEARCOAT)
1099
+ clearcoatSpecularIndirect *= ambientOcclusion;
1100
+ #endif
1101
+ #if defined(USE_SHEEN)
1102
+ sheenSpecularIndirect *= ambientOcclusion;
1103
+ #endif
1104
+ #if defined(USE_ENVMAP) && defined(STANDARD)
1105
+ float dotNV = saturate(dot(geometryNormal, geometryViewDir));
1106
+ reflectedLight.indirectSpecular *=
1107
+ computeSpecularOcclusion(dotNV, ambientOcclusion, material.roughness);
1108
+ #endif
1109
+ #endif`,
1110
+ )
1111
+ .replace(
1112
+ '#include <emissivemap_fragment>',
1113
+ `#ifdef USE_EMISSIVEMAP
1114
+ vec4 emissiveColor = themeSampleSurfaceResponseMap(emissiveMap, vEmissiveMapUv);
1115
+ // Ribbon floors are a single shell, so their top, sides, caps, and underside
1116
+ // share one material. Keep guide-light artwork on the traversable face instead
1117
+ // of wrapping it around the shell like a glowing net.
1118
+ float themeEmissiveSurface = ${emissiveSurface === 'upward' ? 'smoothstep(0.08, 0.36, normalize(vThemeMetricNormal).y)' : '1.0'};
1119
+ totalEmissiveRadiance *= emissiveColor.rgb * themeEmissiveSurface;
1120
+ #endif`,
1121
+ )
1122
+ .replace(
1123
+ '#include <bumpmap_pars_fragment>',
1124
+ `#ifdef USE_BUMPMAP
1125
+ uniform sampler2D bumpMap;
1126
+ uniform float bumpScale;
1127
+ vec2 dHdxy_fwd() {
1128
+ if (vThemeMetricUv.z > 0.5) {
1129
+ vec2 mappedUv = themeOrientedSurfaceUv(vBumpMapUv);
1130
+ vec2 dSTdx = dFdx(mappedUv);
1131
+ vec2 dSTdy = dFdy(mappedUv);
1132
+ float Hll = bumpScale * texture2D(bumpMap, mappedUv).x;
1133
+ float dBx = bumpScale * texture2D(bumpMap, mappedUv + dSTdx).x - Hll;
1134
+ float dBy = bumpScale * texture2D(bumpMap, mappedUv + dSTdy).x - Hll;
1135
+ return vec2(dBx, dBy);
1136
+ }
1137
+ float metricHeight = bumpScale * themeSampleSurfaceMap(bumpMap, vBumpMapUv).x;
1138
+ return vec2(dFdx(metricHeight), dFdy(metricHeight));
1139
+ }
1140
+ vec3 perturbNormalArb(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) {
1141
+ vec3 vSigmaX = normalize(dFdx(surf_pos.xyz));
1142
+ vec3 vSigmaY = normalize(dFdy(surf_pos.xyz));
1143
+ vec3 R1 = cross(vSigmaY, surf_norm);
1144
+ vec3 R2 = cross(surf_norm, vSigmaX);
1145
+ float fDet = dot(vSigmaX, R1) * faceDirection;
1146
+ vec3 vGrad = sign(fDet) * (dHdxy.x * R1 + dHdxy.y * R2);
1147
+ return normalize(abs(fDet) * surf_norm - vGrad);
1148
+ }
1149
+ #endif`,
1150
+ )
1151
+ }
1152
+
1153
+ function applyMetricMapShader(
1154
+ material: THREE.Material,
1155
+ worldSize: readonly [number, number],
1156
+ visualRelief?: MaterialRecipe['visualRelief'],
1157
+ emissiveSurface: MaterialRecipe['emissiveSurface'] = 'all',
1158
+ ): void {
1159
+ configureThemeMetricAttribute(material)
1160
+ material.onBeforeCompile = shader => {
1161
+ injectMetricMapShader(
1162
+ shader,
1163
+ worldSize,
1164
+ visualRelief,
1165
+ material instanceof THREE.MeshStandardMaterial ? material.bumpMap : null,
1166
+ emissiveSurface,
1167
+ )
1168
+ }
1169
+ material.customProgramCacheKey = () =>
1170
+ `metric-map-v5:${worldSize[0].toFixed(4)}:${worldSize[1].toFixed(4)}:${visualRelief ? 'relief' : 'flat'}:${emissiveSurface}`
1171
+ }
1172
+
1173
+ function createToonGradientTexture(steps: number): THREE.DataTexture {
1174
+ const clampedSteps = THREE.MathUtils.clamp(Math.round(steps), 2, 6)
1175
+ const data = new Uint8Array(clampedSteps)
1176
+ for (let index = 0; index < clampedSteps; index += 1) {
1177
+ const progress = index / Math.max(1, clampedSteps - 1)
1178
+ data[index] = Math.round(THREE.MathUtils.lerp(184, 255, Math.pow(progress, 0.82)))
1179
+ }
1180
+ const texture = new THREE.DataTexture(data, clampedSteps, 1, THREE.RedFormat)
1181
+ texture.name = `theme-toon-gradient:${clampedSteps}`
1182
+ texture.minFilter = THREE.NearestFilter
1183
+ texture.magFilter = THREE.NearestFilter
1184
+ texture.generateMipmaps = false
1185
+ texture.needsUpdate = true
1186
+ return texture
1187
+ }
1188
+
1189
+ function applyToonInk(
1190
+ material: THREE.MeshToonMaterial,
1191
+ recipe: Extract<MaterialRecipe, { kind: 'toon' }>,
1192
+ mapWorldSize?: readonly [number, number],
1193
+ ): void {
1194
+ const inkColor = recipe.inkColor ?? '#29352a'
1195
+ const inkStrength = THREE.MathUtils.clamp(recipe.inkStrength ?? 0.18, 0, 0.45)
1196
+ if (mapWorldSize) configureThemeMetricAttribute(material)
1197
+ material.onBeforeCompile = shader => {
1198
+ if (mapWorldSize) injectMetricMapShader(shader, mapWorldSize)
1199
+ shader.uniforms.themeInkColor = { value: new THREE.Color(inkColor) }
1200
+ shader.uniforms.themeInkStrength = { value: inkStrength }
1201
+ shader.fragmentShader =
1202
+ 'uniform vec3 themeInkColor;\nuniform float themeInkStrength;\n' +
1203
+ shader.fragmentShader.replace(
1204
+ '#include <opaque_fragment>',
1205
+ `float themeFacing = abs(dot(normalize(normal), normalize(vViewPosition)));
1206
+ float themeInk = smoothstep(0.62, 0.96, 1.0 - themeFacing) * themeInkStrength;
1207
+ outgoingLight = mix(outgoingLight, themeInkColor, themeInk);
1208
+ #include <opaque_fragment>`,
1209
+ )
1210
+ }
1211
+ material.customProgramCacheKey = () =>
1212
+ `painted-toon:${inkColor}:${inkStrength.toFixed(3)}:${mapWorldSize?.join(':') ?? 'solid'}`
1213
+ }
1214
+
1215
+ function applyNeonNetShader(
1216
+ material: THREE.MeshStandardMaterial,
1217
+ spacing: number,
1218
+ lineWidth: number,
1219
+ fillOpacity: number,
1220
+ fillColor: string,
1221
+ ): void {
1222
+ const safeSpacing = Math.max(0.001, spacing)
1223
+ const safeLineWidth = THREE.MathUtils.clamp(lineWidth, 0.015, 0.2)
1224
+ const safeFillOpacity = THREE.MathUtils.clamp(fillOpacity, 0, 0.95)
1225
+ const textureSignature = safeLineWidth.toFixed(4)
1226
+ if (material.userData.themeNeonNetTextureSignature !== textureSignature) {
1227
+ disposeNeonNetTexture(material)
1228
+ material.userData.themeNeonNetTexture = createNeonNetTexture(safeLineWidth)
1229
+ material.userData.themeNeonNetTextureSignature = textureSignature
1230
+ }
1231
+ const neonNetTexture = material.userData.themeNeonNetTexture as THREE.DataTexture
1232
+ const materialWithAttributeDefaults = material as THREE.MeshStandardMaterial & {
1233
+ defaultAttributeValues?: Record<string, number[]>
1234
+ }
1235
+ materialWithAttributeDefaults.defaultAttributeValues = {
1236
+ ...materialWithAttributeDefaults.defaultAttributeValues,
1237
+ themeNetUv: [0, 0, 0, 0],
1238
+ }
1239
+
1240
+ material.onBeforeCompile = shader => {
1241
+ shader.uniforms.themeNeonNetMap = { value: neonNetTexture }
1242
+ shader.uniforms.themeNeonNetFillOpacity = { value: safeFillOpacity }
1243
+ shader.uniforms.themeNeonNetFillColor = { value: new THREE.Color(fillColor) }
1244
+ shader.vertexShader =
1245
+ 'attribute vec4 themeNetUv;\nvarying vec4 vThemeNetUv;\nvarying vec3 vThemeLocalPosition;\nvarying vec3 vThemeLocalNormal;\n' +
1246
+ shader.vertexShader.replace(
1247
+ '#include <project_vertex>',
1248
+ `#include <project_vertex>
1249
+ vThemeNetUv = themeNetUv;
1250
+ vec3 themeNetPosition = transformed;
1251
+ vec3 themeNetNormal = objectNormal;
1252
+ #ifdef USE_INSTANCING
1253
+ themeNetPosition = (instanceMatrix * vec4(themeNetPosition, 1.0)).xyz;
1254
+ mat3 themeNetInstanceNormalMatrix = mat3(instanceMatrix);
1255
+ themeNetNormal /= vec3(
1256
+ dot(themeNetInstanceNormalMatrix[0], themeNetInstanceNormalMatrix[0]),
1257
+ dot(themeNetInstanceNormalMatrix[1], themeNetInstanceNormalMatrix[1]),
1258
+ dot(themeNetInstanceNormalMatrix[2], themeNetInstanceNormalMatrix[2])
1259
+ );
1260
+ themeNetNormal = themeNetInstanceNormalMatrix * themeNetNormal;
1261
+ #endif
1262
+ vThemeLocalPosition = themeNetPosition;
1263
+ vThemeLocalNormal = normalize(themeNetNormal);`,
1264
+ )
1265
+ shader.fragmentShader =
1266
+ 'uniform sampler2D themeNeonNetMap;\nuniform float themeNeonNetFillOpacity;\nuniform vec3 themeNeonNetFillColor;\nvarying vec4 vThemeNetUv;\nvarying vec3 vThemeLocalPosition;\nvarying vec3 vThemeLocalNormal;\n' +
1267
+ shader.fragmentShader
1268
+ .replace(
1269
+ '#include <alphatest_fragment>',
1270
+ `vec3 themeNormal = abs(normalize(vThemeLocalNormal));
1271
+ vec2 themeNetCoordinate;
1272
+ if (vThemeNetUv.z > 0.5) {
1273
+ float themeNetLength = max(vThemeNetUv.w, ${safeSpacing.toFixed(6)});
1274
+ float themeNetCycles = max(1.0, floor(themeNetLength / ${safeSpacing.toFixed(6)} + 0.5));
1275
+ float themeNetLockedLength = themeNetCycles * ${safeSpacing.toFixed(6)};
1276
+ themeNetCoordinate = vec2(
1277
+ vThemeNetUv.x,
1278
+ vThemeNetUv.y * themeNetLockedLength / themeNetLength
1279
+ );
1280
+ } else {
1281
+ themeNetCoordinate =
1282
+ themeNormal.y >= themeNormal.x && themeNormal.y >= themeNormal.z
1283
+ ? vThemeLocalPosition.xz
1284
+ : themeNormal.x >= themeNormal.z
1285
+ ? vThemeLocalPosition.zy
1286
+ : vThemeLocalPosition.xy;
1287
+ }
1288
+ vec2 themeNetCycle = themeNetCoordinate / ${safeSpacing.toFixed(6)};
1289
+ float themeNetMask = texture2D(themeNeonNetMap, themeNetCycle).a;
1290
+ diffuseColor.rgb = mix(themeNeonNetFillColor, diffuseColor.rgb, themeNetMask);
1291
+ diffuseColor.a *= mix(themeNeonNetFillOpacity, 1.0, themeNetMask);
1292
+ #include <alphatest_fragment>`,
1293
+ )
1294
+ .replace(
1295
+ '#include <emissivemap_fragment>',
1296
+ `#include <emissivemap_fragment>
1297
+ totalEmissiveRadiance *= themeNetMask;`,
1298
+ )
1299
+ }
1300
+ material.customProgramCacheKey = () => `neon-net-v8:${safeSpacing.toFixed(6)}:${safeLineWidth.toFixed(4)}`
1301
+ }
1302
+
1303
+ function createNeonNetTexture(lineWidth: number): THREE.DataTexture {
1304
+ const size = 128
1305
+ const samplesPerAxis = 4
1306
+ const data = new Uint8Array(size * size * 4)
1307
+
1308
+ for (let y = 0; y < size; y += 1) {
1309
+ for (let x = 0; x < size; x += 1) {
1310
+ let coveredSamples = 0
1311
+ for (let sampleY = 0; sampleY < samplesPerAxis; sampleY += 1) {
1312
+ for (let sampleX = 0; sampleX < samplesPerAxis; sampleX += 1) {
1313
+ const unitX = (x + (sampleX + 0.5) / samplesPerAxis) / size
1314
+ const unitY = (y + (sampleY + 0.5) / samplesPerAxis) / size
1315
+ const distanceToVerticalLine = Math.min(unitX, 1 - unitX)
1316
+ const distanceToHorizontalLine = Math.min(unitY, 1 - unitY)
1317
+ if (distanceToVerticalLine <= lineWidth / 2 || distanceToHorizontalLine <= lineWidth / 2) {
1318
+ coveredSamples += 1
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ const offset = (y * size + x) * 4
1324
+ data[offset] = 255
1325
+ data[offset + 1] = 255
1326
+ data[offset + 2] = 255
1327
+ data[offset + 3] = Math.round((coveredSamples / (samplesPerAxis * samplesPerAxis)) * 255)
1328
+ }
1329
+ }
1330
+
1331
+ const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
1332
+ texture.name = `theme-pattern:neon-net:${lineWidth.toFixed(4)}`
1333
+ texture.wrapS = THREE.RepeatWrapping
1334
+ texture.wrapT = THREE.RepeatWrapping
1335
+ texture.magFilter = THREE.LinearFilter
1336
+ texture.minFilter = THREE.LinearMipmapLinearFilter
1337
+ texture.generateMipmaps = true
1338
+ texture.needsUpdate = true
1339
+ return texture
1340
+ }
1341
+
1342
+ function disposeNeonNetTexture(material: THREE.Material): void {
1343
+ const texture = material.userData.themeNeonNetTexture
1344
+ if (texture instanceof THREE.Texture) texture.dispose()
1345
+ delete material.userData.themeNeonNetTexture
1346
+ delete material.userData.themeNeonNetTextureSignature
1347
+ }
1348
+
1349
+ function disposeWeatheredMetalResponseTextures(material: THREE.Material): void {
1350
+ const response = material.userData.themeWeatheredMetalResponse as
1351
+ { height?: THREE.Texture; roughness?: THREE.Texture; metalness?: THREE.Texture } | undefined
1352
+ response?.height?.dispose()
1353
+ response?.roughness?.dispose()
1354
+ response?.metalness?.dispose()
1355
+ if (material instanceof THREE.MeshStandardMaterial) {
1356
+ material.bumpMap = null
1357
+ material.roughnessMap = null
1358
+ material.metalnessMap = null
1359
+ }
1360
+ delete material.userData.themeWeatheredMetalResponse
1361
+ }
1362
+
1363
+ function assignMaterial(mesh: THREE.Mesh, material: THREE.Material, materialIndex?: number): void {
1364
+ if (materialIndex === undefined) {
1365
+ mesh.material = material
1366
+ return
1367
+ }
1368
+ const materials = Array.isArray(mesh.material) ? [...mesh.material] : [mesh.material]
1369
+ materials[materialIndex] = material
1370
+ mesh.material = materials
1371
+ }
1372
+
1373
+ function applyThemeVisibility(object: THREE.Object3D, themeId: string): void {
1374
+ const themeHidden = object.userData.themeHidden
1375
+ if (typeof themeHidden === 'string' && themeHidden === themeId) {
1376
+ object.visible = false
1377
+ return
1378
+ }
1379
+ if (Array.isArray(themeHidden) && themeHidden.includes(themeId)) {
1380
+ object.visible = false
1381
+ return
1382
+ }
1383
+ const themeOnly = object.userData.themeOnly
1384
+ if (typeof themeOnly === 'string') {
1385
+ object.visible = themeOnly === themeId
1386
+ return
1387
+ }
1388
+ if (Array.isArray(themeOnly)) {
1389
+ object.visible = themeOnly.includes(themeId)
1390
+ return
1391
+ }
1392
+ object.visible = true
1393
+ }
1394
+
1395
+ function hasThemeVisibility(object: THREE.Object3D): boolean {
1396
+ return (
1397
+ typeof object.userData.themeOnly === 'string' ||
1398
+ Array.isArray(object.userData.themeOnly) ||
1399
+ typeof object.userData.themeHidden === 'string' ||
1400
+ Array.isArray(object.userData.themeHidden)
1401
+ )
1402
+ }
1403
+
1404
+ function collectTextures(material: THREE.Material, textures: Set<THREE.Texture>): void {
1405
+ for (const value of Object.values(material)) {
1406
+ if (value instanceof THREE.Texture) textures.add(value)
1407
+ }
1408
+ const neonNetTexture = material.userData.themeNeonNetTexture
1409
+ if (neonNetTexture instanceof THREE.Texture) textures.add(neonNetTexture)
1410
+ }
1411
+
1412
+ function patternSignature(pattern: MaterialPatternRecipe | undefined): string {
1413
+ return pattern
1414
+ ? `${pattern.kind}:${pattern.primary}:${pattern.secondary}:${pattern.worldSize[0]}:${pattern.worldSize[1]}`
1415
+ : 'none'
1416
+ }
1417
+
1418
+ function mapSignature(recipe: MaterialRecipe): string {
1419
+ const worldSize = materialTextureWorldSize(recipe)
1420
+ return [
1421
+ patternSignature(recipe.pattern),
1422
+ recipe.textureUrl ?? 'procedural',
1423
+ recipe.emissiveTextureUrl ?? 'no-emissive',
1424
+ recipe.emissiveSurface ?? 'all-emissive-surfaces',
1425
+ recipe.normalTextureUrl ?? 'no-normal',
1426
+ recipe.heightTextureUrl ?? 'no-height',
1427
+ recipe.roughnessTextureUrl ?? 'no-roughness',
1428
+ recipe.aoTextureUrl ?? 'no-ao',
1429
+ worldSize[0],
1430
+ worldSize[1],
1431
+ recipe.visualRelief?.scale ?? 'no-relief',
1432
+ recipe.visualRelief?.bias ?? 'no-relief-bias',
1433
+ recipe.visualRelief?.targetEdgeLength ?? 'no-relief-edge',
1434
+ ].join(':')
1435
+ }
1436
+
1437
+ function materialTextureWorldSize(recipe: MaterialRecipe): readonly [number, number] {
1438
+ return recipe.textureWorldSize ?? recipe.pattern?.worldSize ?? [2, 2]
1439
+ }
1440
+
1441
+ /**
1442
+ * The browser uses the authored image scale while server-side tests and other
1443
+ * non-DOM renderers use the procedural pattern fallback. Keep the shader scale
1444
+ * paired with the texture that was actually created; otherwise albedo repeats
1445
+ * at one physical size while the metric shader samples it at another.
1446
+ */
1447
+ function activeMaterialTextureWorldSize(recipe: MaterialRecipe): readonly [number, number] {
1448
+ if (recipe.textureUrl && typeof document === 'undefined' && recipe.pattern) {
1449
+ return recipe.pattern.worldSize
1450
+ }
1451
+ return materialTextureWorldSize(recipe)
1452
+ }
1453
+
1454
+ function createRecipeTexture(recipe: MaterialRecipe): THREE.Texture | null {
1455
+ if (recipe.textureUrl && typeof document !== 'undefined') {
1456
+ const worldSize = materialTextureWorldSize(recipe)
1457
+ const texture = createThemeMaterialTexture(
1458
+ recipe.textureUrl,
1459
+ `albedo:${worldSize[0]}:${worldSize[1]}`,
1460
+ )
1461
+ texture.name = `theme-texture:${recipe.textureUrl}`
1462
+ texture.wrapS = THREE.RepeatWrapping
1463
+ texture.wrapT = THREE.RepeatWrapping
1464
+ texture.repeat.set(1 / Math.max(0.001, worldSize[0]), 1 / Math.max(0.001, worldSize[1]))
1465
+ texture.colorSpace = THREE.SRGBColorSpace
1466
+ texture.magFilter = THREE.LinearFilter
1467
+ texture.minFilter = THREE.LinearMipmapLinearFilter
1468
+ texture.generateMipmaps = true
1469
+ texture.anisotropy = 4
1470
+ return texture
1471
+ }
1472
+
1473
+ return recipe.pattern && recipe.pattern.kind !== 'neon-net' && recipe.pattern.kind !== 'holographic-hover-grid'
1474
+ ? createPatternTexture(recipe.pattern)
1475
+ : null
1476
+ }
1477
+
1478
+ function createRecipeResponseTexture(
1479
+ url: string | undefined,
1480
+ recipe: MaterialRecipe,
1481
+ role: 'normal' | 'height' | 'roughness' | 'ao' | 'emissive',
1482
+ ): THREE.Texture | null {
1483
+ if (!url || typeof document === 'undefined') return null
1484
+ const worldSize = materialTextureWorldSize(recipe)
1485
+ const texture = createThemeMaterialTexture(
1486
+ url,
1487
+ `${role}:${worldSize[0]}:${worldSize[1]}`,
1488
+ )
1489
+ texture.name = `theme-${role}-texture:${url}`
1490
+ texture.wrapS = THREE.RepeatWrapping
1491
+ texture.wrapT = THREE.RepeatWrapping
1492
+ texture.repeat.set(1 / Math.max(0.001, worldSize[0]), 1 / Math.max(0.001, worldSize[1]))
1493
+ texture.colorSpace = role === 'emissive' ? THREE.SRGBColorSpace : THREE.NoColorSpace
1494
+ texture.magFilter = THREE.LinearFilter
1495
+ texture.minFilter = THREE.LinearMipmapLinearFilter
1496
+ texture.generateMipmaps = true
1497
+ texture.anisotropy = 4
1498
+ if (role === 'ao') texture.channel = 0
1499
+ return texture
1500
+ }
1501
+
1502
+ function createPatternTexture(pattern: MaterialPatternRecipe): THREE.DataTexture {
1503
+ const size =
1504
+ pattern.kind === 'railroad-planks' || pattern.kind === 'bridge-planks' || pattern.kind === 'weathered-sheet-metal'
1505
+ ? 256
1506
+ : pattern.kind === 'fence-timber'
1507
+ ? 128
1508
+ : 64
1509
+ const data = new Uint8Array(size * size * 4)
1510
+ const primary = new THREE.Color(pattern.primary)
1511
+ const secondary = new THREE.Color(pattern.secondary)
1512
+ const iron = new THREE.Color('#343338')
1513
+ const ironHighlight = new THREE.Color('#c0b7a6')
1514
+
1515
+ for (let y = 0; y < size; y += 1) {
1516
+ for (let x = 0; x < size; x += 1) {
1517
+ if (pattern.kind === 'railroad-planks' || pattern.kind === 'bridge-planks') {
1518
+ writeBridgePlankPixel(
1519
+ data,
1520
+ (y * size + x) * 4,
1521
+ x,
1522
+ y,
1523
+ size,
1524
+ primary,
1525
+ secondary,
1526
+ iron,
1527
+ ironHighlight,
1528
+ pattern.kind === 'railroad-planks',
1529
+ )
1530
+ continue
1531
+ }
1532
+ if (pattern.kind === 'weathered-sheet-metal') {
1533
+ writeWeatheredSheetMetalPixel(data, (y * size + x) * 4, x, y, size, primary, secondary)
1534
+ continue
1535
+ }
1536
+ const value = patternValue(pattern.kind, x, y, size)
1537
+ const color = primary.clone().lerp(secondary, value)
1538
+ if (pattern.kind === 'fence-timber') color.convertLinearToSRGB()
1539
+ const offset = (y * size + x) * 4
1540
+ data[offset] = Math.round(color.r * 255)
1541
+ data[offset + 1] = Math.round(color.g * 255)
1542
+ data[offset + 2] = Math.round(color.b * 255)
1543
+ data[offset + 3] = pattern.kind === 'neon-net' && value < 0.5 ? 0 : 255
1544
+ }
1545
+ }
1546
+
1547
+ const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
1548
+ texture.name = `theme-pattern:${pattern.kind}`
1549
+ texture.wrapS = THREE.RepeatWrapping
1550
+ texture.wrapT = THREE.RepeatWrapping
1551
+ texture.repeat.set(1 / Math.max(0.001, pattern.worldSize[0]), 1 / Math.max(0.001, pattern.worldSize[1]))
1552
+ texture.colorSpace = THREE.SRGBColorSpace
1553
+ texture.magFilter = pattern.kind === 'neon-net' ? THREE.NearestFilter : THREE.LinearFilter
1554
+ texture.minFilter = pattern.kind === 'neon-net' ? THREE.NearestFilter : THREE.LinearMipmapLinearFilter
1555
+ texture.generateMipmaps = pattern.kind !== 'neon-net'
1556
+ texture.needsUpdate = true
1557
+ return texture
1558
+ }
1559
+
1560
+ interface WeatheredMetalSample {
1561
+ seam: number
1562
+ rivet: number
1563
+ rust: number
1564
+ scratch: number
1565
+ variation: number
1566
+ }
1567
+
1568
+ function weatheredMetalSample(x: number, y: number, size: number): WeatheredMetalSample {
1569
+ const unitX = x / size
1570
+ const unitY = y / size
1571
+ const edgeX = Math.min(unitX, 1 - unitX)
1572
+ const edgeY = Math.min(unitY, 1 - unitY)
1573
+ const seamDistance = Math.min(edgeX, edgeY)
1574
+ const seam = 1 - THREE.MathUtils.smoothstep(seamDistance, 0.012, 0.036)
1575
+ const rivetCenters = [0.075, 0.925]
1576
+ let rivet = 0
1577
+ for (const centerX of rivetCenters) {
1578
+ for (const centerY of rivetCenters) {
1579
+ const distance = Math.hypot(unitX - centerX, unitY - centerY)
1580
+ rivet = Math.max(rivet, 1 - THREE.MathUtils.smoothstep(distance, 0.018, 0.035))
1581
+ }
1582
+ }
1583
+ const coarse = pseudoNoise(Math.floor(x / 7) * 11 + 5, Math.floor(y / 7) * 13 + 9)
1584
+ const fine = pseudoNoise(x * 5 + 19, y * 7 + 31)
1585
+ const seamOxidation = seam * (0.24 + pseudoNoise(x * 3 + 7, y * 2 + 17) * 0.62)
1586
+ const patch = coarse > 0.69 ? THREE.MathUtils.smoothstep(coarse, 0.69, 0.94) : 0
1587
+ const rust = THREE.MathUtils.clamp(seamOxidation * 0.82 + patch * 0.76 + (fine > 0.975 ? 0.5 : 0), 0, 1)
1588
+ const diagonal = Math.abs(((unitX * 2.7 + unitY * 0.42 + 0.21) % 1) - 0.5)
1589
+ const scratch = diagonal < 0.006 || Math.abs(((unitX * 0.7 - unitY * 3.1 + 0.37) % 1) - 0.5) < 0.004 ? 1 : 0
1590
+ return {
1591
+ seam,
1592
+ rivet,
1593
+ rust,
1594
+ scratch,
1595
+ variation: pseudoNoise(x * 2 + 3, y * 5 + 11),
1596
+ }
1597
+ }
1598
+
1599
+ function writeWeatheredSheetMetalPixel(
1600
+ data: Uint8Array,
1601
+ offset: number,
1602
+ x: number,
1603
+ y: number,
1604
+ size: number,
1605
+ steel: THREE.Color,
1606
+ oxide: THREE.Color,
1607
+ ): void {
1608
+ const sample = weatheredMetalSample(x, y, size)
1609
+ const seamColor = new THREE.Color('#242728')
1610
+ const rivetColor = new THREE.Color('#d4dad7')
1611
+ const grime = 0.09 + sample.variation * 0.15
1612
+ const color = steel.clone().multiplyScalar(1 - grime)
1613
+ color.lerp(oxide, sample.rust * 0.88)
1614
+ color.lerp(seamColor, sample.seam * 0.72)
1615
+ color.lerp(rivetColor, sample.rivet * 0.94)
1616
+ if (sample.scratch > 0 && sample.rust < 0.5) color.lerp(rivetColor, 0.44)
1617
+ data[offset] = Math.round(THREE.MathUtils.clamp(color.r, 0, 1) * 255)
1618
+ data[offset + 1] = Math.round(THREE.MathUtils.clamp(color.g, 0, 1) * 255)
1619
+ data[offset + 2] = Math.round(THREE.MathUtils.clamp(color.b, 0, 1) * 255)
1620
+ data[offset + 3] = 255
1621
+ }
1622
+
1623
+ function createWeatheredMetalResponseTextures(pattern: MaterialPatternRecipe): {
1624
+ height: THREE.DataTexture
1625
+ roughness: THREE.DataTexture
1626
+ metalness: THREE.DataTexture
1627
+ } {
1628
+ const size = 256
1629
+ const height = new Uint8Array(size * size * 4)
1630
+ const roughness = new Uint8Array(size * size * 4)
1631
+ const metalness = new Uint8Array(size * size * 4)
1632
+ const writeScalar = (target: Uint8Array, pixel: number, value: number): void => {
1633
+ const offset = pixel * 4
1634
+ target[offset] = value
1635
+ target[offset + 1] = value
1636
+ target[offset + 2] = value
1637
+ target[offset + 3] = 255
1638
+ }
1639
+ for (let y = 0; y < size; y += 1) {
1640
+ for (let x = 0; x < size; x += 1) {
1641
+ const sample = weatheredMetalSample(x, y, size)
1642
+ const offset = y * size + x
1643
+ writeScalar(
1644
+ height,
1645
+ offset,
1646
+ Math.round(
1647
+ THREE.MathUtils.clamp(0.52 - sample.seam * 0.38 + sample.rivet * 0.48 + sample.rust * 0.08, 0, 1) * 255,
1648
+ ),
1649
+ )
1650
+ writeScalar(
1651
+ roughness,
1652
+ offset,
1653
+ Math.round(
1654
+ THREE.MathUtils.clamp(0.38 + sample.variation * 0.18 + sample.rust * 0.48 - sample.rivet * 0.2, 0.08, 1) *
1655
+ 255,
1656
+ ),
1657
+ )
1658
+ writeScalar(
1659
+ metalness,
1660
+ offset,
1661
+ Math.round(
1662
+ THREE.MathUtils.clamp(0.96 - sample.rust * 0.9 - sample.seam * 0.12 + sample.rivet * 0.04, 0.03, 1) * 255,
1663
+ ),
1664
+ )
1665
+ }
1666
+ }
1667
+ const makeTexture = (data: Uint8Array, name: string): THREE.DataTexture => {
1668
+ // Three's roughness and metalness chunks read green and blue respectively,
1669
+ // so repeat the scalar into every RGB channel instead of using RedFormat.
1670
+ const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
1671
+ texture.name = name
1672
+ texture.wrapS = THREE.RepeatWrapping
1673
+ texture.wrapT = THREE.RepeatWrapping
1674
+ texture.repeat.set(1 / Math.max(0.001, pattern.worldSize[0]), 1 / Math.max(0.001, pattern.worldSize[1]))
1675
+ texture.magFilter = THREE.LinearFilter
1676
+ texture.minFilter = THREE.LinearMipmapLinearFilter
1677
+ texture.generateMipmaps = true
1678
+ texture.needsUpdate = true
1679
+ return texture
1680
+ }
1681
+ return {
1682
+ height: makeTexture(height, 'theme-response:weathered-sheet-metal:height'),
1683
+ roughness: makeTexture(roughness, 'theme-response:weathered-sheet-metal:roughness'),
1684
+ metalness: makeTexture(metalness, 'theme-response:weathered-sheet-metal:metalness'),
1685
+ }
1686
+ }
1687
+
1688
+ function writeBridgePlankPixel(
1689
+ data: Uint8Array,
1690
+ offset: number,
1691
+ x: number,
1692
+ y: number,
1693
+ size: number,
1694
+ timber: THREE.Color,
1695
+ darkTimber: THREE.Color,
1696
+ iron: THREE.Color,
1697
+ ironHighlight: THREE.Color,
1698
+ includeRailroad: boolean,
1699
+ ): void {
1700
+ const unitX = x / size
1701
+ const unitY = y / size
1702
+ const rowCount = 8
1703
+ const row = Math.floor(unitY * rowCount)
1704
+ const rowProgress = (unitY * rowCount) % 1
1705
+ const centeredX = Math.min(unitX, 1 - unitX)
1706
+ const railDistance = Math.abs(centeredX - 0.15)
1707
+ const boardVariation = (pseudoNoise(row * 31 + 7, 13) - 0.5) * 0.16
1708
+ const broadGrain = Math.abs(Math.sin(unitX * 115 + Math.sin(unitY * 19) * 2.1))
1709
+ const fineGrain = Math.abs(Math.sin(unitX * 280 + unitY * 11))
1710
+ const seam = Math.min(rowProgress, 1 - rowProgress)
1711
+ const plankGap = seam < 0.035
1712
+ const edgeDarkening = seam < 0.075 ? 0.28 : 0
1713
+ const timberMix = THREE.MathUtils.clamp(
1714
+ 0.08 + broadGrain * 0.13 + fineGrain * 0.045 + pseudoNoise(x, y) * 0.055 + boardVariation + edgeDarkening,
1715
+ 0,
1716
+ 0.92,
1717
+ )
1718
+
1719
+ let red = THREE.MathUtils.lerp(timber.r, darkTimber.r, timberMix)
1720
+ let green = THREE.MathUtils.lerp(timber.g, darkTimber.g, timberMix)
1721
+ let blue = THREE.MathUtils.lerp(timber.b, darkTimber.b, timberMix)
1722
+
1723
+ if (includeRailroad && railDistance < 0.016) {
1724
+ const highlight = 1 - railDistance / 0.016
1725
+ const railColorMix = 0.14 + Math.pow(highlight, 2.4) * 0.86
1726
+ red = THREE.MathUtils.lerp(iron.r, ironHighlight.r, railColorMix)
1727
+ green = THREE.MathUtils.lerp(iron.g, ironHighlight.g, railColorMix)
1728
+ blue = THREE.MathUtils.lerp(iron.b, ironHighlight.b, railColorMix)
1729
+ } else if (includeRailroad && railDistance < 0.026) {
1730
+ red *= 0.58
1731
+ green *= 0.58
1732
+ blue *= 0.58
1733
+ }
1734
+
1735
+ const spikeRow = Math.min(Math.abs(rowProgress - 0.22), Math.abs(rowProgress - 0.78)) < 0.027
1736
+ if (includeRailroad && spikeRow && railDistance >= 0.016 && railDistance < 0.045) {
1737
+ red = ironHighlight.r * 0.72
1738
+ green = ironHighlight.g * 0.72
1739
+ blue = ironHighlight.b * 0.72
1740
+ }
1741
+
1742
+ data[offset] = Math.round(THREE.MathUtils.clamp(red, 0, 1) * 255)
1743
+ data[offset + 1] = Math.round(THREE.MathUtils.clamp(green, 0, 1) * 255)
1744
+ data[offset + 2] = Math.round(THREE.MathUtils.clamp(blue, 0, 1) * 255)
1745
+ data[offset + 3] = plankGap && (!includeRailroad || railDistance > 0.045) ? 0 : 255
1746
+ }
1747
+
1748
+ function patternValue(kind: MaterialPatternRecipe['kind'], x: number, y: number, size: number): number {
1749
+ const unitX = x / size
1750
+ const unitY = y / size
1751
+ const hash = pseudoNoise(x, y)
1752
+
1753
+ switch (kind) {
1754
+ case 'wood': {
1755
+ const grain = Math.sin(unitX * 46 + Math.sin(unitY * 12) * 2.2)
1756
+ const plank = y % 16 < 1 || x % 31 < 1 ? 0.82 : 0
1757
+ return THREE.MathUtils.clamp(0.12 + Math.abs(grain) * 0.18 + hash * 0.08 + plank, 0, 1)
1758
+ }
1759
+ case 'rough-wood': {
1760
+ const longWarp = Math.sin(unitY * 11 + Math.sin(unitY * 4.5) * 1.8)
1761
+ const broadGrain = Math.abs(Math.sin(unitX * 25 + longWarp * 2.8))
1762
+ const fineGrain = Math.abs(Math.sin(unitX * 63 + unitY * 6 + Math.sin(unitY * 18) * 1.6))
1763
+ const knotA = Math.hypot((unitX - 0.28) * 1.6, unitY - 0.3)
1764
+ const knotB = Math.hypot((unitX - 0.74) * 1.8, unitY - 0.76)
1765
+ const knotRings =
1766
+ Math.exp(-knotA * 12) * Math.abs(Math.sin(knotA * 82)) + Math.exp(-knotB * 13) * Math.abs(Math.sin(knotB * 94))
1767
+ const crackA = Math.abs(unitX - (0.16 + Math.sin(unitY * 17) * 0.022)) < 0.012 ? 0.58 : 0
1768
+ const crackB =
1769
+ unitY > 0.42 && unitY < 0.84 && Math.abs(unitX - (0.84 + Math.sin(unitY * 23 + 0.6) * 0.019)) < 0.01 ? 0.52 : 0
1770
+ return THREE.MathUtils.clamp(
1771
+ 0.04 + broadGrain * 0.3 + fineGrain * 0.1 + knotRings * 0.62 + crackA + crackB + hash * 0.1,
1772
+ 0,
1773
+ 1,
1774
+ )
1775
+ }
1776
+ case 'railroad-planks':
1777
+ case 'bridge-planks':
1778
+ return 0
1779
+ case 'fence-timber': {
1780
+ const longGrain = Math.abs(Math.sin(unitX * 33 + Math.sin(unitY * 7.5) * 2.2))
1781
+ const fineGrain = Math.abs(Math.sin(unitX * 81 + unitY * 4.5))
1782
+ const knotA = Math.hypot((unitX - 0.27) * 2.2, unitY - 0.34)
1783
+ const knotB = Math.hypot((unitX - 0.73) * 2.4, unitY - 0.78)
1784
+ const knots =
1785
+ Math.exp(-knotA * 16) * Math.abs(Math.sin(knotA * 95)) + Math.exp(-knotB * 17) * Math.abs(Math.sin(knotB * 104))
1786
+ const sawVariation = Math.abs(Math.sin(unitY * 17 + unitX * 3.2)) * 0.035
1787
+ return THREE.MathUtils.clamp(
1788
+ 0.045 + longGrain * 0.34 + fineGrain * 0.075 + knots * 0.62 + sawVariation + hash * 0.045,
1789
+ 0,
1790
+ 0.94,
1791
+ )
1792
+ }
1793
+ case 'braided-rope': {
1794
+ const diagonalA = Math.sin((unitX * 7.5 + unitY * 25) * Math.PI)
1795
+ const diagonalB = Math.sin((-unitX * 7.5 + unitY * 25) * Math.PI)
1796
+ const braid = Math.pow(Math.max(diagonalA, diagonalB) * 0.5 + 0.5, 2.2)
1797
+ const strandShadow = Math.abs(Math.sin(unitY * Math.PI * 50))
1798
+ return THREE.MathUtils.clamp(0.08 + braid * 0.62 + strandShadow * 0.13 + hash * 0.07, 0, 0.96)
1799
+ }
1800
+ case 'sand': {
1801
+ const dune = Math.sin(unitX * 8.5 + Math.sin(unitY * 5.2) * 1.7) * 0.5 + 0.5
1802
+ const ripple = Math.abs(Math.sin(unitX * 39 + unitY * 8 + Math.sin(unitY * 14) * 1.2))
1803
+ const grit = pseudoNoise(x * 7 + 3, y * 11 + 5)
1804
+ const pebble = grit > 0.965 ? 0.48 : 0
1805
+ return THREE.MathUtils.clamp(0.06 + dune * 0.2 + ripple * 0.075 + grit * 0.09 + pebble, 0, 1)
1806
+ }
1807
+ case 'rock': {
1808
+ const ridgeA = Math.abs(Math.sin(unitX * 13 + unitY * 7 + Math.sin(unitY * 11) * 2.1))
1809
+ const ridgeB = Math.abs(Math.sin(unitX * 29 - unitY * 17 + Math.sin(unitX * 9) * 1.3))
1810
+ const strata = Math.abs(Math.sin(unitY * 22 + Math.sin(unitX * 5) * 2.4))
1811
+ const fissure = ridgeB > 0.965 ? 0.54 : 0
1812
+ return THREE.MathUtils.clamp(0.08 + ridgeA * 0.24 + strata * 0.16 + hash * 0.12 + fissure, 0, 1)
1813
+ }
1814
+ case 'gunmetal-silver': {
1815
+ const brushed = Math.abs(Math.sin(unitX * 170 + Math.sin(unitY * 12) * 0.9))
1816
+ const diagonalScratch = Math.abs(((unitX * 1.8 + unitY * 0.38 + 0.17) % 1) - 0.5)
1817
+ const fineScratch = diagonalScratch < 0.009 ? 0.28 : 0
1818
+ const weatherFleck = pseudoNoise(x * 13 + 17, y * 19 + 29) > 0.982 ? 0.34 : 0
1819
+ const softPatina = Math.max(0, Math.sin(unitX * 9.3 + unitY * 7.1) - 0.84) * 0.22
1820
+ return THREE.MathUtils.clamp(
1821
+ 0.035 + brushed * 0.085 + hash * 0.055 + fineScratch + weatherFleck + softPatina,
1822
+ 0,
1823
+ 0.52,
1824
+ )
1825
+ }
1826
+ case 'weathered-sheet-metal':
1827
+ return 0
1828
+ case 'painted-wood': {
1829
+ const plankSeam = y % 21 < 1 ? 0.72 : 0
1830
+ const broadStroke = Math.abs(Math.sin(unitX * 18 + Math.sin(unitY * 8) * 2.4))
1831
+ const fineStroke = Math.abs(Math.sin(unitX * 49 + unitY * 5.5))
1832
+ const knot = Math.hypot((unitX - 0.68) * 1.9, unitY - 0.38)
1833
+ const knotRing = Math.exp(-knot * 11) * Math.abs(Math.sin(knot * 66))
1834
+ return THREE.MathUtils.clamp(
1835
+ 0.08 + broadStroke * 0.2 + fineStroke * 0.065 + knotRing * 0.42 + plankSeam + hash * 0.1,
1836
+ 0,
1837
+ 1,
1838
+ )
1839
+ }
1840
+ case 'painted-stone': {
1841
+ const cellX = Math.floor(x / 12)
1842
+ const cellY = Math.floor(y / 10)
1843
+ const mortar = x % 12 < 1 || (y % 10 < 1 && (cellX + cellY) % 2 === 0) ? 0.72 : 0
1844
+ const wash = Math.abs(Math.sin(unitX * 7 + unitY * 9)) * 0.12
1845
+ const moss = pseudoNoise(cellX * 5, cellY * 7) > 0.72 ? 0.24 : 0
1846
+ return THREE.MathUtils.clamp(0.08 + mortar + wash + moss + hash * 0.08, 0, 1)
1847
+ }
1848
+ case 'watercolor': {
1849
+ const cloudA = Math.sin(unitX * 7.2 + Math.sin(unitY * 4.1) * 1.6)
1850
+ const cloudB = Math.sin(unitY * 9.4 + Math.sin(unitX * 5.7) * 1.4)
1851
+ return THREE.MathUtils.clamp(0.22 + (cloudA + cloudB + 2) * 0.105 + hash * 0.06, 0, 1)
1852
+ }
1853
+ case 'panels': {
1854
+ const seam = x % 16 < 1 || y % 16 < 1 ? 0.78 : 0
1855
+ const inset = x % 16 === 3 && y % 16 === 3 ? 0.5 : 0
1856
+ return THREE.MathUtils.clamp(0.06 + hash * 0.04 + seam + inset, 0, 1)
1857
+ }
1858
+ case 'grid': {
1859
+ const line = x % 16 < 1 || y % 16 < 1 ? 0.9 : 0
1860
+ return THREE.MathUtils.clamp(0.05 + line + hash * 0.03, 0, 1)
1861
+ }
1862
+ case 'neon-net':
1863
+ return x % 16 < 2 || y % 16 < 2 ? 1 : 0
1864
+ case 'holographic-hover-grid':
1865
+ return 0
1866
+ case 'waves': {
1867
+ const wave = Math.abs(Math.sin(unitX * Math.PI * 5 + Math.sin(unitY * Math.PI * 3)))
1868
+ return 0.08 + wave * 0.3
1869
+ }
1870
+ case 'bark': {
1871
+ const ridge = Math.abs(Math.sin(unitX * 24 + Math.sin(unitY * 10) * 1.7))
1872
+ return THREE.MathUtils.clamp(0.12 + ridge * 0.3 + hash * 0.14, 0, 1)
1873
+ }
1874
+ case 'jungle-vines': {
1875
+ const diagonalA = Math.abs(((x + y * 0.62) % 22) - 11)
1876
+ const diagonalB = Math.abs(((x - y * 0.54 + 96) % 29) - 14.5)
1877
+ const vine = diagonalA < 1.15 || diagonalB < 0.9 ? 0.78 : 0
1878
+ const nodeX = (x + Math.floor(y / 12) * 7) % 24
1879
+ const nodeY = y % 12
1880
+ const leaf = Math.pow((nodeX - 12) / 5.2, 2) + Math.pow((nodeY - 6) / 2.7, 2) < 1 ? 0.52 : 0
1881
+ const vein = leaf > 0 && Math.abs(nodeY - 6) < 0.7 ? 0.18 : 0
1882
+ return THREE.MathUtils.clamp(0.035 + hash * 0.045 + vine + leaf + vein, 0, 1)
1883
+ }
1884
+ case 'leaf-canopy': {
1885
+ const cellSize = 12
1886
+ const cellX = Math.floor(x / cellSize)
1887
+ const cellY = Math.floor(y / cellSize)
1888
+ const localX = (x % cellSize) / cellSize
1889
+ const localY = (y % cellSize) / cellSize
1890
+ const centerX = 0.24 + pseudoNoise(cellX * 19 + 5, cellY * 7 + 3) * 0.52
1891
+ const centerY = 0.2 + pseudoNoise(cellX * 11 + 13, cellY * 17 + 9) * 0.58
1892
+ const angle = pseudoNoise(cellX * 23 + 1, cellY * 29 + 4) * Math.PI
1893
+ const deltaX = localX - centerX
1894
+ const deltaY = localY - centerY
1895
+ const rotatedX = deltaX * Math.cos(angle) - deltaY * Math.sin(angle)
1896
+ const rotatedY = deltaX * Math.sin(angle) + deltaY * Math.cos(angle)
1897
+ const leafShape = Math.abs(rotatedX) * 1.45 + Math.abs(rotatedY) * 3.4
1898
+ const leaf = leafShape < 0.72 ? 0.58 : 0
1899
+ const vein = leaf > 0 && Math.abs(rotatedX) < 0.035 ? 0.24 : 0
1900
+ const overlap = Math.max(0, Math.sin(unitX * 13 + unitY * 17)) * 0.16
1901
+ return THREE.MathUtils.clamp(0.08 + hash * 0.08 + overlap + leaf + vein, 0, 1)
1902
+ }
1903
+ case 'moss-bark': {
1904
+ const ridge = Math.abs(Math.sin(unitX * 25 + Math.sin(unitY * 8.5) * 2.1))
1905
+ const broadWarp = Math.sin(unitX * 6.5 + unitY * 4.2) + Math.sin(unitY * 7.4 - unitX * 3.8)
1906
+ const mossPatch = broadWarp > 0.72 ? 0.58 : broadWarp > 0.32 ? 0.28 : 0
1907
+ return THREE.MathUtils.clamp(0.06 + ridge * 0.22 + mossPatch + hash * 0.1, 0, 1)
1908
+ }
1909
+ case 'ancient-bricks': {
1910
+ const brickHeight = 10
1911
+ const brickWidth = 22
1912
+ const row = Math.floor(y / brickHeight)
1913
+ const shiftedX = (x + (row % 2) * (brickWidth / 2)) % brickWidth
1914
+ const mortar = shiftedX < 1.4 || y % brickHeight < 1.4 ? 0.9 : 0
1915
+ const chippedEdge =
1916
+ (shiftedX < 3 || shiftedX > brickWidth - 3 || y % brickHeight < 3) && pseudoNoise(x * 3 + row, y * 5) > 0.72
1917
+ ? 0.28
1918
+ : 0
1919
+ const sunWash = Math.abs(Math.sin(unitX * 8.5 + unitY * 5.2)) * 0.11
1920
+ return THREE.MathUtils.clamp(0.06 + mortar + chippedEdge + sunWash + hash * 0.07, 0, 1)
1921
+ }
1922
+ case 'stick-fence': {
1923
+ const stickWidth = 7
1924
+ const stickOffset = Math.floor(x / stickWidth)
1925
+ const localX = x % stickWidth
1926
+ const seam = localX === 0 || localX === stickWidth - 1 ? 0.78 : 0
1927
+ const irregularEdge = (y + stickOffset * 3) % size < (stickOffset % 3) + 1 ? 0.26 : 0
1928
+ const ropeBand = (y >= 17 && y <= 20) || (y >= 43 && y <= 46) ? 0.34 : 0
1929
+ const grain = Math.abs(Math.sin(unitY * 42 + stickOffset * 1.7)) * 0.12
1930
+ return THREE.MathUtils.clamp(0.08 + seam + irregularEdge + ropeBand + grain + hash * 0.08, 0, 1)
1931
+ }
1932
+ case 'debug':
1933
+ return (x + y) % 8 < 4 ? 0.1 : 0.85
1934
+ }
1935
+ }
1936
+
1937
+ function pseudoNoise(x: number, y: number): number {
1938
+ const value = Math.sin(x * 12.9898 + y * 78.233) * 43_758.5453
1939
+ return value - Math.floor(value)
1940
+ }
1941
+
1942
+ function disposeMaterial(material: THREE.Material): void {
1943
+ const textures = new Set<THREE.Texture>()
1944
+ collectTextures(material, textures)
1945
+ material.dispose()
1946
+ textures.forEach(disposeTexture)
1947
+ }
1948
+
1949
+ function disposeTexture(texture: THREE.Texture | null | undefined): void {
1950
+ if (!texture) return
1951
+ if (releaseThemeMaterialTexture(texture)) texture.dispose()
1952
+ }