@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,760 @@
1
+ import * as THREE from 'three'
2
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
3
+
4
+ import { STATIC_SHADOW_PROXY_LAYER } from '../../../three/scene-lighting'
5
+ import { StaticTrackBatchedMesh } from '../../../three/static-track-batched-mesh'
6
+ import { preserveGeometryAttributeAliases } from '../../../three/geometry-attribute-aliases'
7
+ import { batchTrackConnectorMarkers } from '../../../three/track-connector-batch'
8
+
9
+ const DYNAMIC_SHADOW_ROOT = 'trackDynamicShadowRoot'
10
+ const STATIC_COLOR_BATCH_SOURCE = 'trackStaticColorBatchSource'
11
+
12
+ interface StaticColorBatchSource {
13
+ geometry: THREE.BufferGeometry
14
+ material: THREE.Material
15
+ matrix: THREE.Matrix4
16
+ mesh: THREE.Mesh
17
+ }
18
+
19
+ interface StaticColorBatchBinding {
20
+ batch: THREE.BatchedMesh
21
+ instanceId: number
22
+ source: StaticColorBatchSource
23
+ }
24
+
25
+ export interface StaticTrackColorBatch {
26
+ allocatedGeometryBytes: number
27
+ connectorBatchCount: number
28
+ connectorSourceCount: number
29
+ updateConnectorVisibility(): void
30
+ batchCount: number
31
+ candidateCount: number
32
+ rejectionCounts: Readonly<Record<string, number>>
33
+ releasedSourceGeometryBytes: number
34
+ releasedSourceGeometryCount: number
35
+ sourceCount: number
36
+ detachSources(sourceRoots: readonly THREE.Object3D[]): number
37
+ releaseSourceGeometryCpuData(): { bytes: number; geometries: number }
38
+ updateTransforms(): void
39
+ dispose(): void
40
+ }
41
+
42
+ interface ShadowGeometrySource {
43
+ mesh: THREE.Mesh
44
+ matrix: THREE.Matrix4
45
+ position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute
46
+ index?: THREE.BufferAttribute
47
+ start: number
48
+ count: number
49
+ side: THREE.Side
50
+ }
51
+
52
+ /** Marks a piece subtree whose transforms change while the simulation runs. */
53
+ export function markDynamicTrackShadowRoot(root: THREE.Object3D): void {
54
+ root.userData[DYNAMIC_SHADOW_ROOT] = true
55
+ }
56
+
57
+ /** Keeps race shadow maps limited to static track and environment geometry. */
58
+ export function disableDynamicTrackShadowCasters(root: THREE.Object3D): void {
59
+ root.traverse(object => {
60
+ if (object instanceof THREE.Mesh && hasDynamicShadowAncestor(object, root)) object.castShadow = false
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Consolidates compatible opaque meshes from several immutable Track Lab
66
+ * pieces without flattening their culling bounds into one map-sized mesh.
67
+ * `BatchedMesh` retains per-object frustum culling while issuing one draw per
68
+ * compatible material/attribute group. The original meshes stay in the scene
69
+ * graph (hidden from rendering) so editor ray-picking, bounds, theme bindings,
70
+ * and piece-local disposal keep their existing ownership.
71
+ *
72
+ * Run this after static shadow proxies have been built: the color batches do
73
+ * not cast shadows because the exact three-side shadow streams already do so
74
+ * with fewer submissions.
75
+ */
76
+ export function batchStaticTrackColorMeshes(
77
+ root: THREE.Group,
78
+ sourceRoots: readonly THREE.Object3D[],
79
+ options: { multiDrawSupported?: boolean; maxAdditionalGeometryBytes?: number } = {},
80
+ ): StaticTrackColorBatch {
81
+ root.updateWorldMatrix(true, true)
82
+ const rootInverse = root.matrixWorld.clone().invert()
83
+ const updatedMatrix = new THREE.Matrix4()
84
+ const sources: StaticColorBatchSource[] = []
85
+ const rejectionCounts: Record<string, number> = {}
86
+ const geometryConsumers = collectGeometryConsumers(root)
87
+ const connectors = batchTrackConnectorMarkers(root, sourceRoots)
88
+ for (const sourceRoot of sourceRoots) {
89
+ sourceRoot.traverse(object => {
90
+ if (!(object instanceof THREE.Mesh)) return
91
+ const rejection = staticColorBatchRejectionReason(object, sourceRoot)
92
+ if (rejection) {
93
+ rejectionCounts[rejection] = (rejectionCounts[rejection] ?? 0) + 1
94
+ return
95
+ }
96
+ sources.push({
97
+ geometry: object.geometry,
98
+ material: object.material,
99
+ matrix: rootInverse.clone().multiply(object.matrixWorld),
100
+ mesh: object,
101
+ })
102
+ })
103
+ }
104
+
105
+ const groups = new Map<string, StaticColorBatchSource[]>()
106
+ for (const source of sources) {
107
+ const key = [
108
+ source.material.uuid,
109
+ geometryAttributeSignature(source.geometry),
110
+ source.geometry.index ? 'indexed' : 'non-indexed',
111
+ source.mesh.receiveShadow ? 1 : 0,
112
+ source.mesh.layers.mask,
113
+ source.mesh.renderOrder,
114
+ ].join('|')
115
+ const group = groups.get(key) ?? []
116
+ group.push(source)
117
+ groups.set(key, group)
118
+ }
119
+
120
+ const batches: THREE.BatchedMesh[] = []
121
+ const bindings: StaticColorBatchBinding[] = []
122
+ const hiddenSources = new Map<THREE.Mesh, boolean>()
123
+ const sourceRanges = new Map<THREE.BufferGeometry, { batch: THREE.BatchedMesh; geometryId: number }>()
124
+ let batchIndex = 0
125
+ let batchedSourceCount = 0
126
+ let allocatedGeometryBytes = 0
127
+ for (const group of groups.values()) {
128
+ // Without WEBGL_multi_draw, Three submits one ordinary draw per instance
129
+ // anyway, plus a DrawID uniform update and batch texture lookups. Keep the
130
+ // original geometry instead of paying for a course-sized duplicate buffer.
131
+ if (options.multiDrawSupported === false || group.length < 2) continue
132
+ const uniqueGeometries = [...new Set(group.map(source => source.geometry))]
133
+ const maxVertexCount = uniqueGeometries.reduce(
134
+ (sum, geometry) => sum + (geometry.getAttribute('position')?.count ?? 0),
135
+ 0,
136
+ )
137
+ const maxIndexCount = uniqueGeometries.reduce((sum, geometry) => sum + (geometry.index?.count ?? 0), 0)
138
+ if (maxVertexCount === 0) continue
139
+ const first = group[0]!
140
+ const vertexStride = Object.values(first.geometry.attributes).reduce(
141
+ (sum, attribute) => sum + attribute.itemSize * attribute.array.BYTES_PER_ELEMENT,
142
+ 0,
143
+ )
144
+ const allocationBytes = maxVertexCount * vertexStride + maxIndexCount * (maxVertexCount > 65535 ? 4 : 2)
145
+ // Include Three's initial duplicate channels and index widening in the
146
+ // staging budget, even when attribute aliases are restored afterward.
147
+ if (allocatedGeometryBytes + allocationBytes > (options.maxAdditionalGeometryBytes ?? Infinity)) continue
148
+ allocatedGeometryBytes += allocationBytes
149
+ const batch = new StaticTrackBatchedMesh(group.length, maxVertexCount, maxIndexCount, first.material)
150
+ batch.name = `track-static-color-batch-${batchIndex++}`
151
+ batch.castShadow = false
152
+ batch.receiveShadow = first.mesh.receiveShadow
153
+ batch.layers.mask = first.mesh.layers.mask
154
+ batch.renderOrder = first.mesh.renderOrder
155
+ batch.perObjectFrustumCulled = true
156
+ batch.sortObjects = true
157
+ batch.userData.trackStaticColorBatch = true
158
+ batch.userData.decorative = true
159
+ batch.userData.visualOnly = true
160
+ batch.userData.excludeFromEditorBounds = true
161
+ batch.userData.materialSlot = first.mesh.userData.materialSlot
162
+ const geometryIds = new Map<THREE.BufferGeometry, number>()
163
+ for (const source of group) {
164
+ let geometryId = geometryIds.get(source.geometry)
165
+ if (geometryId === undefined) {
166
+ geometryId = batch.addGeometry(source.geometry)
167
+ geometryIds.set(source.geometry, geometryId)
168
+ sourceRanges.set(source.geometry, { batch, geometryId })
169
+ }
170
+ const instanceId = batch.addInstance(geometryId)
171
+ batch.setMatrixAt(instanceId, source.matrix)
172
+ bindings.push({ batch, instanceId, source })
173
+ if (!hiddenSources.has(source.mesh)) hiddenSources.set(source.mesh, source.mesh.visible)
174
+ source.mesh.userData[STATIC_COLOR_BATCH_SOURCE] = true
175
+ source.mesh.visible = false
176
+ batchedSourceCount += 1
177
+ }
178
+ preserveGeometryAttributeAliases(batch.geometry, uniqueGeometries)
179
+ batch.computeBoundingBox()
180
+ batch.computeBoundingSphere()
181
+ root.add(batch)
182
+ batches.push(batch)
183
+ }
184
+
185
+ // BatchedMesh copies each source geometry into its own combined buffers.
186
+ // Keep hidden source meshes and exact CPU views for Lab picking, bounds,
187
+ // and later edits, but release redundant WebGL buffers once
188
+ // every renderer consumer of a geometry is represented by a color batch.
189
+ // BufferGeometry.dispose() does not delete its attributes; Three can upload
190
+ // them again if dispose() below restores the source meshes for a rebuild.
191
+ let releasedSourceGeometryBytes = 0
192
+ let releasedSourceGeometryCount = 0
193
+ const releasableSourceGeometries = new Set<THREE.BufferGeometry>()
194
+ for (const [geometry, consumers] of geometryConsumers) {
195
+ if (consumers.size === 0 || [...consumers].some(mesh => !hiddenSources.has(mesh))) continue
196
+ releasedSourceGeometryBytes += bufferGeometryByteLength(geometry)
197
+ releasedSourceGeometryCount += 1
198
+ releasableSourceGeometries.add(geometry)
199
+ geometry.dispose()
200
+ const range = sourceRanges.get(geometry)
201
+ if (range) shareStaticSourceAttributes(geometry, range.batch, range.geometryId)
202
+ }
203
+
204
+ return {
205
+ allocatedGeometryBytes,
206
+ connectorBatchCount: connectors.batchCount,
207
+ connectorSourceCount: connectors.sourceCount,
208
+ updateConnectorVisibility: connectors.updateTransforms,
209
+ batchCount: batches.length,
210
+ candidateCount: sources.length,
211
+ rejectionCounts,
212
+ releasedSourceGeometryBytes,
213
+ releasedSourceGeometryCount,
214
+ sourceCount: batchedSourceCount,
215
+ detachSources(sourceRoots) {
216
+ connectors.detachSources(sourceRoots)
217
+ const detachedMeshes = new Set<THREE.Mesh>()
218
+ for (const sourceRoot of sourceRoots) {
219
+ sourceRoot.traverse(object => {
220
+ if (object instanceof THREE.Mesh) detachedMeshes.add(object)
221
+ })
222
+ }
223
+ if (detachedMeshes.size === 0) return 0
224
+
225
+ let detachedCount = 0
226
+ let writeIndex = 0
227
+ for (const binding of bindings) {
228
+ const mesh = binding.source.mesh
229
+ if (!detachedMeshes.has(mesh)) {
230
+ bindings[writeIndex++] = binding
231
+ continue
232
+ }
233
+ releasableSourceGeometries.delete(binding.source.geometry)
234
+ binding.batch.deleteInstance(binding.instanceId)
235
+ if (hiddenSources.has(mesh)) {
236
+ mesh.visible = hiddenSources.get(mesh)!
237
+ hiddenSources.delete(mesh)
238
+ }
239
+ delete mesh.userData[STATIC_COLOR_BATCH_SOURCE]
240
+ detachedCount += 1
241
+ }
242
+ bindings.length = writeIndex
243
+ return detachedCount
244
+ },
245
+ releaseSourceGeometryCpuData() {
246
+ let bytes = 0
247
+ let geometries = 0
248
+ for (const geometry of releasableSourceGeometries) {
249
+ const byteLength = bufferGeometryByteLength(geometry)
250
+ if (byteLength === 0) continue
251
+ // Attribute views already share the live batch allocation. Only the
252
+ // source-local index (or an unshared attribute) is released here.
253
+ const batchGeometry = sourceRanges.get(geometry)?.batch.geometry
254
+ const privateArrays = new Set<ArrayBufferLike>()
255
+ if (geometry.index) privateArrays.add(geometry.index.array.buffer)
256
+ for (const [name, attribute] of Object.entries(geometry.attributes)) {
257
+ if (attribute.array.buffer !== batchGeometry?.getAttribute(name)?.array.buffer) {
258
+ privateArrays.add(attribute.array.buffer)
259
+ }
260
+ }
261
+ bytes += [...privateArrays].reduce((sum, array) => sum + array.byteLength, 0)
262
+ geometries += 1
263
+ geometry.setIndex(null)
264
+ for (const name of Object.keys(geometry.attributes)) geometry.deleteAttribute(name)
265
+ geometry.morphAttributes = {}
266
+ geometry.clearGroups()
267
+ geometry.boundingBox = null
268
+ geometry.boundingSphere = null
269
+ }
270
+ releasableSourceGeometries.clear()
271
+ return { bytes, geometries }
272
+ },
273
+ updateTransforms() {
274
+ connectors.updateTransforms()
275
+ root.updateWorldMatrix(true, true)
276
+ rootInverse.copy(root.matrixWorld).invert()
277
+ for (const { batch, instanceId, source } of bindings) {
278
+ updatedMatrix.copy(rootInverse).multiply(source.mesh.matrixWorld)
279
+ batch.setMatrixAt(instanceId, updatedMatrix)
280
+ }
281
+ for (const batch of batches) {
282
+ batch.computeBoundingBox()
283
+ batch.computeBoundingSphere()
284
+ }
285
+ },
286
+ dispose() {
287
+ connectors.dispose()
288
+ for (const [mesh, visible] of hiddenSources) {
289
+ mesh.visible = visible
290
+ delete mesh.userData[STATIC_COLOR_BATCH_SOURCE]
291
+ }
292
+ for (const batch of batches) {
293
+ batch.removeFromParent()
294
+ batch.dispose()
295
+ }
296
+ hiddenSources.clear()
297
+ releasableSourceGeometries.clear()
298
+ bindings.length = 0
299
+ batches.length = 0
300
+ },
301
+ }
302
+ }
303
+
304
+ function collectGeometryConsumers(root: THREE.Object3D): Map<THREE.BufferGeometry, Set<THREE.Mesh>> {
305
+ const consumers = new Map<THREE.BufferGeometry, Set<THREE.Mesh>>()
306
+ root.traverse(object => {
307
+ if (!(object instanceof THREE.Mesh) || object instanceof THREE.BatchedMesh) return
308
+ const geometryConsumers = consumers.get(object.geometry) ?? new Set<THREE.Mesh>()
309
+ geometryConsumers.add(object)
310
+ consumers.set(object.geometry, geometryConsumers)
311
+ })
312
+ return consumers
313
+ }
314
+
315
+ function bufferGeometryByteLength(geometry: THREE.BufferGeometry): number {
316
+ const arrays = new Set<ArrayBufferLike>()
317
+ const collectAttribute = (attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute): void => {
318
+ arrays.add(
319
+ attribute instanceof THREE.InterleavedBufferAttribute ? attribute.data.array.buffer : attribute.array.buffer,
320
+ )
321
+ }
322
+ if (geometry.index) collectAttribute(geometry.index)
323
+ for (const attribute of Object.values(geometry.attributes)) collectAttribute(attribute)
324
+ for (const attributes of Object.values(geometry.morphAttributes)) {
325
+ for (const attribute of attributes) collectAttribute(attribute)
326
+ }
327
+ let byteLength = 0
328
+ for (const array of arrays) byteLength += array.byteLength
329
+ return byteLength
330
+ }
331
+
332
+ /** Editor picking deliberately keeps hidden batch sources raycastable. */
333
+ export function isStaticTrackColorBatchSource(object: THREE.Object3D): boolean {
334
+ return object.userData[STATIC_COLOR_BATCH_SOURCE] === true
335
+ }
336
+
337
+ /**
338
+ * Combines nearby opaque, static track meshes into exact shadow-only
339
+ * triangle streams (front/back/double sided). Visible meshes and materials are
340
+ * untouched; only their redundant per-object shadow submissions are replaced.
341
+ */
342
+ export function batchStaticTrackShadowCasters(root: THREE.Group): number {
343
+ const sources = collectStaticShadowSources(root)
344
+ buildStaticShadowProxies(root, sources)
345
+ return sources.length
346
+ }
347
+
348
+ /**
349
+ * Builds the same exact shadow proxies as `batchStaticTrackShadowCasters`, but
350
+ * yields between large vertex streams. Lab Test Run uses this path so a large
351
+ * course cannot monopolise the browser main thread while its runtime copy is
352
+ * prepared.
353
+ */
354
+ export async function batchStaticTrackShadowCastersAsync(root: THREE.Group, sliceMilliseconds = 4): Promise<number> {
355
+ const sources = collectStaticShadowSources(root)
356
+ await buildStaticShadowProxiesAsync(root, sources, sliceMilliseconds)
357
+ return sources.length
358
+ }
359
+
360
+ function collectStaticShadowSources(root: THREE.Group): ShadowGeometrySource[] {
361
+ root.updateWorldMatrix(true, true)
362
+ const rootInverse = root.matrixWorld.clone().invert()
363
+ const sources: ShadowGeometrySource[] = []
364
+ root.traverse(object => {
365
+ if (
366
+ !(object instanceof THREE.Mesh) ||
367
+ object instanceof THREE.InstancedMesh ||
368
+ object instanceof THREE.SkinnedMesh ||
369
+ object.userData.trackStaticShadowProxy
370
+ ) {
371
+ return
372
+ }
373
+ if (!object.castShadow || !visibleThroughRoot(object, root) || hasDynamicShadowAncestor(object, root)) return
374
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
375
+ if (materials.length === 0 || materials.some(material => !canBatchShadowMaterial(material))) return
376
+ const sides = new Set(materials.map(material => material.shadowSide ?? material.side))
377
+ if (sides.size !== 1) return
378
+ const position = object.geometry.getAttribute('position')
379
+ if (!position || position.itemSize < 3 || object.geometry.morphAttributes.position?.length) return
380
+ const index = object.geometry.index ?? undefined
381
+ const availableCount = index?.count ?? position.count
382
+ const start = Math.max(0, Math.floor(object.geometry.drawRange.start))
383
+ const requestedCount = object.geometry.drawRange.count
384
+ const count = Math.max(
385
+ 0,
386
+ Math.min(availableCount - start, Number.isFinite(requestedCount) ? Math.floor(requestedCount) : availableCount),
387
+ )
388
+ const triangleCount = count - (count % 3)
389
+ if (triangleCount === 0) return
390
+ sources.push({
391
+ mesh: object,
392
+ matrix: rootInverse.clone().multiply(object.matrixWorld),
393
+ position,
394
+ ...(index ? { index } : {}),
395
+ start,
396
+ count: triangleCount,
397
+ side: [...sides][0]!,
398
+ })
399
+ })
400
+ return sources
401
+ }
402
+
403
+ function spatialShadowGroups(sources: readonly ShadowGeometrySource[]): ShadowGeometrySource[][] {
404
+ // A course-wide merged bound defeats the shadow camera's frustum culling:
405
+ // touching one end submits every triangle. Keep nearby sources together,
406
+ // including vertically stacked tracks, without clipping or duplicating them.
407
+ // Bounds still enclose the entire source, including pieces crossing cells.
408
+ const cellSize = 32
409
+ const groups = new Map<string, ShadowGeometrySource[]>()
410
+ const bounds = new THREE.Box3()
411
+ const center = new THREE.Vector3()
412
+ for (const source of sources) {
413
+ if (!source.mesh.geometry.boundingBox) source.mesh.geometry.computeBoundingBox()
414
+ bounds.copy(source.mesh.geometry.boundingBox!).applyMatrix4(source.matrix).getCenter(center)
415
+ const key = `${source.side}:${source.mesh.layers.mask}:${Math.round(center.x / cellSize)}:${Math.round(center.y / cellSize)}:${Math.round(center.z / cellSize)}`
416
+ const group = groups.get(key) ?? []
417
+ group.push(source)
418
+ groups.set(key, group)
419
+ }
420
+ return [...groups.values()]
421
+ }
422
+
423
+ function buildStaticShadowProxies(root: THREE.Group, sources: readonly ShadowGeometrySource[]): void {
424
+ for (const sideSources of spatialShadowGroups(sources)) {
425
+ const { positions, indices } = createShadowProxyArrays(sideSources)
426
+ addStaticShadowProxy(root, sideSources[0]!.side, positions, indices, sideSources[0]!.mesh.layers.mask)
427
+ }
428
+ }
429
+
430
+ async function buildStaticShadowProxiesAsync(
431
+ root: THREE.Group,
432
+ sources: readonly ShadowGeometrySource[],
433
+ sliceMilliseconds: number,
434
+ ): Promise<void> {
435
+ let sliceStartedAt = performance.now()
436
+ const yieldIfNeeded = async (): Promise<void> => {
437
+ if (performance.now() - sliceStartedAt < sliceMilliseconds) return
438
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
439
+ sliceStartedAt = performance.now()
440
+ }
441
+ for (const sideSources of spatialShadowGroups(sources)) {
442
+ const vertexCount = sideSources.reduce((sum, source) => sum + source.position.count, 0)
443
+ const positions = new Float32Array(vertexCount * 3)
444
+ const indices = new Uint32Array(sideSources.reduce((sum, source) => sum + source.count, 0))
445
+ const vertex = new THREE.Vector3()
446
+ let vertexOffset = 0
447
+ let indexOffset = 0
448
+ for (const source of sideSources) {
449
+ for (let sourceVertex = 0; sourceVertex < source.position.count; sourceVertex += 1) {
450
+ vertex.fromBufferAttribute(source.position, sourceVertex).applyMatrix4(source.matrix)
451
+ const writeOffset = (vertexOffset + sourceVertex) * 3
452
+ positions[writeOffset] = vertex.x
453
+ positions[writeOffset + 1] = vertex.y
454
+ positions[writeOffset + 2] = vertex.z
455
+ if ((sourceVertex & 1_023) === 1_023) await yieldIfNeeded()
456
+ }
457
+ for (let offset = source.start; offset < source.start + source.count; offset += 1) {
458
+ indices[indexOffset++] = vertexOffset + (source.index?.getX(offset) ?? offset)
459
+ }
460
+ vertexOffset += source.position.count
461
+ source.mesh.castShadow = false
462
+ await yieldIfNeeded()
463
+ }
464
+ addStaticShadowProxy(root, sideSources[0]!.side, positions, indices, sideSources[0]!.mesh.layers.mask)
465
+ await yieldIfNeeded()
466
+ }
467
+ }
468
+
469
+ function createShadowProxyArrays(sideSources: readonly ShadowGeometrySource[]): {
470
+ positions: Float32Array
471
+ indices: Uint32Array
472
+ } {
473
+ const vertexCount = sideSources.reduce((sum, source) => sum + source.position.count, 0)
474
+ const positions = new Float32Array(vertexCount * 3)
475
+ const indices = new Uint32Array(sideSources.reduce((sum, source) => sum + source.count, 0))
476
+ const vertex = new THREE.Vector3()
477
+ let vertexOffset = 0
478
+ let indexOffset = 0
479
+ for (const source of sideSources) {
480
+ for (let sourceVertex = 0; sourceVertex < source.position.count; sourceVertex += 1) {
481
+ vertex.fromBufferAttribute(source.position, sourceVertex).applyMatrix4(source.matrix)
482
+ const writeOffset = (vertexOffset + sourceVertex) * 3
483
+ positions[writeOffset] = vertex.x
484
+ positions[writeOffset + 1] = vertex.y
485
+ positions[writeOffset + 2] = vertex.z
486
+ }
487
+ for (let offset = source.start; offset < source.start + source.count; offset += 1) {
488
+ indices[indexOffset++] = vertexOffset + (source.index?.getX(offset) ?? offset)
489
+ }
490
+ vertexOffset += source.position.count
491
+ source.mesh.castShadow = false
492
+ }
493
+ return { positions, indices }
494
+ }
495
+
496
+ function addStaticShadowProxy(
497
+ root: THREE.Group,
498
+ side: THREE.Side,
499
+ positions: Float32Array,
500
+ indices: Uint32Array,
501
+ layersMask: number,
502
+ ): void {
503
+ const geometry = new THREE.BufferGeometry()
504
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
505
+ geometry.setIndex(new THREE.BufferAttribute(indices, 1))
506
+ geometry.computeBoundingBox()
507
+ geometry.computeBoundingSphere()
508
+ // WebGLShadowMap filters casters against the viewing camera's layers, not
509
+ // the shadow camera's layers. Keep proxies on the source layer, but make
510
+ // their ordinary pass write neither color nor depth. Three's shadow pass
511
+ // uses its own depth material, so the exact caster remains visible there.
512
+ const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side, colorWrite: false, depthWrite: false })
513
+ material.name = 'track-static-shadow-proxy-material'
514
+ material.userData.ownsMaterial = true
515
+ const proxy = new THREE.Mesh(geometry, material)
516
+ proxy.name = `track-static-shadow-proxy-${side}`
517
+ proxy.layers.mask = layersMask
518
+ proxy.layers.enable(STATIC_SHADOW_PROXY_LAYER)
519
+ proxy.castShadow = true
520
+ proxy.receiveShadow = false
521
+ proxy.userData.trackStaticShadowProxy = true
522
+ proxy.userData.excludeFromEditorBounds = true
523
+ proxy.userData.decorative = true
524
+ root.add(proxy)
525
+ }
526
+
527
+ function canBatchShadowMaterial(material: THREE.Material): boolean {
528
+ if (!material.visible || material.transparent || material.opacity < 1 || material.alphaTest > 0) return false
529
+ if ('alphaMap' in material && material.alphaMap) return false
530
+ if (material instanceof THREE.MeshStandardMaterial && material.displacementMap && material.displacementScale !== 0) {
531
+ return false
532
+ }
533
+ return true
534
+ }
535
+
536
+ /**
537
+ * Keep exact source-local data for editor picking without retaining a second
538
+ * course-sized set of CPU arrays. Batch positions are copied before instance
539
+ * transforms, so views into their ranges have the original coordinates.
540
+ * Indices stay source-local: batch indices include a vertex offset.
541
+ */
542
+ function shareStaticSourceAttributes(source: THREE.BufferGeometry, batch: THREE.BatchedMesh, geometryId: number): void {
543
+ const range = batch.getGeometryRangeAt(geometryId)
544
+ if (!range) return
545
+ const aliases = new Map<THREE.BufferAttribute, THREE.BufferAttribute>()
546
+ for (const [name, attribute] of Object.entries(source.attributes)) {
547
+ const target = batch.geometry.getAttribute(name)
548
+ if (!(attribute instanceof THREE.BufferAttribute) || !(target instanceof THREE.BufferAttribute)) continue
549
+ let shared = aliases.get(attribute)
550
+ if (!shared) {
551
+ const start = range.vertexStart * attribute.itemSize
552
+ shared = new THREE.BufferAttribute(
553
+ target.array.subarray(start, start + attribute.count * attribute.itemSize),
554
+ attribute.itemSize,
555
+ attribute.normalized,
556
+ )
557
+ shared.name = attribute.name
558
+ shared.setUsage(attribute.usage)
559
+ shared.gpuType = attribute.gpuType
560
+ aliases.set(attribute, shared)
561
+ }
562
+ source.setAttribute(name, shared)
563
+ }
564
+ }
565
+
566
+ function staticColorBatchRejectionReason(object: THREE.Mesh, sourceRoot: THREE.Object3D): string | undefined {
567
+ if (
568
+ object instanceof THREE.BatchedMesh ||
569
+ object instanceof THREE.InstancedMesh ||
570
+ object instanceof THREE.SkinnedMesh
571
+ ) {
572
+ return 'special-mesh'
573
+ }
574
+ if (object.userData.trackStaticColorBatch || object.userData.trackStaticShadowProxy) return 'existing-batch'
575
+ if (object.userData.isTrackConnectorMarker) return 'connector'
576
+ if (Array.isArray(object.material)) return 'material-array'
577
+ if (object.geometry.morphAttributes.position?.length) return 'morph'
578
+ if (hasDynamicShadowAncestor(object, sourceRoot)) return 'dynamic'
579
+ if (!visibleThroughRoot(object, sourceRoot)) return 'hidden'
580
+ const material = object.material
581
+ const baseTransparent = material.userData.themeBaseTransparent
582
+ const baseOpacity = material.userData.themeBaseOpacity
583
+ const inherentlyTransparent = typeof baseTransparent === 'boolean' ? baseTransparent : material.transparent
584
+ const inherentOpacity = typeof baseOpacity === 'number' ? baseOpacity : material.opacity
585
+ if (!material.visible || inherentlyTransparent || inherentOpacity < 1 || material.alphaTest > 0) return 'transparent'
586
+ const position = object.geometry.getAttribute('position')
587
+ if (!position || position.itemSize < 3) return 'position'
588
+ const availableCount = object.geometry.index?.count ?? position.count
589
+ const requestedCount = object.geometry.drawRange.count
590
+ const drawCount = Number.isFinite(requestedCount) ? Math.floor(requestedCount) : availableCount
591
+ return object.geometry.drawRange.start === 0 && drawCount >= availableCount ? undefined : 'draw-range'
592
+ }
593
+
594
+ function visibleThroughRoot(object: THREE.Object3D, root: THREE.Object3D): boolean {
595
+ for (let current: THREE.Object3D | null = object; current; current = current.parent) {
596
+ if (!current.visible) return false
597
+ if (current === root) return true
598
+ }
599
+ return false
600
+ }
601
+
602
+ function hasDynamicShadowAncestor(object: THREE.Object3D, root: THREE.Object3D): boolean {
603
+ for (let current: THREE.Object3D | null = object; current && current !== root; current = current.parent) {
604
+ if (current.userData[DYNAMIC_SHADOW_ROOT]) return true
605
+ }
606
+ return false
607
+ }
608
+
609
+ interface OverviewDecorationSource {
610
+ geometry: THREE.BufferGeometry
611
+ material: THREE.Material
612
+ matrix: THREE.Matrix4
613
+ mesh: THREE.Mesh
614
+ themeHidden?: readonly string[]
615
+ themeOnly?: readonly string[]
616
+ visibilitySignature: string
617
+ }
618
+
619
+ /**
620
+ * Merges static decorative color meshes inside one editable Track Lab piece.
621
+ * Geometry is grouped by material, attributes, render state, and inherited
622
+ * theme visibility, so a later in-place theme change cannot reveal geometry
623
+ * from another visual family. Instanced and dynamic details stay independent.
624
+ */
625
+ export function batchStaticOverviewDecorations(root: THREE.Group): number {
626
+ root.updateWorldMatrix(true, true)
627
+ const rootInverse = root.matrixWorld.clone().invert()
628
+ const sources: OverviewDecorationSource[] = []
629
+ root.traverse(object => {
630
+ if (
631
+ !(object instanceof THREE.Mesh) ||
632
+ object instanceof THREE.InstancedMesh ||
633
+ object instanceof THREE.SkinnedMesh ||
634
+ object.userData.trackStaticOverviewBatch ||
635
+ object.userData.trackStaticShadowProxy ||
636
+ Array.isArray(object.material) ||
637
+ object.renderOrder !== 0 ||
638
+ object.geometry.morphAttributes.position?.length
639
+ ) {
640
+ return
641
+ }
642
+ const material = object.material
643
+ if (!material.visible || material.transparent || material.opacity < 1 || material.alphaTest > 0) return
644
+ const visibility = overviewDecorationVisibility(object, root)
645
+ if (!visibility) return
646
+ sources.push({
647
+ geometry: object.geometry,
648
+ material,
649
+ matrix: rootInverse.clone().multiply(object.matrixWorld),
650
+ mesh: object,
651
+ ...visibility,
652
+ })
653
+ })
654
+
655
+ const groups = new Map<string, OverviewDecorationSource[]>()
656
+ for (const source of sources) {
657
+ const key = [
658
+ source.material.uuid,
659
+ source.visibilitySignature,
660
+ source.mesh.castShadow ? 1 : 0,
661
+ source.mesh.receiveShadow ? 1 : 0,
662
+ geometryAttributeSignature(source.geometry),
663
+ ].join('|')
664
+ const group = groups.get(key) ?? []
665
+ group.push(source)
666
+ groups.set(key, group)
667
+ }
668
+
669
+ let mergedSourceCount = 0
670
+ let batchIndex = 0
671
+ for (const group of groups.values()) {
672
+ if (group.length < 2) continue
673
+ const transformed = group.map(source => {
674
+ const geometry = source.geometry.clone()
675
+ // Primitive geometries such as BoxGeometry retain face groups even
676
+ // when the mesh owns one material. The groups are redundant in that
677
+ // case and would otherwise prevent safe cross-object merging.
678
+ geometry.clearGroups()
679
+ return geometry.applyMatrix4(source.matrix)
680
+ })
681
+ const geometry = mergeGeometries(transformed, false)
682
+ transformed.forEach(candidate => candidate.dispose())
683
+ if (!geometry) continue
684
+ preserveGeometryAttributeAliases(
685
+ geometry,
686
+ group.map(source => source.geometry),
687
+ )
688
+ geometry.computeBoundingBox()
689
+ geometry.computeBoundingSphere()
690
+ const first = group[0]!
691
+ const batch = new THREE.Mesh(geometry, first.material)
692
+ batch.name = `${root.name}-overview-decoration-batch-${batchIndex++}`
693
+ batch.castShadow = first.mesh.castShadow
694
+ batch.receiveShadow = first.mesh.receiveShadow
695
+ batch.userData.trackStaticOverviewBatch = true
696
+ batch.userData.decorative = true
697
+ batch.userData.visualOnly = true
698
+ batch.userData.materialSlot = first.mesh.userData.materialSlot
699
+ if (first.themeOnly) {
700
+ batch.userData.themeOnly = first.themeOnly.length === 1 ? first.themeOnly[0] : [...first.themeOnly]
701
+ }
702
+ if (first.themeHidden) {
703
+ batch.userData.themeHidden = first.themeHidden.length === 1 ? first.themeHidden[0] : [...first.themeHidden]
704
+ }
705
+ root.add(batch)
706
+ for (const source of group) {
707
+ source.mesh.removeFromParent()
708
+ if (!source.mesh.userData.sharedGeometry) source.geometry.dispose()
709
+ }
710
+ mergedSourceCount += group.length
711
+ }
712
+ return mergedSourceCount
713
+ }
714
+
715
+ function overviewDecorationVisibility(
716
+ object: THREE.Object3D,
717
+ root: THREE.Object3D,
718
+ ): { themeHidden?: readonly string[]; themeOnly?: readonly string[]; visibilitySignature: string } | undefined {
719
+ let decorative = false
720
+ let only: Set<string> | undefined
721
+ const hidden = new Set<string>()
722
+ for (let current: THREE.Object3D | null = object; current; current = current.parent) {
723
+ decorative ||= current.userData.decorative === true
724
+ if (current.userData[DYNAMIC_SHADOW_ROOT] || current.userData.isPortalViewSurface) return undefined
725
+ const currentOnly = themeVisibilityIds(current.userData.themeOnly)
726
+ const currentHidden = themeVisibilityIds(current.userData.themeHidden)
727
+ if (!current.visible && currentOnly.length === 0 && currentHidden.length === 0) return undefined
728
+ if (currentOnly.length > 0) {
729
+ const values = new Set(currentOnly)
730
+ only = only ? new Set([...only].filter(themeId => values.has(themeId))) : values
731
+ }
732
+ currentHidden.forEach(themeId => hidden.add(themeId))
733
+ if (current === root) break
734
+ }
735
+ if (!decorative) return undefined
736
+ if (only) hidden.forEach(themeId => only!.delete(themeId))
737
+ if (only?.size === 0) return undefined
738
+ const themeOnly = only ? [...only].sort() : undefined
739
+ const themeHidden = hidden.size > 0 ? [...hidden].sort() : undefined
740
+ return {
741
+ ...(themeOnly ? { themeOnly } : {}),
742
+ ...(themeHidden ? { themeHidden } : {}),
743
+ visibilitySignature: `only=${themeOnly?.join(',') ?? '*'};hidden=${themeHidden?.join(',') ?? ''}`,
744
+ }
745
+ }
746
+
747
+ function themeVisibilityIds(value: unknown): string[] {
748
+ if (typeof value === 'string') return [value]
749
+ if (Array.isArray(value)) return value.filter((themeId): themeId is string => typeof themeId === 'string')
750
+ return []
751
+ }
752
+
753
+ function geometryAttributeSignature(geometry: THREE.BufferGeometry): string {
754
+ const attributes = Object.entries(geometry.attributes)
755
+ .sort(([left], [right]) => left.localeCompare(right))
756
+ .map(([name, attribute]) =>
757
+ [name, attribute.itemSize, attribute.normalized ? 1 : 0, attribute.array.constructor.name].join(':'),
758
+ )
759
+ return `${geometry.index ? 'indexed' : 'plain'}:${attributes.join(',')}`
760
+ }