@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,983 @@
1
+ import type { MaterialSlot, TrackPieceCategory } from '@marble.fun/physics-engine'
2
+ import * as THREE from 'three'
3
+
4
+ import { applyTransparentSupportFadeShader } from '../../../three/theme-material-registry'
5
+ import {
6
+ JAPANESE_SAKURA_DRAGON_STONE_SUPPORT_ID,
7
+ JAPANESE_SAKURA_VERMILION_UNDERBRIDGE_SUPPORT_ID,
8
+ } from '../../../three/japanese-sakura-appearance'
9
+ import { addJapaneseSakuraCastleSupportModel } from './japanese-sakura-castle-support-blender'
10
+ import {
11
+ createUnderTrackSupportLayout,
12
+ UNDER_TRACK_SUPPORT_BAY_LENGTH,
13
+ UNDER_TRACK_SUPPORT_DEPTH,
14
+ UNDER_TRACK_SUPPORT_FADE_DEPTH,
15
+ UNDER_TRACK_SUPPORT_FADE_START_DEPTH,
16
+ UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE,
17
+ UNDER_TRACK_SUPPORT_MEMBER_SCALE,
18
+ underTrackSupportFadeAtDepth,
19
+ underTrackSupportHalfSpan,
20
+ underTrackSupportStationClearance,
21
+ } from './under-track-support-architecture'
22
+
23
+ export const WILD_WEST_TIMBER_TRESTLE_SUPPORT_ID = 'wild-west.timber-trestle-support'
24
+ export const WILD_WEST_CANYON_BUTTRESS_SUPPORT_ID = 'wild-west.canyon-buttress-support'
25
+ export const WILD_WEST_TRESTLE_METAL_MATERIAL_ID = 'wild-west.trestle-iron'
26
+
27
+ const WILD_WEST_THEME_ID = 'rocky-mountain-wild-west'
28
+ const JAPANESE_SAKURA_THEME_ID = 'japanese-sakura-lake'
29
+ const WORLD_UP = new THREE.Vector3(0, 1, 0)
30
+ const UNSUPPORTED_RUNTIME_KINDS = new Set(['conveyor', 'elevator', 'vortex'])
31
+ const SUPPORT_DECK_UNDERSIDE_OFFSET = 0.32
32
+ const CANYON_SLOPE_TRANSITION_DEPTH_FRACTION = 0.36
33
+ const CANYON_COURSE_SCHEDULE = [
34
+ [0, 0.055, 1.05, 1.02, false],
35
+ [0.058, 0.11, 0.88, 0.96, true],
36
+ [0.113, 0.17, 0.82, 0.93, false],
37
+ [0.173, 0.23, 0.78, 0.91, true],
38
+ [0.233, 0.295, 0.7, 0.88, false],
39
+ [0.298, 0.36, 0.64, 0.84, true],
40
+ [0.363, 0.43, 0.78, 0.88, false],
41
+ [0.433, 0.5, 0.74, 0.85, false],
42
+ [0.503, 0.57, 0.68, 0.82, true],
43
+ [0.573, 0.64, 0.7, 0.82, false],
44
+ [0.643, 0.71, 0.76, 0.84, true],
45
+ [0.713, 0.78, 0.82, 0.85, false],
46
+ [0.783, 0.85, 0.86, 0.86, true],
47
+ [0.853, 0.925, 0.9, 0.88, false],
48
+ [0.928, 1, 0.94, 0.9, true],
49
+ ] as const
50
+
51
+ export interface TrackAppearanceSupportFrame {
52
+ point: THREE.Vector3
53
+ tangent: THREE.Vector3
54
+ right: THREE.Vector3
55
+ up: THREE.Vector3
56
+ width: number
57
+ }
58
+
59
+ export interface TrackAppearanceSupportMaterials {
60
+ support: THREE.Material
61
+ timber: THREE.Material
62
+ metal: THREE.Material
63
+ }
64
+
65
+ export interface TrackAppearanceSupportOptions {
66
+ capStart?: boolean
67
+ capEnd?: boolean
68
+ /** Cumulative path length at the input connector, shared with material continuity. */
69
+ connectionPhase?: number
70
+ /** Existing topology-owned stations which this lower-priority route must not overlap. */
71
+ excludedStationPoints?: readonly THREE.Vector3[]
72
+ }
73
+
74
+ interface SampledSupportLayout {
75
+ stations: TrackAppearanceSupportFrame[]
76
+ ownedBays: readonly {
77
+ start: TrackAppearanceSupportFrame
78
+ end: TrackAppearanceSupportFrame
79
+ startDistance: number
80
+ endDistance: number
81
+ }[]
82
+ }
83
+
84
+ interface Beam {
85
+ start: THREE.Vector3
86
+ end: THREE.Vector3
87
+ width: number
88
+ depth: number
89
+ fadeAnchorY: number
90
+ }
91
+
92
+ export function supportsTrackAppearanceSupport(
93
+ _category: TrackPieceCategory,
94
+ runtimeKind: string,
95
+ materialSlots: readonly MaterialSlot[],
96
+ ): boolean {
97
+ return materialSlots.includes('lane.surface') && !UNSUPPORTED_RUNTIME_KINDS.has(runtimeKind)
98
+ }
99
+
100
+ export function addTrackAppearanceSupport(
101
+ root: THREE.Group,
102
+ pieceId: string,
103
+ frames: readonly TrackAppearanceSupportFrame[],
104
+ supportAppearanceId: string | undefined,
105
+ materials: TrackAppearanceSupportMaterials,
106
+ options: TrackAppearanceSupportOptions = {},
107
+ ): THREE.Group | undefined {
108
+ if (frames.length < 2) return undefined
109
+ if (
110
+ supportAppearanceId !== WILD_WEST_TIMBER_TRESTLE_SUPPORT_ID &&
111
+ supportAppearanceId !== WILD_WEST_CANYON_BUTTRESS_SUPPORT_ID &&
112
+ supportAppearanceId !== JAPANESE_SAKURA_VERMILION_UNDERBRIDGE_SUPPORT_ID &&
113
+ supportAppearanceId !== JAPANESE_SAKURA_DRAGON_STONE_SUPPORT_ID
114
+ ) {
115
+ return undefined
116
+ }
117
+
118
+ applyTransparentSupportFadeShader(materials.support)
119
+ applyTransparentSupportFadeShader(materials.metal)
120
+ const group = new THREE.Group()
121
+ group.name = `${pieceId}-under-track-support`
122
+ const themeOnly = supportAppearanceId.startsWith('japanese-sakura.') ? JAPANESE_SAKURA_THEME_ID : WILD_WEST_THEME_ID
123
+ group.userData.themeOnly = themeOnly
124
+ group.userData.decorative = true
125
+ group.userData.visualOnly = true
126
+ group.userData.supportAppearanceId = supportAppearanceId
127
+ group.userData.depthFadeTechnique = 'transparent-height-fade-with-exterior-depth-prepass'
128
+ group.userData.supportDepth = UNDER_TRACK_SUPPORT_DEPTH
129
+ group.userData.supportFadeStartDepth = UNDER_TRACK_SUPPORT_FADE_START_DEPTH
130
+ group.userData.supportFadeEndDepth = UNDER_TRACK_SUPPORT_DEPTH
131
+ group.userData.supportBayLength = UNDER_TRACK_SUPPORT_BAY_LENGTH
132
+ group.userData.supportArchitecture = 'fixed-depth-continuous-metric-bays-v2'
133
+ group.userData.supportFootprintScale = UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE
134
+ group.userData.supportMemberScale = UNDER_TRACK_SUPPORT_MEMBER_SCALE
135
+ root.add(group)
136
+
137
+ let content: THREE.Group | undefined
138
+ const renderAtConnectionPhase = (connectionPhase: number) => {
139
+ if (!Number.isFinite(connectionPhase)) return
140
+ if (content && Math.abs((group.userData.supportConnectionPhase as number) - connectionPhase) < 0.000_001) return
141
+ if (content) {
142
+ group.remove(content)
143
+ disposeSupportContent(content)
144
+ }
145
+ content = new THREE.Group()
146
+ content.name = `${pieceId}-under-track-support-content`
147
+ group.add(content)
148
+ const excludedStationPoints: THREE.Vector3[] = [...(options.excludedStationPoints ?? [])]
149
+ root.traverse(object => {
150
+ if (!Array.isArray(object.userData.supportStationPoints)) return
151
+ for (const point of object.userData.supportStationPoints as [number, number, number][]) {
152
+ excludedStationPoints.push(new THREE.Vector3(...point))
153
+ }
154
+ })
155
+ const resolvedOptions = { ...options, connectionPhase, excludedStationPoints }
156
+ if (supportAppearanceId === WILD_WEST_TIMBER_TRESTLE_SUPPORT_ID) {
157
+ addTimberTrestle(content, pieceId, frames, materials, resolvedOptions)
158
+ } else if (supportAppearanceId === WILD_WEST_CANYON_BUTTRESS_SUPPORT_ID) {
159
+ addStackedCanyonButtresses(content, pieceId, frames, materials.support, resolvedOptions)
160
+ } else if (supportAppearanceId === JAPANESE_SAKURA_VERMILION_UNDERBRIDGE_SUPPORT_ID) {
161
+ addSakuraVermilionUnderbridge(content, pieceId, frames, materials, resolvedOptions)
162
+ } else {
163
+ addSakuraDragonStoneSupport(content, pieceId, frames, materials.support, resolvedOptions)
164
+ }
165
+ Object.assign(group.userData, content.userData)
166
+ group.userData.supportConnectionPhase = connectionPhase
167
+ content.traverse(object => {
168
+ object.userData.themeOnly = themeOnly
169
+ object.userData.decorative = true
170
+ object.userData.visualOnly = true
171
+ object.castShadow = true
172
+ object.receiveShadow = true
173
+ })
174
+ addSupportDepthPrepasses(content)
175
+ }
176
+ group.userData.setSupportConnectionPhase = renderAtConnectionPhase
177
+ renderAtConnectionPhase(options.connectionPhase ?? 0)
178
+ return group
179
+ }
180
+
181
+ function disposeSupportContent(content: THREE.Object3D): void {
182
+ content.userData.supportDisposed = true
183
+ content.traverse(object => {
184
+ if (!(object instanceof THREE.Mesh)) return
185
+ object.geometry.dispose()
186
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
187
+ for (const material of materials) {
188
+ if (material.userData.supportDepthPrepassMaterial === true) material.dispose()
189
+ }
190
+ })
191
+ }
192
+
193
+ function addSupportDepthPrepasses(group: THREE.Group): void {
194
+ const meshes: THREE.Mesh[] = []
195
+ group.traverse(object => {
196
+ if (
197
+ object instanceof THREE.Mesh &&
198
+ object.userData.supportDepthPrepass !== true &&
199
+ object.userData.supportSkipDepthPrepass !== true
200
+ ) {
201
+ meshes.push(object)
202
+ }
203
+ })
204
+
205
+ for (const mesh of meshes) {
206
+ const visibleMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
207
+ visibleMaterials.forEach(material => {
208
+ material.depthFunc = THREE.EqualDepth
209
+ })
210
+ const depthMaterials = visibleMaterials.map(material => {
211
+ const depthMaterial = material.clone()
212
+ depthMaterial.onBeforeCompile = THREE.Material.prototype.onBeforeCompile
213
+ depthMaterial.customProgramCacheKey = THREE.Material.prototype.customProgramCacheKey
214
+ delete depthMaterial.userData.transparentSupportFadeShader
215
+ applyTransparentSupportFadeShader(depthMaterial, true, true)
216
+ depthMaterial.name = `${material.name || 'support'}:exterior-depth-prepass`
217
+ depthMaterial.transparent = false
218
+ depthMaterial.depthWrite = true
219
+ depthMaterial.colorWrite = false
220
+ depthMaterial.blending = THREE.NoBlending
221
+ depthMaterial.depthFunc = THREE.LessEqualDepth
222
+ depthMaterial.userData.supportDepthPrepassMaterial = true
223
+ depthMaterial.userData.supportDepthCoverage = 'complete-fade-exterior'
224
+ depthMaterial.needsUpdate = true
225
+ return depthMaterial
226
+ })
227
+ const depthMesh = mesh.clone(false)
228
+ depthMesh.name = `${mesh.name}-exterior-depth-prepass`
229
+ depthMesh.material = Array.isArray(mesh.material) ? depthMaterials : depthMaterials[0]!
230
+ depthMesh.userData = {
231
+ ...mesh.userData,
232
+ supportDepthPrepass: true,
233
+ decorative: true,
234
+ visualOnly: true,
235
+ }
236
+ depthMesh.castShadow = false
237
+ depthMesh.receiveShadow = false
238
+ const parent = mesh.parent ?? group
239
+ parent.add(depthMesh)
240
+ }
241
+ }
242
+
243
+ function trestleBentAtFrame(frame: TrackAppearanceSupportFrame) {
244
+ const halfSpan = underTrackSupportHalfSpan(frame.width)
245
+ const topCenter = frame.point.clone().addScaledVector(frame.up, -0.39)
246
+ const left = topCenter.clone().addScaledVector(frame.right, -halfSpan)
247
+ const right = topCenter.clone().addScaledVector(frame.right, halfSpan)
248
+ return {
249
+ left,
250
+ right,
251
+ bottomLeft: left.clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_DEPTH),
252
+ bottomRight: right.clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_DEPTH),
253
+ fadeAnchorY: Math.max(left.y, right.y),
254
+ }
255
+ }
256
+
257
+ function sakuraBentAtFrame(frame: TrackAppearanceSupportFrame) {
258
+ const halfSpan = underTrackSupportHalfSpan(frame.width)
259
+ const topCenter = frame.point.clone().addScaledVector(frame.up, -0.43)
260
+ const left = topCenter.clone().addScaledVector(frame.right, -halfSpan)
261
+ const right = topCenter.clone().addScaledVector(frame.right, halfSpan)
262
+ return { left, right, fadeAnchorY: Math.max(left.y, right.y) }
263
+ }
264
+
265
+ function addTimberTrestle(
266
+ root: THREE.Group,
267
+ pieceId: string,
268
+ pathFrames: readonly TrackAppearanceSupportFrame[],
269
+ materials: TrackAppearanceSupportMaterials,
270
+ options: TrackAppearanceSupportOptions,
271
+ ): void {
272
+ const layout = sampleContinuousSupportLayout(pathFrames, options)
273
+ const sampled = layout.stations
274
+ const posts: Beam[] = []
275
+ const ties: Beam[] = []
276
+ const braces: Beam[] = []
277
+ const straps: Beam[] = []
278
+
279
+ for (const [index, frame] of sampled.entries()) {
280
+ const halfSpan = underTrackSupportHalfSpan(frame.width)
281
+ const topCenter = frame.point.clone().addScaledVector(frame.up, -0.39)
282
+ const topLeft = topCenter.clone().addScaledVector(frame.right, -halfSpan)
283
+ const topRight = topCenter.clone().addScaledVector(frame.right, halfSpan)
284
+ const fadeAnchorY = Math.max(topLeft.y, topRight.y)
285
+ const bottomLeft = topLeft.clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_DEPTH)
286
+ const bottomRight = topRight.clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_DEPTH)
287
+ const postWidth = (0.3 + deterministic(`${pieceId}:post:${index}`) * 0.055) * UNDER_TRACK_SUPPORT_MEMBER_SCALE
288
+ posts.push(
289
+ beam(bottomLeft, topLeft, postWidth, postWidth * 0.92, fadeAnchorY),
290
+ beam(bottomRight, topRight, postWidth * 0.96, postWidth, fadeAnchorY),
291
+ )
292
+ ties.push(
293
+ beam(
294
+ topLeft,
295
+ topRight,
296
+ 0.27 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
297
+ 0.21 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
298
+ fadeAnchorY,
299
+ ),
300
+ beam(
301
+ bottomLeft.clone().lerp(topLeft, 0.38),
302
+ bottomRight.clone().lerp(topRight, 0.38),
303
+ 0.18 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
304
+ 0.15 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
305
+ fadeAnchorY,
306
+ ),
307
+ )
308
+ braces.push(
309
+ beam(
310
+ bottomLeft,
311
+ topRight,
312
+ 0.14 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
313
+ 0.11 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
314
+ fadeAnchorY,
315
+ ),
316
+ beam(
317
+ bottomRight,
318
+ topLeft,
319
+ 0.14 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
320
+ 0.11 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
321
+ fadeAnchorY,
322
+ ),
323
+ )
324
+ for (const top of [topLeft, topRight]) {
325
+ straps.push(
326
+ beam(
327
+ top.clone().addScaledVector(frame.right, -0.055),
328
+ top.clone().addScaledVector(frame.right, 0.055),
329
+ postWidth + 0.1 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
330
+ 0.075 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
331
+ fadeAnchorY,
332
+ ),
333
+ )
334
+ }
335
+ }
336
+
337
+ // Load-bearing stringers and the evenly distributed bent schedule both
338
+ // meet the exact connector planes, matching a space-between layout.
339
+ for (let index = 0; index < pathFrames.length - 1; index += 1) {
340
+ const current = pathFrames[index]!
341
+ const next = pathFrames[index + 1]!
342
+ for (const side of [-1, 1] as const) {
343
+ ties.push(
344
+ beam(
345
+ supportContact(current, side),
346
+ supportContact(next, side),
347
+ 0.21 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
348
+ 0.18 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
349
+ ),
350
+ )
351
+ }
352
+ }
353
+ for (const ownedBay of layout.ownedBays) {
354
+ const current = trestleBentAtFrame(ownedBay.start)
355
+ const next = trestleBentAtFrame(ownedBay.end)
356
+ const fadeAnchorY = Math.max(current.fadeAnchorY, next.fadeAnchorY)
357
+ braces.push(
358
+ beam(
359
+ current.bottomLeft,
360
+ next.left,
361
+ 0.14 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
362
+ 0.11 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
363
+ fadeAnchorY,
364
+ ),
365
+ beam(
366
+ current.left,
367
+ next.bottomLeft,
368
+ 0.14 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
369
+ 0.11 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
370
+ fadeAnchorY,
371
+ ),
372
+ beam(
373
+ current.bottomRight,
374
+ next.right,
375
+ 0.14 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
376
+ 0.11 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
377
+ fadeAnchorY,
378
+ ),
379
+ beam(
380
+ current.right,
381
+ next.bottomRight,
382
+ 0.14 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
383
+ 0.11 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
384
+ fadeAnchorY,
385
+ ),
386
+ )
387
+ }
388
+
389
+ addBeamBatch(root, `${pieceId}-trestle-posts`, posts, materials.support, 'trestle-post')
390
+ addBeamBatch(root, `${pieceId}-trestle-ties`, ties, materials.support, 'trestle-tie')
391
+ addBeamBatch(root, `${pieceId}-trestle-braces`, braces, materials.support, 'trestle-brace')
392
+ addBeamBatch(root, `${pieceId}-trestle-iron-straps`, straps, materials.metal, 'trestle-strap')
393
+ root.userData.supportBentCount = sampled.length
394
+ root.userData.supportBentLayout = 'continuous-metric-grid'
395
+ root.userData.supportBentPoints = sampled.map(frame => frame.point.toArray())
396
+ root.userData.supportStationPoints = sampled.map(frame => frame.point.toArray())
397
+ root.userData.supportOwnedBayCount = layout.ownedBays.length
398
+ const pathLength = cumulativeDistances(pathFrames).at(-1) ?? 0
399
+ root.userData.openStartInset = sampled[0]?.point.distanceTo(pathFrames[0]!.point) ?? pathLength
400
+ root.userData.openEndInset = sampled.at(-1)?.point.distanceTo(pathFrames.at(-1)!.point) ?? pathLength
401
+ root.userData.stringersMeetConnectorPlanes = true
402
+ root.userData.everyAdjacentBentConnected = true
403
+ }
404
+
405
+ function addSakuraVermilionUnderbridge(
406
+ root: THREE.Group,
407
+ pieceId: string,
408
+ pathFrames: readonly TrackAppearanceSupportFrame[],
409
+ materials: TrackAppearanceSupportMaterials,
410
+ options: TrackAppearanceSupportOptions,
411
+ ): void {
412
+ const layout = sampleContinuousSupportLayout(pathFrames, options)
413
+ const sampled = layout.stations
414
+ const timber: Beam[] = []
415
+ const hardware: Beam[] = []
416
+
417
+ for (const frame of sampled) {
418
+ const halfSpan = underTrackSupportHalfSpan(frame.width)
419
+ const topCenter = frame.point.clone().addScaledVector(frame.up, -0.43)
420
+ const left = topCenter.clone().addScaledVector(frame.right, -halfSpan)
421
+ const right = topCenter.clone().addScaledVector(frame.right, halfSpan)
422
+ const fadeAnchorY = Math.max(left.y, right.y)
423
+ const leftBottom = left.clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_DEPTH)
424
+ const rightBottom = right.clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_DEPTH)
425
+ timber.push(
426
+ beam(
427
+ leftBottom,
428
+ left,
429
+ 0.72 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
430
+ 0.66 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
431
+ fadeAnchorY,
432
+ ),
433
+ beam(
434
+ rightBottom,
435
+ right,
436
+ 0.72 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
437
+ 0.66 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
438
+ fadeAnchorY,
439
+ ),
440
+ beam(
441
+ left.clone().addScaledVector(frame.right, -0.28 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
442
+ right.clone().addScaledVector(frame.right, 0.28 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
443
+ 0.82 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
444
+ 0.52 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
445
+ fadeAnchorY,
446
+ ),
447
+ )
448
+ for (const depth of [0.65, 1.15].map(value => value * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE)) {
449
+ for (const post of [left, right]) {
450
+ hardware.push(
451
+ beam(
452
+ post
453
+ .clone()
454
+ .addScaledVector(frame.right, -0.41 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE)
455
+ .addScaledVector(WORLD_UP, -depth),
456
+ post
457
+ .clone()
458
+ .addScaledVector(frame.right, 0.41 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE)
459
+ .addScaledVector(WORLD_UP, -depth),
460
+ 0.15 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
461
+ 0.78 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
462
+ fadeAnchorY,
463
+ ),
464
+ )
465
+ }
466
+ }
467
+ for (const side of [-1, 1] as const) {
468
+ const post = side < 0 ? left : right
469
+ for (const [step, width] of [
470
+ [0.55, 1.28],
471
+ [0.91, 1.82],
472
+ [1.27, 2.32],
473
+ ] as const) {
474
+ timber.push(
475
+ beam(
476
+ post
477
+ .clone()
478
+ .addScaledVector(frame.right, -side * width * 0.5 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE)
479
+ .addScaledVector(WORLD_UP, -step * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
480
+ post
481
+ .clone()
482
+ .addScaledVector(frame.right, side * width * 0.5 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE)
483
+ .addScaledVector(WORLD_UP, -step * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
484
+ 0.31 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
485
+ 0.38 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
486
+ fadeAnchorY,
487
+ ),
488
+ )
489
+ }
490
+ }
491
+ }
492
+
493
+ for (let index = 0; index < pathFrames.length - 1; index += 1) {
494
+ for (const side of [-1, 1] as const) {
495
+ const current = supportContact(pathFrames[index]!, side)
496
+ const next = supportContact(pathFrames[index + 1]!, side)
497
+ timber.push(
498
+ beam(current, next, 0.5 * UNDER_TRACK_SUPPORT_MEMBER_SCALE, 0.42 * UNDER_TRACK_SUPPORT_MEMBER_SCALE),
499
+ beam(
500
+ current.clone().addScaledVector(WORLD_UP, -0.52 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
501
+ next.clone().addScaledVector(WORLD_UP, -0.52 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
502
+ 0.42 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
503
+ 0.38 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
504
+ ),
505
+ )
506
+ }
507
+ }
508
+ for (const ownedBay of layout.ownedBays) {
509
+ const current = sakuraBentAtFrame(ownedBay.start)
510
+ const next = sakuraBentAtFrame(ownedBay.end)
511
+ const fadeAnchorY = Math.max(current.fadeAnchorY, next.fadeAnchorY)
512
+ for (const side of ['left', 'right'] as const) {
513
+ timber.push(
514
+ beam(
515
+ current[side].clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_FADE_START_DEPTH),
516
+ next[side].clone().addScaledVector(WORLD_UP, -1.65 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
517
+ 0.28 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
518
+ 0.34 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
519
+ fadeAnchorY,
520
+ ),
521
+ beam(
522
+ current[side].clone().addScaledVector(WORLD_UP, -1.65 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
523
+ next[side].clone().addScaledVector(WORLD_UP, -UNDER_TRACK_SUPPORT_FADE_START_DEPTH),
524
+ 0.28 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
525
+ 0.34 * UNDER_TRACK_SUPPORT_MEMBER_SCALE,
526
+ fadeAnchorY,
527
+ ),
528
+ )
529
+ }
530
+ }
531
+
532
+ addBeamBatch(root, `${pieceId}-sakura-underbridge-timber`, timber, materials.support, 'sakura-underbridge-timber')
533
+ addBeamBatch(root, `${pieceId}-sakura-underbridge-hardware`, hardware, materials.metal, 'sakura-underbridge-hardware')
534
+ root.userData.supportBentCount = sampled.length
535
+ root.userData.supportBentLayout = 'continuous-metric-grid'
536
+ root.userData.stringersMeetConnectorPlanes = true
537
+ root.userData.supportStationPoints = sampled.map(frame => frame.point.toArray())
538
+ root.userData.supportOwnedBayCount = layout.ownedBays.length
539
+ }
540
+
541
+ function addSakuraDragonStoneSupport(
542
+ root: THREE.Group,
543
+ pieceId: string,
544
+ pathFrames: readonly TrackAppearanceSupportFrame[],
545
+ material: THREE.Material,
546
+ options: TrackAppearanceSupportOptions,
547
+ ): void {
548
+ const layout = sampleContinuousSupportLayout(pathFrames, options)
549
+ const sampled = layout.stations
550
+ void addJapaneseSakuraCastleSupportModel(root, pieceId, pathFrames, sampled, material, {
551
+ ...options,
552
+ ownedBayDistances: layout.ownedBays.map(bay => [bay.startDistance, bay.endDistance]),
553
+ }).then(() => {
554
+ if (root.userData.supportDisposed !== true) addSupportDepthPrepasses(root)
555
+ })
556
+ root.userData.supportBentCount = sampled.length
557
+ root.userData.supportBentLayout = 'continuous-metric-grid-approved-blender-modules'
558
+ root.userData.hasDragonHero = false
559
+ root.userData.dragonSideCoverage = 'none'
560
+ root.userData.supportStationPoints = sampled.map(frame => frame.point.toArray())
561
+ root.userData.supportOwnedBayCount = layout.ownedBays.length
562
+ }
563
+
564
+ function supportContact(frame: TrackAppearanceSupportFrame, side: -1 | 1): THREE.Vector3 {
565
+ return frame.point
566
+ .clone()
567
+ .addScaledVector(frame.up, -0.39)
568
+ .addScaledVector(frame.right, side * underTrackSupportHalfSpan(frame.width))
569
+ }
570
+
571
+ function sampleContinuousSupportLayout(
572
+ frames: readonly TrackAppearanceSupportFrame[],
573
+ options: TrackAppearanceSupportOptions,
574
+ ): SampledSupportLayout {
575
+ const distances = cumulativeDistances(frames)
576
+ const length = distances.at(-1) ?? 0
577
+ const layout = createUnderTrackSupportLayout(
578
+ length,
579
+ options.connectionPhase,
580
+ options.capStart === false,
581
+ options.capEnd === false,
582
+ )
583
+ const excluded = options.excludedStationPoints ?? []
584
+ const allowed = (frame: TrackAppearanceSupportFrame) =>
585
+ excluded.every(point => point.distanceTo(frame.point) >= underTrackSupportStationClearance(frame.width))
586
+ const stations = layout.centers.map(center => frameAtDistance(frames, distances, center)).filter(allowed)
587
+ const ownedBays = layout.ownedBays
588
+ .map(bay => ({
589
+ start: frameAtDistance(frames, distances, bay.start),
590
+ end: frameAtDistance(frames, distances, bay.end),
591
+ startDistance: bay.start,
592
+ endDistance: bay.end,
593
+ }))
594
+ .filter(bay => allowed(bay.start) && allowed(bay.end))
595
+ return { stations, ownedBays }
596
+ }
597
+
598
+ function addStackedCanyonButtresses(
599
+ root: THREE.Group,
600
+ pieceId: string,
601
+ pathFrames: readonly TrackAppearanceSupportFrame[],
602
+ material: THREE.Material,
603
+ options: TrackAppearanceSupportOptions,
604
+ ): void {
605
+ const layout = sampleContinuousSupportLayout(pathFrames, options)
606
+ const frames = layout.stations
607
+ const geometry = createStackedCanyonGeometry(pieceId, frames)
608
+ const buttresses = new THREE.Mesh(geometry, material)
609
+ buttresses.name = `${pieceId}-stacked-canyon-buttresses`
610
+ buttresses.userData.supportRole = 'stacked-canyon-buttresses'
611
+ buttresses.userData.supportCourseCount = CANYON_COURSE_SCHEDULE.length
612
+ buttresses.userData.supportFadeAttribute = 'supportFade'
613
+ root.userData.supportBentCount = frames.length
614
+ root.userData.supportStationPoints = frames.map(frame => frame.point.toArray())
615
+ root.userData.supportOwnedBayCount = layout.ownedBays.length
616
+ const pathLength = cumulativeDistances(pathFrames).at(-1) ?? 0
617
+ root.userData.openStartInset =
618
+ options.capStart === false ? (frames[0]?.point.distanceTo(pathFrames[0]!.point) ?? pathLength) : 0
619
+ root.userData.openEndInset =
620
+ options.capEnd === false ? (frames.at(-1)?.point.distanceTo(pathFrames.at(-1)!.point) ?? pathLength) : 0
621
+ root.add(buttresses)
622
+ }
623
+
624
+ function createStackedCanyonGeometry(
625
+ pieceId: string,
626
+ frames: readonly TrackAppearanceSupportFrame[],
627
+ ): THREE.BufferGeometry {
628
+ const vertices: number[] = []
629
+ const uvs: number[] = []
630
+ const fades: number[] = []
631
+ const indices: number[] = []
632
+ const topContactVertexIndices: number[] = []
633
+
634
+ for (const [buttressIndex, frame] of frames.entries()) {
635
+ const contactPlanePoint = frame.point.clone().addScaledVector(frame.up, -SUPPORT_DECK_UNDERSIDE_OFFSET)
636
+ const topY = contactPlanePoint.y
637
+ const right = frame.right.clone().setY(0)
638
+ if (right.lengthSq() < 0.000_001) right.set(frame.tangent.z, 0, -frame.tangent.x)
639
+ right.normalize()
640
+ const tangent = new THREE.Vector3().crossVectors(right, WORLD_UP).normalize()
641
+ if (tangent.dot(frame.tangent) < 0) tangent.negate()
642
+ const baseWidth = Math.max(2.9, frame.width * 0.9) * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE
643
+ const baseDepth = 2.9 * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE
644
+ const moduleWidth = 0.96 + deterministic(`${pieceId}:${buttressIndex}:stack-width`) * 0.08
645
+ const moduleDepth = 0.96 + deterministic(`${pieceId}:${buttressIndex}:stack-depth`) * 0.08
646
+
647
+ for (const [
648
+ courseIndex,
649
+ [topFraction, bottomFraction, widthScale, depthScale, split],
650
+ ] of CANYON_COURSE_SCHEDULE.entries()) {
651
+ const courseWidth = baseWidth * widthScale * moduleWidth
652
+ const courseDepth = baseDepth * depthScale * moduleDepth
653
+ const seed = `${pieceId}:${buttressIndex}:${courseIndex}`
654
+ const centerRight = (deterministic(`${seed}:stack-x`) - 0.5) * baseWidth * 0.09
655
+ const centerTangent = (deterministic(`${seed}:stack-z`) - 0.5) * baseDepth * 0.055
656
+ const inset = UNDER_TRACK_SUPPORT_DEPTH * 0.000_35
657
+ const courseTopY = topY - topFraction * UNDER_TRACK_SUPPORT_DEPTH - inset
658
+ const courseBottomY = topY - bottomFraction * UNDER_TRACK_SUPPORT_DEPTH + inset
659
+ const topContactBlend = Math.max(0, 1 - topFraction / CANYON_SLOPE_TRANSITION_DEPTH_FRACTION)
660
+ const bottomContactBlend = Math.max(0, 1 - bottomFraction / CANYON_SLOPE_TRANSITION_DEPTH_FRACTION)
661
+ const splitThisCourse = split && deterministic(`${seed}:split`) > 0.52
662
+ const blocks: { width: number; centerRight: number }[] = []
663
+ if (splitThisCourse) {
664
+ const joint = baseWidth * 0.025
665
+ const leftShare = 0.46 + deterministic(`${seed}:split-share`) * 0.1
666
+ const leftWidth = courseWidth * leftShare - joint / 2
667
+ const rightWidth = courseWidth * (1 - leftShare) - joint / 2
668
+ blocks.push(
669
+ { width: leftWidth, centerRight: centerRight - courseWidth / 2 + leftWidth / 2 },
670
+ { width: rightWidth, centerRight: centerRight + courseWidth / 2 - rightWidth / 2 },
671
+ )
672
+ } else {
673
+ blocks.push({ width: courseWidth, centerRight })
674
+ }
675
+ for (const [blockIndex, block] of blocks.entries()) {
676
+ appendCanyonCourse(
677
+ { vertices, uvs, fades, indices, topContactVertexIndices },
678
+ {
679
+ topY: courseTopY,
680
+ bottomY: courseBottomY,
681
+ width: block.width,
682
+ depth: courseDepth,
683
+ center: frame.point
684
+ .clone()
685
+ .addScaledVector(right, block.centerRight)
686
+ .addScaledVector(tangent, centerTangent),
687
+ right,
688
+ tangent,
689
+ fadeAnchorY: topY,
690
+ seed: `${seed}:${blockIndex}`,
691
+ topContactPlane: topContactBlend > 0 ? { point: contactPlanePoint, normal: frame.up } : undefined,
692
+ topContactBlendRange: [bottomContactBlend, topContactBlend],
693
+ },
694
+ )
695
+ }
696
+ }
697
+ }
698
+
699
+ const geometry = new THREE.BufferGeometry()
700
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
701
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
702
+ geometry.setAttribute('uv1', new THREE.Float32BufferAttribute(uvs, 2))
703
+ const themeNetUvs = new Float32Array((uvs.length / 2) * 4)
704
+ for (let index = 0; index < uvs.length; index += 2) {
705
+ const target = index * 2
706
+ themeNetUvs[target] = uvs[index] ?? 0
707
+ themeNetUvs[target + 1] = uvs[index + 1] ?? 0
708
+ themeNetUvs[target + 2] = 1
709
+ themeNetUvs[target + 3] = 1
710
+ }
711
+ geometry.setAttribute('themeNetUv', new THREE.BufferAttribute(themeNetUvs, 4))
712
+ geometry.setAttribute('supportFade', new THREE.Float32BufferAttribute(fades, 1))
713
+ geometry.setIndex(indices)
714
+ geometry.computeVertexNormals()
715
+ geometry.computeBoundingBox()
716
+ geometry.computeBoundingSphere()
717
+ geometry.userData.supportGeometry = 'spaced-stacked-canyon-buttresses'
718
+ geometry.userData.supportCourseCount = CANYON_COURSE_SCHEDULE.length
719
+ geometry.userData.supportButtressCount = frames.length
720
+ geometry.userData.supportDepth = UNDER_TRACK_SUPPORT_DEPTH
721
+ geometry.userData.supportFadeDepth = UNDER_TRACK_SUPPORT_FADE_DEPTH
722
+ geometry.userData.supportCapSlopeMatched = true
723
+ geometry.userData.supportTopContactVertexIndices = topContactVertexIndices
724
+ return geometry
725
+ }
726
+
727
+ interface CanyonGeometryBuffers {
728
+ vertices: number[]
729
+ uvs: number[]
730
+ fades: number[]
731
+ indices: number[]
732
+ topContactVertexIndices: number[]
733
+ }
734
+
735
+ interface CanyonCourseOptions {
736
+ topY: number
737
+ bottomY: number
738
+ width: number
739
+ depth: number
740
+ center: THREE.Vector3
741
+ right: THREE.Vector3
742
+ tangent: THREE.Vector3
743
+ fadeAnchorY: number
744
+ seed: string
745
+ topContactPlane?: {
746
+ point: THREE.Vector3
747
+ normal: THREE.Vector3
748
+ }
749
+ topContactBlendRange: readonly [bottom: number, top: number]
750
+ }
751
+
752
+ function appendCanyonCourse(buffers: CanyonGeometryBuffers, options: CanyonCourseOptions): void {
753
+ const {
754
+ topY,
755
+ bottomY,
756
+ width,
757
+ depth,
758
+ center,
759
+ right,
760
+ tangent,
761
+ fadeAnchorY,
762
+ seed,
763
+ topContactPlane,
764
+ topContactBlendRange,
765
+ } = options
766
+ const courseHeight = topY - bottomY
767
+ const bevel = Math.min(
768
+ courseHeight * (0.12 + deterministic(`${seed}:bevel-height`) * 0.13),
769
+ Math.min(width, depth) * 0.07,
770
+ )
771
+ const ringSchedule = [
772
+ [bottomY, 0.93 + deterministic(`${seed}:bottom-inset`) * 0.045],
773
+ [bottomY + bevel, 0.96 + deterministic(`${seed}:lower-shoulder`) * 0.08],
774
+ [topY - bevel, 0.97 + deterministic(`${seed}:upper-shoulder`) * 0.09],
775
+ [topY, 0.93 + deterministic(`${seed}:top-inset`) * 0.045],
776
+ ] as const
777
+ const rings = ringSchedule.map(([y, scale], ringIndex) => ({
778
+ y,
779
+ topContactBlend: topContactPlane
780
+ ? THREE.MathUtils.lerp(
781
+ topContactBlendRange[0],
782
+ topContactBlendRange[1],
783
+ THREE.MathUtils.clamp((y - bottomY) / courseHeight, 0, 1),
784
+ )
785
+ : 0,
786
+ points: createCanyonCourseContour((width * scale) / 2, (depth * scale) / 2, seed, ringIndex),
787
+ }))
788
+
789
+ const worldPoint = (point: THREE.Vector2, y: number, topContactBlend = 0) => {
790
+ const world = center.clone().addScaledVector(right, point.x).addScaledVector(tangent, point.y).setY(y)
791
+ if (!topContactPlane || topContactBlend <= 0 || Math.abs(topContactPlane.normal.y) <= 0.000_001) return world
792
+ const contactY =
793
+ topContactPlane.point.y -
794
+ (topContactPlane.normal.x * (world.x - topContactPlane.point.x) +
795
+ topContactPlane.normal.z * (world.z - topContactPlane.point.z)) /
796
+ topContactPlane.normal.y
797
+ world.y += (contactY - topY) * topContactBlend
798
+ return world
799
+ }
800
+ const fadeAt = (y: number) => underTrackSupportFadeAtDepth(fadeAnchorY - y)
801
+
802
+ for (let ringIndex = 0; ringIndex < rings.length - 1; ringIndex += 1) {
803
+ const lower = rings[ringIndex]!
804
+ const upper = rings[ringIndex + 1]!
805
+ let lowerU = 0
806
+ let upperU = 0
807
+ for (let side = 0; side < lower.points.length; side += 1) {
808
+ const next = (side + 1) % lower.points.length
809
+ const a = lower.points[side]!
810
+ const b = lower.points[next]!
811
+ const c = upper.points[side]!
812
+ const d = upper.points[next]!
813
+ const base = buffers.vertices.length / 3
814
+ const world = [
815
+ worldPoint(a, lower.y, lower.topContactBlend),
816
+ worldPoint(b, lower.y, lower.topContactBlend),
817
+ worldPoint(c, upper.y, upper.topContactBlend),
818
+ worldPoint(d, upper.y, upper.topContactBlend),
819
+ ]
820
+ world.forEach(point => buffers.vertices.push(point.x, point.y, point.z))
821
+ if (topContactPlane && upper.topContactBlend === 1) {
822
+ buffers.topContactVertexIndices.push(base + 2, base + 3)
823
+ }
824
+ const lowerNextU = lowerU + a.distanceTo(b)
825
+ const upperNextU = upperU + c.distanceTo(d)
826
+ buffers.uvs.push(lowerU, -lower.y, lowerNextU, -lower.y, upperU, -upper.y, upperNextU, -upper.y)
827
+ buffers.fades.push(fadeAt(lower.y), fadeAt(lower.y), fadeAt(upper.y), fadeAt(upper.y))
828
+ buffers.indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3)
829
+ lowerU = lowerNextU
830
+ upperU = upperNextU
831
+ }
832
+ }
833
+
834
+ for (const [ring, isTop] of [
835
+ [rings[0]!, false],
836
+ [rings.at(-1)!, true],
837
+ ] as const) {
838
+ const centerIndex = buffers.vertices.length / 3
839
+ const capCenter = worldPoint(new THREE.Vector2(), ring.y, ring.topContactBlend)
840
+ buffers.vertices.push(capCenter.x, capCenter.y, capCenter.z)
841
+ if (topContactPlane && isTop && ring.topContactBlend === 1) {
842
+ buffers.topContactVertexIndices.push(centerIndex)
843
+ }
844
+ buffers.uvs.push(center.x, center.z)
845
+ buffers.fades.push(fadeAt(ring.y))
846
+ for (let side = 0; side < ring.points.length; side += 1) {
847
+ const next = (side + 1) % ring.points.length
848
+ const base = buffers.vertices.length / 3
849
+ const a = worldPoint(ring.points[side]!, ring.y, ring.topContactBlend)
850
+ const b = worldPoint(ring.points[next]!, ring.y, ring.topContactBlend)
851
+ buffers.vertices.push(a.x, a.y, a.z, b.x, b.y, b.z)
852
+ if (topContactPlane && isTop && ring.topContactBlend === 1) {
853
+ buffers.topContactVertexIndices.push(base, base + 1)
854
+ }
855
+ buffers.uvs.push(a.x, a.z, b.x, b.z)
856
+ buffers.fades.push(fadeAt(ring.y), fadeAt(ring.y))
857
+ buffers.indices.push(...(isTop ? [centerIndex, base + 1, base] : [centerIndex, base, base + 1]))
858
+ }
859
+ }
860
+ }
861
+
862
+ function createCanyonCourseContour(
863
+ halfWidth: number,
864
+ halfDepth: number,
865
+ seed: string,
866
+ ringIndex: number,
867
+ ): THREE.Vector2[] {
868
+ const pointCount = 20
869
+ const exponent = 4.6 + deterministic(`${seed}:course-boxiness`) * 1.8
870
+ const phase = deterministic(`${seed}:course-phase`) * Math.PI * 2
871
+ return Array.from({ length: pointCount }, (_, index) => {
872
+ const angle = -Math.PI * 0.75 + (index / pointCount) * Math.PI * 2
873
+ const cosine = Math.cos(angle)
874
+ const sine = Math.sin(angle)
875
+ const x = Math.sign(cosine) * Math.pow(Math.abs(cosine), 2 / exponent) * halfWidth
876
+ const z = Math.sign(sine) * Math.pow(Math.abs(sine), 2 / exponent) * halfDepth
877
+ const broadChip = Math.sin(angle * 3 + phase) * 0.052 + Math.sin(angle * 5 - phase) * 0.028
878
+ const cornerLoss = (deterministic(`${seed}:course-chip:${index}`) - 0.5) * 0.105
879
+ const ringVariation = (deterministic(`${seed}:course-ring:${ringIndex}:${index}`) - 0.5) * 0.05
880
+ const displacement = Math.min(halfWidth, halfDepth) * (broadChip + cornerLoss + ringVariation)
881
+ const length = Math.hypot(x, z) || 1
882
+ return new THREE.Vector2(x + (x / length) * displacement, z + (z / length) * displacement)
883
+ })
884
+ }
885
+
886
+ function beam(
887
+ start: THREE.Vector3,
888
+ end: THREE.Vector3,
889
+ width: number,
890
+ depth: number,
891
+ fadeAnchorY = Math.max(start.y, end.y),
892
+ ): Beam {
893
+ return { start, end, width, depth, fadeAnchorY }
894
+ }
895
+
896
+ function addBeamBatch(
897
+ root: THREE.Group,
898
+ name: string,
899
+ beams: readonly Beam[],
900
+ material: THREE.Material,
901
+ role: string,
902
+ ): void {
903
+ if (beams.length === 0) return
904
+ const geometry = new THREE.BoxGeometry(1, 1, 1)
905
+ const bottomAttribute = new THREE.InstancedBufferAttribute(
906
+ new Float32Array(beams.map(item => item.fadeAnchorY - UNDER_TRACK_SUPPORT_DEPTH)),
907
+ 1,
908
+ )
909
+ const topAttribute = new THREE.InstancedBufferAttribute(
910
+ new Float32Array(beams.map(item => item.fadeAnchorY - UNDER_TRACK_SUPPORT_FADE_START_DEPTH)),
911
+ 1,
912
+ )
913
+ geometry.setAttribute('supportFadeBottom', bottomAttribute)
914
+ geometry.setAttribute('supportFadeTop', topAttribute)
915
+ const mesh = new THREE.InstancedMesh(geometry, material, beams.length)
916
+ mesh.name = name
917
+ for (const [index, item] of beams.entries()) {
918
+ const direction = item.end.clone().sub(item.start)
919
+ const length = direction.length()
920
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(WORLD_UP, direction.normalize())
921
+ mesh.setMatrixAt(
922
+ index,
923
+ new THREE.Matrix4().compose(
924
+ item.start.clone().lerp(item.end, 0.5),
925
+ quaternion,
926
+ new THREE.Vector3(item.width, length, item.depth),
927
+ ),
928
+ )
929
+ }
930
+ mesh.instanceMatrix.needsUpdate = true
931
+ mesh.computeBoundingBox()
932
+ mesh.computeBoundingSphere()
933
+ mesh.userData.supportRole = role
934
+ mesh.userData.supportFadeAttributes = ['supportFadeBottom', 'supportFadeTop']
935
+ root.add(mesh)
936
+ }
937
+
938
+ function cumulativeDistances(frames: readonly TrackAppearanceSupportFrame[]): number[] {
939
+ const distances = [0]
940
+ for (let index = 0; index < frames.length - 1; index += 1) {
941
+ distances.push(distances.at(-1)! + frames[index]!.point.distanceTo(frames[index + 1]!.point))
942
+ }
943
+ return distances
944
+ }
945
+
946
+ function frameAtDistance(
947
+ frames: readonly TrackAppearanceSupportFrame[],
948
+ distances: readonly number[],
949
+ target: number,
950
+ ): TrackAppearanceSupportFrame {
951
+ let segment = 0
952
+ while (segment < distances.length - 2 && distances[segment + 1]! < target) segment += 1
953
+ const length = distances[segment + 1]! - distances[segment]!
954
+ const progress = length <= 0.000_001 ? 0 : (target - distances[segment]!) / length
955
+ const start = frames[segment]!
956
+ const end = frames[segment + 1]!
957
+ const tangent = start.tangent.clone().lerp(end.tangent, progress).normalize()
958
+ const right = start.right.clone().lerp(end.right, progress)
959
+ right.addScaledVector(tangent, -right.dot(tangent))
960
+ if (right.lengthSq() < 0.000_001) right.crossVectors(start.up, tangent)
961
+ right.normalize()
962
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
963
+ if (up.dot(start.up.clone().lerp(end.up, progress)) < 0) {
964
+ right.negate()
965
+ up.negate()
966
+ }
967
+ return {
968
+ point: start.point.clone().lerp(end.point, progress),
969
+ tangent,
970
+ right,
971
+ up,
972
+ width: THREE.MathUtils.lerp(start.width, end.width, progress),
973
+ }
974
+ }
975
+
976
+ function deterministic(seed: string): number {
977
+ let hash = 2_166_136_261
978
+ for (const character of seed) {
979
+ hash ^= character.charCodeAt(0)
980
+ hash = Math.imul(hash, 16_777_619)
981
+ }
982
+ return (hash >>> 0) / 4_294_967_296
983
+ }