@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,1350 @@
1
+ import RAPIER from '@dimforge/rapier3d-deterministic-compat'
2
+ import {
3
+ ReboundPhysicsMechanism,
4
+ ConveyorPhysicsMechanism,
5
+ RisingWallPhysicsMechanism,
6
+ FlipperBankPhysicsMechanism,
7
+ SplitterPhysicsMechanism,
8
+ SPLITTER_CYCLE_SECONDS_RANGE,
9
+ SPLITTER_MAX_TIP_SPEED,
10
+ type SplitterMotionProfile,
11
+ VortexPhysicsMechanism,
12
+ } from '@marble.fun/physics-engine'
13
+ export { SPLITTER_CYCLE_SECONDS_RANGE, SPLITTER_MAX_TIP_SPEED } from '@marble.fun/physics-engine'
14
+ import * as THREE from 'three'
15
+
16
+ import { VisualSphereGeometry, VisualCylinderGeometry, VisualTorusGeometry } from '../../../three/visual-geometry'
17
+
18
+ import type { MaterialSurfaceTreatment, TrackAppearanceDetailStyle } from '../../../three/track-theme'
19
+ import { visualQualityPolicy, type VisualQualityPolicy } from '../../../three/visual-quality'
20
+
21
+ import { FIXED_TIMESTEP } from './race-physics'
22
+ import type {
23
+ ConveyorPieceDefinition,
24
+ DipperPieceDefinition,
25
+ ReboundPieceDefinition,
26
+ FlipperBankPieceDefinition,
27
+ PortalPieceDefinition,
28
+ RisingWallForestPieceDefinition,
29
+ SplitterPieceDefinition,
30
+ TrackVector,
31
+ TurntablePieceDefinition,
32
+ VortexPieceDefinition,
33
+ } from './track-definition'
34
+ import { trackSurfaceBasis } from './track-surface-basis'
35
+ import { markDynamicTrackShadowRoot } from './track-shadow-batching'
36
+ import { risingWallExposedHeight } from '../../track-lab/rising-wall-layout'
37
+ import { addWildWestMineLiftFrame, decorateWildWestMineLiftShelf, WILD_WEST_THEME_ID } from './wild-west-mine-lift'
38
+ import { addJungleTempleLiftSkin, decorateJungleTempleShelf, JUNGLE_TEMPLE_THEME_ID } from './jungle-temple-lift'
39
+ import {
40
+ addPortalDerivedElevatorSkins,
41
+ decoratePortalDerivedElevatorShelf,
42
+ EGYPT_ELEVATOR_THEME_ID,
43
+ JAPAN_ELEVATOR_THEME_ID,
44
+ SPACE_ELEVATOR_THEME_ID,
45
+ } from './portal-derived-elevator-skins'
46
+ import { createReboundVisualFactory } from './rebound-visual'
47
+ import {
48
+ createPortalEndpointVisual,
49
+ PORTAL_LIVE_VIEW_FADE_START_RATIO,
50
+ portalLiveViewBlend,
51
+ positionFixedPortalViewCamera,
52
+ renderFixedPortalView,
53
+ type PortalEndpointVisual,
54
+ } from './portal-visual'
55
+ import { PORTAL_LIVE_VIEW_HEIGHT, PORTAL_LIVE_VIEW_WIDTH } from './portal-proportions'
56
+ import { SPACE_STATION_PORTAL_FRAME_THEME_ID } from './space-station-portal-frame'
57
+ import {
58
+ createEndPivotedFlipperPaddleVisual,
59
+ createFlipperHubVisual,
60
+ createFlipperPaddleVisual,
61
+ } from '../../track-lab/flipper-component'
62
+
63
+ export type FloorSurfaceTreatment = MaterialSurfaceTreatment & {
64
+ material: THREE.Material
65
+ themeId: string | readonly string[]
66
+ }
67
+
68
+ export interface TrackMaterials {
69
+ /** Active theme identity used to gate theme-specific visual geometry. */
70
+ activeThemeId?: string
71
+ /** Track Lab keeps reusable theme geometry resident so theme changes do not rebuild pieces. */
72
+ prebuildThemeVariants?: boolean
73
+ top: THREE.Material
74
+ edge: THREE.Material
75
+ rail: THREE.Material
76
+ railBacking: THREE.Material
77
+ /** Transparent visual shell used only by the open shared rope wall. */
78
+ ropeWallBacking?: THREE.Material
79
+ rope?: THREE.Material
80
+ ropeWallEndGrain?: THREE.Material
81
+ ropeWallTimber?: THREE.Material
82
+ structure: THREE.Material
83
+ moving: THREE.Material
84
+ signal: THREE.Material
85
+ hazard: THREE.Material
86
+ portal: THREE.Material
87
+ energy: THREE.Material
88
+ railroad?: THREE.Material
89
+ /** Geometry family selected by the independent rail/overlay appearance. */
90
+ railDetailStyle?: TrackAppearanceDetailStyle
91
+ railroadTimber?: THREE.Material
92
+ obstacle?: THREE.Material
93
+ /** Generic obstacle trim/end-cap role; Japanese Sakura binds dark gunmetal here. */
94
+ obstacleAccent?: THREE.Material
95
+ /** Generic obstacle mounting/base role; Japanese Sakura binds dark gunmetal here. */
96
+ obstacleBase?: THREE.Material
97
+ /** Rectangular flipper-bar role; Japanese Sakura binds polished silver here. */
98
+ flipperPrimary?: THREE.Material
99
+ /** Flipper base/hub role; Japanese Sakura binds dark gunmetal here. */
100
+ flipperSecondary?: THREE.Material
101
+ mineTimber?: THREE.Material
102
+ coveredMineTimber?: THREE.Material
103
+ coveredMineIron?: THREE.Material
104
+ portalIron?: THREE.Material
105
+ support?: THREE.Material
106
+ bone?: THREE.Material
107
+ floorSurfaceTreatment?: FloorSurfaceTreatment
108
+ /** Theme-gated treatments kept resident so a material-only theme swap can reveal them. */
109
+ floorSurfaceTreatments?: readonly FloorSurfaceTreatment[]
110
+ supportMetal?: THREE.Material
111
+ wildWestBrass?: THREE.Material
112
+ jungleTempleStone?: THREE.Material
113
+ jungleTempleFacade?: THREE.Material
114
+ jungleTempleTimber?: THREE.Material
115
+ jungleTemplePlatform?: THREE.Material
116
+ jungleTempleMoss?: THREE.Material
117
+ jungleTempleBronze?: THREE.Material
118
+ stoneTrackBlock?: THREE.Material
119
+ egyptPortalSandstone?: THREE.Material
120
+ egyptPortalCarvedSandstone?: THREE.Material
121
+ egyptPortalAgedGold?: THREE.Material
122
+ egyptPyramidLimestone?: THREE.Material
123
+ egyptPyramidGold?: THREE.Material
124
+ sakuraCastleTimber?: THREE.Material
125
+ sakuraCastleMasonry?: THREE.Material
126
+ sakuraCastleKawara?: THREE.Material
127
+ sakuraPortalFoundationStone?: THREE.Material
128
+ sakuraBridgeTimber?: THREE.Material
129
+ sakuraBridgeHardware?: THREE.Material
130
+ spaceStationWallPanel?: THREE.Material
131
+ spaceStationWallChassis?: THREE.Material
132
+ spaceStationWallRib?: THREE.Material
133
+ spaceStationWallTrim?: THREE.Material
134
+ spaceStationWallLight?: THREE.Material
135
+ spaceStationCenterGuide?: THREE.Material
136
+ spaceStationFloorDeck?: THREE.Material
137
+ }
138
+
139
+ export interface PortalTeleportEvent {
140
+ slotId: string
141
+ portalId: string
142
+ tick: number
143
+ entryPosition: THREE.Vector3
144
+ exitPosition: THREE.Vector3
145
+ exitDirection: THREE.Vector3
146
+ }
147
+
148
+ export interface TrackRenderBudget {
149
+ portalViewsRemaining: number
150
+ portalCandidateKey?: string
151
+ }
152
+
153
+ export interface TrackPortalRenderCandidate {
154
+ key: string
155
+ score: number
156
+ }
157
+
158
+ export interface TrackMechanismVisualAdapter {
159
+ readonly id: string
160
+ readonly type: string
161
+ readonly bodyCount: number
162
+ readonly colliderCount: number
163
+ readonly sensorCount: number
164
+ portalRenderCandidate?(mainCamera: THREE.Camera): TrackPortalRenderCandidate | undefined
165
+ invalidatePortalViewCache?(): void
166
+ prepareRender?(
167
+ renderer: THREE.WebGLRenderer,
168
+ scene: THREE.Object3D,
169
+ mainCamera: THREE.Camera,
170
+ budget?: TrackRenderBudget,
171
+ ): void
172
+ setVisualQualityPolicy?(policy: VisualQualityPolicy): void
173
+ dispose?(): void
174
+ }
175
+
176
+ function toVector3(value: TrackVector): THREE.Vector3 {
177
+ return new THREE.Vector3(value[0], value[1], value[2])
178
+ }
179
+
180
+ function toRapierRotation(quaternion: THREE.Quaternion): RAPIER.Rotation {
181
+ return { x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }
182
+ }
183
+
184
+ function createPortalRenderTarget(name: string, scale: number): THREE.WebGLRenderTarget {
185
+ const target = new THREE.WebGLRenderTarget(
186
+ Math.max(1, Math.round(PORTAL_LIVE_VIEW_WIDTH * scale)),
187
+ Math.max(1, Math.round(PORTAL_LIVE_VIEW_HEIGHT * scale)),
188
+ {
189
+ depthBuffer: true,
190
+ stencilBuffer: false,
191
+ minFilter: THREE.LinearFilter,
192
+ magFilter: THREE.LinearFilter,
193
+ generateMipmaps: false,
194
+ },
195
+ )
196
+ target.texture.name = name
197
+ target.texture.colorSpace = THREE.SRGBColorSpace
198
+ return target
199
+ }
200
+
201
+ export const FLIPPER_BANK_MAX_TIP_SPEED = 4.5
202
+
203
+ function flipperMaterialsFor(materials: TrackMaterials): { primary: THREE.Material; secondary: THREE.Material } {
204
+ const fallback = materials.moving
205
+ return {
206
+ primary: materials.flipperPrimary ?? fallback,
207
+ secondary: materials.flipperSecondary ?? fallback,
208
+ }
209
+ }
210
+
211
+ function surfaceRotation(direction: TrackVector): THREE.Quaternion {
212
+ const basis = trackSurfaceBasis(direction)
213
+ return new THREE.Quaternion().setFromRotationMatrix(
214
+ new THREE.Matrix4().makeBasis(toVector3(basis.right), toVector3(basis.up), toVector3(basis.forward).negate()),
215
+ )
216
+ }
217
+
218
+ class SplitterMechanism implements TrackMechanismVisualAdapter {
219
+ readonly type = 'splitter'
220
+ readonly bodyCount = 1
221
+ readonly colliderCount = 4
222
+ readonly sensorCount = 1
223
+ private readonly physics: SplitterPhysicsMechanism
224
+ private readonly visual: THREE.Group
225
+
226
+ constructor(
227
+ readonly id: string,
228
+ world: RAPIER.World,
229
+ root: THREE.Group,
230
+ definition: SplitterPieceDefinition,
231
+ motion: SplitterMotionProfile,
232
+ materials: TrackMaterials,
233
+ existingVisual?: THREE.Group,
234
+ ) {
235
+ const center = toVector3(definition.center)
236
+ this.physics = new SplitterPhysicsMechanism(id, world, definition, motion)
237
+ const dimensions = {
238
+ length: definition.barLength,
239
+ width: definition.barDepth,
240
+ height: definition.barHeight,
241
+ }
242
+ const componentMaterials = flipperMaterialsFor(materials)
243
+ this.visual =
244
+ existingVisual ??
245
+ createEndPivotedFlipperPaddleVisual(
246
+ `${id}-splitter-paddle`,
247
+ componentMaterials.primary,
248
+ dimensions,
249
+ componentMaterials.secondary,
250
+ `${id}-splitter-hub`,
251
+ 0,
252
+ )
253
+ this.visual.userData.cycleSecondsRange = [
254
+ Math.max(this.physics.minimumCycleSeconds, SPLITTER_CYCLE_SECONDS_RANGE[0]),
255
+ Math.max(this.physics.minimumCycleSeconds, SPLITTER_CYCLE_SECONDS_RANGE[1]),
256
+ ]
257
+ this.visual.userData.maximumTipSpeed = SPLITTER_MAX_TIP_SPEED
258
+ this.visual.userData.motionProfile = 'seeded-smooth-variable-cycle-sweep'
259
+ this.visual.userData.rapierBodyType = 'kinematic-position'
260
+ this.visual.userData.sweepPhaseCycles = this.physics.sweepPhaseCycles
261
+ this.visual.userData.initialSide = this.physics.initialSide
262
+ this.visual.position.copy(center)
263
+ this.visual.frustumCulled = false
264
+ markDynamicTrackShadowRoot(this.visual)
265
+ if (!existingVisual) root.add(this.visual)
266
+ }
267
+ }
268
+
269
+ class RisingWallForestMechanism implements TrackMechanismVisualAdapter {
270
+ readonly type = 'rising-wall-forest'
271
+ readonly bodyCount: number
272
+ readonly colliderCount: number
273
+ readonly sensorCount = 0
274
+ private readonly physics: RisingWallPhysicsMechanism
275
+ private readonly visuals: THREE.Mesh[] = []
276
+
277
+ constructor(
278
+ readonly id: string,
279
+ world: RAPIER.World,
280
+ private readonly definition: RisingWallForestPieceDefinition,
281
+ simulationKey: string,
282
+ root: THREE.Group,
283
+ materials: TrackMaterials,
284
+ existingVisuals?: readonly THREE.Mesh[],
285
+ ) {
286
+ const reusesExistingVisuals = existingVisuals?.length === definition.anchors.length
287
+ this.physics = new RisingWallPhysicsMechanism(id, world, definition, simulationKey)
288
+ this.bodyCount = this.physics.bodyCount
289
+ this.colliderCount = this.physics.colliderCount
290
+ const basis = trackSurfaceBasis(definition.direction)
291
+ const slotUp = toVector3(basis.up)
292
+ const slotRotation = surfaceRotation(definition.direction)
293
+ const wallGeometry = reusesExistingVisuals
294
+ ? undefined
295
+ : new THREE.BoxGeometry(definition.wallWidth, definition.wallHeight, definition.wallDepth)
296
+ const slotGeometry = reusesExistingVisuals
297
+ ? undefined
298
+ : new THREE.BoxGeometry(definition.wallWidth + 0.14, 0.035, definition.wallDepth + 0.14)
299
+ definition.anchors.forEach((anchor, wallIndex) => {
300
+ const fraction = this.physics.heights[wallIndex]!
301
+ const wall = reusesExistingVisuals
302
+ ? existingVisuals![wallIndex]!
303
+ : new THREE.Mesh(wallGeometry!, materials.obstacle ?? materials.hazard)
304
+ if (!reusesExistingVisuals) {
305
+ wall.name = `${id}-wall-${wallIndex}`
306
+ wall.castShadow = true
307
+ wall.receiveShadow = true
308
+ root.add(wall)
309
+ const slot = new THREE.Mesh(slotGeometry!, materials.obstacleBase ?? materials.signal)
310
+ slot.name = `${id}-wall-slot-${wallIndex}`
311
+ slot.position.copy(toVector3(anchor.position)).addScaledVector(slotUp, 0.018)
312
+ slot.quaternion.copy(slotRotation)
313
+ slot.receiveShadow = true
314
+ root.add(slot)
315
+ }
316
+ wall.userData.risingWallVisual = {
317
+ anchor: [...anchor.position],
318
+ up: [...this.physics.up],
319
+ wallHeight: definition.wallHeight,
320
+ }
321
+ markDynamicTrackShadowRoot(wall)
322
+ this.visuals.push(wall)
323
+ this.updateWallVisual(wallIndex, fraction)
324
+ })
325
+ }
326
+
327
+ private updateWallVisual(index: number, fraction: number): void {
328
+ const exposedHeight = risingWallExposedHeight(this.definition.wallHeight, fraction)
329
+ const wall = this.visuals[index]!
330
+ wall.position
331
+ .copy(toVector3(this.definition.anchors[index]!.position))
332
+ .addScaledVector(toVector3(this.physics.up), exposedHeight / 2)
333
+ wall.quaternion.set(...this.physics.rotation)
334
+ wall.scale.set(1, exposedHeight / this.definition.wallHeight, 1)
335
+ wall.visible = exposedHeight > 0.001
336
+ }
337
+ }
338
+
339
+ export { deterministicPortalExitVariation } from '@marble.fun/physics-engine'
340
+
341
+ class PortalMechanism implements TrackMechanismVisualAdapter {
342
+ readonly type = 'portal'
343
+ readonly bodyCount = 0
344
+ readonly colliderCount = 0
345
+ readonly sensorCount = 0
346
+ private readonly visual: THREE.Group
347
+ private readonly entryVisual: PortalEndpointVisual
348
+ private readonly exitVisual: PortalEndpointVisual
349
+ private readonly liveTargets: Partial<Record<'entry' | 'exit', THREE.WebGLRenderTarget>> = {}
350
+ private readonly liveCamera = new THREE.PerspectiveCamera(62, 1, 0.08, 260)
351
+ private readonly liveViewProjection = new THREE.Matrix4()
352
+ private readonly liveViewFrustum = new THREE.Frustum()
353
+ private readonly liveViewBounds = new THREE.Sphere()
354
+ private readonly liveViewProjectedCenter = new THREE.Vector3()
355
+ private readonly liveViewWorldQuaternion = new THREE.Quaternion()
356
+ private readonly liveViewWorldNormal = new THREE.Vector3()
357
+ private readonly liveViewCameraPosition = new THREE.Vector3()
358
+ private readonly liveViewToCamera = new THREE.Vector3()
359
+ private qualityPolicy: VisualQualityPolicy
360
+ private readonly portalColor: string
361
+
362
+ constructor(
363
+ readonly id: string,
364
+ root: THREE.Group,
365
+ definition: PortalPieceDefinition,
366
+ materials: TrackMaterials,
367
+ qualityPolicy: VisualQualityPolicy,
368
+ existingVisuals?: { entry: PortalEndpointVisual; exit: PortalEndpointVisual },
369
+ ) {
370
+ this.qualityPolicy = qualityPolicy
371
+ this.portalColor = definition.color
372
+ this.visual = new THREE.Group()
373
+ this.visual.name = `${id}-portal-pair`
374
+ const activeThemeId = materials.activeThemeId
375
+ const endpointMaterials = {
376
+ activeThemeId,
377
+ structure: materials.structure,
378
+ timber: materials.mineTimber ?? materials.structure,
379
+ metal: materials.portalIron ?? materials.railroad ?? materials.edge,
380
+ portal: materials.portal,
381
+ energy: materials.energy,
382
+ jungle:
383
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
384
+ ? {
385
+ stone: materials.jungleTempleStone ?? materials.structure,
386
+ facade: materials.jungleTempleFacade ?? materials.railBacking,
387
+ bronze: materials.jungleTempleBronze ?? materials.signal,
388
+ foliage: materials.jungleTempleMoss ?? materials.structure,
389
+ }
390
+ : undefined,
391
+ egypt:
392
+ activeThemeId === EGYPT_ELEVATOR_THEME_ID
393
+ ? {
394
+ sandstone: materials.egyptPortalSandstone ?? materials.structure,
395
+ carvedStone: materials.egyptPortalCarvedSandstone ?? materials.railBacking,
396
+ agedGold: materials.egyptPortalAgedGold ?? materials.signal,
397
+ }
398
+ : undefined,
399
+ sakura:
400
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
401
+ ? {
402
+ vermilion: materials.sakuraBridgeTimber ?? materials.structure,
403
+ plaster: materials.railBacking,
404
+ foundationStone: materials.sakuraPortalFoundationStone ?? materials.structure,
405
+ kawara: materials.sakuraCastleKawara ?? materials.edge,
406
+ hardware: materials.sakuraBridgeHardware ?? materials.portalIron ?? materials.edge,
407
+ }
408
+ : undefined,
409
+ spaceStation:
410
+ activeThemeId === SPACE_STATION_PORTAL_FRAME_THEME_ID
411
+ ? {
412
+ chassis: materials.spaceStationWallChassis ?? materials.railBacking,
413
+ panel: materials.spaceStationWallPanel ?? materials.rail,
414
+ rib: materials.spaceStationWallRib ?? materials.structure,
415
+ trim: materials.spaceStationWallTrim ?? materials.edge,
416
+ light: materials.spaceStationWallLight ?? materials.energy,
417
+ }
418
+ : undefined,
419
+ }
420
+ this.entryVisual =
421
+ existingVisuals?.entry ??
422
+ createPortalEndpointVisual(`${id}-entry`, 'live-window', definition.color, endpointMaterials)
423
+ this.exitVisual =
424
+ existingVisuals?.exit ??
425
+ createPortalEndpointVisual(`${id}-exit`, 'live-window', definition.color, endpointMaterials)
426
+ if (!existingVisuals) {
427
+ for (const [endpoint, center, direction] of [
428
+ [this.entryVisual, toVector3(definition.entryVisualCenter), toVector3(definition.entryVisualDirection)],
429
+ [this.exitVisual, toVector3(definition.exitVisualCenter), toVector3(definition.exitVisualDirection)],
430
+ ] as const) {
431
+ const rotation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction.normalize())
432
+ endpoint.group.position.copy(center)
433
+ endpoint.group.quaternion.copy(rotation)
434
+ this.visual.add(endpoint.group)
435
+ }
436
+ root.add(this.visual)
437
+ }
438
+ this.entryVisual.setFallback(this.portalColor)
439
+ this.exitVisual.setFallback(this.portalColor)
440
+ this.configureLiveTargets()
441
+ }
442
+
443
+ dispose(): void {
444
+ this.disposeLiveTargets()
445
+ }
446
+
447
+ setVisualQualityPolicy(policy: VisualQualityPolicy): void {
448
+ if (this.qualityPolicy === policy) return
449
+ this.qualityPolicy = policy
450
+ this.disposeLiveTargets()
451
+ this.entryVisual.setFallback(this.portalColor)
452
+ this.exitVisual.setFallback(this.portalColor)
453
+ this.configureLiveTargets()
454
+ }
455
+
456
+ invalidatePortalViewCache(): void {
457
+ this.entryVisual.setFallback(this.portalColor)
458
+ this.exitVisual.setFallback(this.portalColor)
459
+ }
460
+
461
+ prepareRender(
462
+ renderer: THREE.WebGLRenderer,
463
+ scene: THREE.Object3D,
464
+ mainCamera: THREE.Camera,
465
+ budget: TrackRenderBudget = { portalViewsRemaining: 2 },
466
+ ): void {
467
+ // Rift animation is independent of destination-camera rendering.
468
+ const renderTimeMs = globalThis.performance.now()
469
+ this.entryVisual.update(renderTimeMs / 1_000)
470
+ this.exitVisual.update(renderTimeMs / 1_000)
471
+ if (budget.portalViewsRemaining <= 0) return
472
+ if (!this.liveTargets.entry && !this.liveTargets.exit) return
473
+ const candidate = this.portalRenderCandidate(mainCamera)
474
+ if (!candidate || (budget.portalCandidateKey && candidate.key !== budget.portalCandidateKey)) return
475
+ const endpoint: 'entry' | 'exit' = candidate.key.endsWith(':entry') ? 'entry' : 'exit'
476
+ const previousTarget = renderer.getRenderTarget()
477
+ const previousXr = renderer.xr.enabled
478
+ const previousEntryVisibility = this.entryVisual.group.visible
479
+ const previousExitVisibility = this.exitVisual.group.visible
480
+ this.entryVisual.group.visible = false
481
+ this.exitVisual.group.visible = false
482
+ renderer.xr.enabled = false
483
+ this.liveCamera.layers.mask = mainCamera.layers.mask
484
+ try {
485
+ if (endpoint === 'entry' && this.liveTargets.entry && budget.portalViewsRemaining > 0) {
486
+ this.renderRemoteView(
487
+ renderer,
488
+ scene,
489
+ mainCamera,
490
+ this.exitVisual.group,
491
+ this.liveTargets.entry,
492
+ )
493
+ this.entryVisual.setLiveTexture(this.liveTargets.entry.texture)
494
+ this.entryVisual.setLiveBlend(portalLiveViewBlend(candidate.score))
495
+ budget.portalViewsRemaining -= 1
496
+ } else if (endpoint === 'exit' && this.liveTargets.exit && budget.portalViewsRemaining > 0) {
497
+ this.renderRemoteView(
498
+ renderer,
499
+ scene,
500
+ mainCamera,
501
+ this.entryVisual.group,
502
+ this.liveTargets.exit,
503
+ )
504
+ this.exitVisual.setLiveTexture(this.liveTargets.exit.texture)
505
+ this.exitVisual.setLiveBlend(portalLiveViewBlend(candidate.score))
506
+ budget.portalViewsRemaining -= 1
507
+ }
508
+ } finally {
509
+ renderer.setRenderTarget(previousTarget)
510
+ renderer.xr.enabled = previousXr
511
+ this.entryVisual.group.visible = previousEntryVisibility
512
+ this.exitVisual.group.visible = previousExitVisibility
513
+ }
514
+ }
515
+
516
+ portalRenderCandidate(mainCamera: THREE.Camera): TrackPortalRenderCandidate | undefined {
517
+ if (!this.liveTargets.entry && !this.liveTargets.exit) return undefined
518
+ mainCamera.updateWorldMatrix(true, false)
519
+ this.liveViewProjection.multiplyMatrices(mainCamera.projectionMatrix, mainCamera.matrixWorldInverse)
520
+ this.liveViewFrustum.setFromProjectionMatrix(this.liveViewProjection)
521
+ const rawEntryScore = this.liveTargets.entry ? this.endpointScreenRatio(this.entryVisual, mainCamera) : 0
522
+ const rawExitScore = this.liveTargets.exit ? this.endpointScreenRatio(this.exitVisual, mainCamera) : 0
523
+ this.entryVisual.setLiveBlend(portalLiveViewBlend(rawEntryScore))
524
+ this.exitVisual.setLiveBlend(portalLiveViewBlend(rawExitScore))
525
+ const entryScore =
526
+ !this.entryVisual.screen.userData.liveTextureReady && rawEntryScore >= PORTAL_LIVE_VIEW_FADE_START_RATIO
527
+ ? rawEntryScore
528
+ : 0
529
+ const exitScore =
530
+ !this.exitVisual.screen.userData.liveTextureReady && rawExitScore >= PORTAL_LIVE_VIEW_FADE_START_RATIO
531
+ ? rawExitScore
532
+ : 0
533
+ if (entryScore <= 0 && exitScore <= 0) return undefined
534
+ return entryScore >= exitScore
535
+ ? { key: `${this.id}:entry`, score: entryScore }
536
+ : { key: `${this.id}:exit`, score: exitScore }
537
+ }
538
+
539
+ private endpointScreenRatio(endpoint: PortalEndpointVisual, camera: THREE.Camera): number {
540
+ let ancestor: THREE.Object3D | null = endpoint.screen
541
+ while (ancestor) {
542
+ if (!ancestor.visible) return 0
543
+ ancestor = ancestor.parent
544
+ }
545
+ if (!camera.layers.test(endpoint.screen.layers)) return 0
546
+ endpoint.screen.updateWorldMatrix(true, false)
547
+ if (!endpoint.screen.geometry.boundingSphere) endpoint.screen.geometry.computeBoundingSphere()
548
+ const localBounds = endpoint.screen.geometry.boundingSphere
549
+ if (!localBounds) return 0
550
+ this.liveViewBounds.copy(localBounds).applyMatrix4(endpoint.screen.matrixWorld)
551
+ if (!this.liveViewFrustum.intersectsSphere(this.liveViewBounds)) return 0
552
+ this.liveViewProjectedCenter.copy(this.liveViewBounds.center).project(camera)
553
+ if (
554
+ Math.abs(this.liveViewProjectedCenter.x) > 1 ||
555
+ Math.abs(this.liveViewProjectedCenter.y) > 1 ||
556
+ this.liveViewProjectedCenter.z < -1 ||
557
+ this.liveViewProjectedCenter.z > 1
558
+ ) {
559
+ return 0
560
+ }
561
+ endpoint.group.getWorldQuaternion(this.liveViewWorldQuaternion)
562
+ this.liveViewWorldNormal.set(0, 0, 1).applyQuaternion(this.liveViewWorldQuaternion)
563
+ camera.getWorldPosition(this.liveViewCameraPosition)
564
+ this.liveViewToCamera.subVectors(this.liveViewCameraPosition, this.liveViewBounds.center)
565
+ const distance = Math.max(0.001, this.liveViewToCamera.length())
566
+ if (this.liveViewWorldNormal.dot(this.liveViewToCamera) <= 0) return 0
567
+ return this.liveViewBounds.radius / distance
568
+ }
569
+
570
+ private renderRemoteView(
571
+ renderer: THREE.WebGLRenderer,
572
+ scene: THREE.Object3D,
573
+ viewerCamera: THREE.Camera,
574
+ destination: THREE.Group,
575
+ target: THREE.WebGLRenderTarget,
576
+ ): void {
577
+ positionFixedPortalViewCamera(
578
+ this.liveCamera,
579
+ viewerCamera,
580
+ destination,
581
+ target.width / target.height,
582
+ )
583
+ renderFixedPortalView(renderer, scene, this.liveCamera, target)
584
+ }
585
+
586
+ private releaseLiveTargetTexture(endpoint: PortalEndpointVisual, target: THREE.WebGLRenderTarget | undefined): void {
587
+ if (!target || !(endpoint.screen.material instanceof THREE.MeshBasicMaterial)) return
588
+ if (endpoint.screen.material.map !== target.texture) return
589
+ endpoint.screen.material.map = null
590
+ endpoint.screen.material.needsUpdate = true
591
+ }
592
+
593
+ private configureLiveTargets(): void {
594
+ if (this.qualityPolicy.portal.mode === 'static') return
595
+ const scale = this.qualityPolicy.portal.renderTargetScale
596
+ this.liveTargets.entry = createPortalRenderTarget(`${this.id}-entry-window`, scale)
597
+ this.liveTargets.exit = createPortalRenderTarget(`${this.id}-exit-window`, scale)
598
+ }
599
+
600
+ private disposeLiveTargets(): void {
601
+ this.releaseLiveTargetTexture(this.entryVisual, this.liveTargets.entry)
602
+ this.releaseLiveTargetTexture(this.exitVisual, this.liveTargets.exit)
603
+ this.liveTargets.entry?.dispose()
604
+ this.liveTargets.exit?.dispose()
605
+ delete this.liveTargets.entry
606
+ delete this.liveTargets.exit
607
+ }
608
+ }
609
+
610
+ const FLIPPER_LOCAL_UP = new THREE.Vector3(0, 1, 0)
611
+
612
+ class FlipperBankMechanism implements TrackMechanismVisualAdapter {
613
+ readonly type = 'flipper-bank'
614
+ readonly bodyCount: number
615
+ readonly colliderCount: number
616
+ readonly sensorCount = 0
617
+ private readonly physics: FlipperBankPhysicsMechanism
618
+
619
+ constructor(
620
+ readonly id: string,
621
+ world: RAPIER.World,
622
+ root: THREE.Group,
623
+ definition: FlipperBankPieceDefinition,
624
+ simulationKey: string,
625
+ materials: TrackMaterials,
626
+ existingVisuals?: readonly THREE.Group[],
627
+ ) {
628
+ const reusesExistingVisuals = existingVisuals?.length === definition.mounts.length
629
+ this.physics = new FlipperBankPhysicsMechanism(id, world, definition, simulationKey)
630
+ this.bodyCount = this.physics.bodyCount
631
+ this.colliderCount = this.physics.colliderCount
632
+
633
+ const dimensions = {
634
+ length: definition.paddleLength,
635
+ width: definition.paddleWidth,
636
+ height: definition.paddleHeight,
637
+ }
638
+ const flipperMaterials = flipperMaterialsFor(materials)
639
+ definition.mounts.forEach((mount, index) => {
640
+ const tangent = toVector3(mount.direction ?? definition.direction).normalize()
641
+ const right = new THREE.Vector3(tangent.z, 0, -tangent.x).normalize()
642
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
643
+ const state = this.physics.flippers[index]!
644
+ const baseRotation = new THREE.Quaternion(...state.baseRotation)
645
+ const hubRotation = new THREE.Quaternion().setFromUnitVectors(FLIPPER_LOCAL_UP, up)
646
+ const hinge = toVector3(state.hinge)
647
+ const bodyPosition = state.body.translation()
648
+ const center = new THREE.Vector3(bodyPosition.x, bodyPosition.y, bodyPosition.z)
649
+
650
+ const visual =
651
+ (reusesExistingVisuals ? existingVisuals![index] : undefined) ??
652
+ createFlipperPaddleVisual(`${id}-paddle-${index + 1}`, flipperMaterials.primary, dimensions)
653
+ visual.position.copy(center)
654
+ visual.quaternion.copy(baseRotation)
655
+ visual.userData.maximumTipSpeed = FLIPPER_BANK_MAX_TIP_SPEED
656
+ visual.userData.strikeSeconds = this.physics.strikeTicks * FIXED_TIMESTEP
657
+ visual.userData.returnSeconds = this.physics.returnTicks * FIXED_TIMESTEP
658
+ visual.userData.motionProfile = 'deterministic-bounded-strike'
659
+ markDynamicTrackShadowRoot(visual)
660
+ if (!reusesExistingVisuals) root.add(visual)
661
+
662
+ if (!reusesExistingVisuals) {
663
+ const hub = createFlipperHubVisual(`${id}-hub-${index + 1}`, flipperMaterials.secondary, dimensions)
664
+ hub.position.copy(hinge)
665
+ hub.quaternion.copy(hubRotation)
666
+ hub.castShadow = true
667
+ hub.receiveShadow = true
668
+ root.add(hub)
669
+ }
670
+
671
+ void visual
672
+ })
673
+ }
674
+ }
675
+
676
+ class ReboundMechanism implements TrackMechanismVisualAdapter {
677
+ readonly type = 'rebound'
678
+ readonly bodyCount: number
679
+ readonly colliderCount: number
680
+ readonly sensorCount = 0
681
+ private readonly physics: ReboundPhysicsMechanism
682
+
683
+ constructor(
684
+ readonly id: string,
685
+ world: RAPIER.World,
686
+ root: THREE.Group,
687
+ definition: ReboundPieceDefinition,
688
+ materials: TrackMaterials,
689
+ existingVisuals?: readonly THREE.Group[],
690
+ ) {
691
+ const reusesExistingVisuals = existingVisuals?.length === definition.mounts.length
692
+ this.physics = new ReboundPhysicsMechanism(id, world, definition)
693
+ this.bodyCount = this.physics.bodyCount
694
+ this.colliderCount = this.physics.colliderCount
695
+ const createVisual = createReboundVisualFactory({
696
+ reboundLength: definition.reboundLength,
697
+ reboundWidth: definition.reboundWidth,
698
+ reboundHeight: definition.reboundHeight,
699
+ bodyMaterial: materials.obstacle ?? materials.hazard,
700
+ accentMaterial: materials.obstacleAccent ?? materials.railroad ?? materials.edge,
701
+ })
702
+
703
+ definition.mounts.forEach((_mount, index) => {
704
+ const state = this.physics.rebounds[index]!
705
+ const bodyPosition = state.body.translation()
706
+ const bodyRotation = state.body.rotation()
707
+
708
+ const visual = (reusesExistingVisuals ? existingVisuals![index] : undefined) ?? createVisual()
709
+ visual.name = `${id}-rebound-${index}`
710
+ visual.position.set(bodyPosition.x, bodyPosition.y, bodyPosition.z)
711
+ visual.quaternion.set(bodyRotation.x, bodyRotation.y, bodyRotation.z, bodyRotation.w)
712
+ visual.frustumCulled = false
713
+ markDynamicTrackShadowRoot(visual)
714
+ if (!reusesExistingVisuals) root.add(visual)
715
+
716
+ void visual
717
+ })
718
+ }
719
+ }
720
+
721
+ class TurntableMechanism implements TrackMechanismVisualAdapter {
722
+ readonly type = 'turntable'
723
+ readonly bodyCount = 1
724
+ readonly colliderCount = 3
725
+ readonly sensorCount = 1
726
+ private readonly body: RAPIER.RigidBody
727
+ private readonly visual: THREE.Group
728
+ private readonly center: THREE.Vector3
729
+ private readonly baseRotation: THREE.Quaternion
730
+ private readonly rotation = new THREE.Quaternion()
731
+
732
+ constructor(
733
+ readonly id: string,
734
+ world: RAPIER.World,
735
+ root: THREE.Group,
736
+ definition: TurntablePieceDefinition,
737
+ materials: TrackMaterials,
738
+ ) {
739
+ if (definition.routeAngles.length < 2) {
740
+ throw new Error(`Turntable ${id} requires at least two route angles`)
741
+ }
742
+ const tangent = toVector3(definition.direction).normalize()
743
+ const right = new THREE.Vector3(tangent.z, 0, -tangent.x).normalize()
744
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
745
+ this.baseRotation = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(right, up, tangent))
746
+ // Sink the rotating disk by its half-height so its top is exactly flush
747
+ // with the authored entry rail. This avoids a hidden curb at the rim.
748
+ this.center = toVector3(definition.center).addScaledVector(up, -0.11)
749
+ this.rotation
750
+ .copy(this.baseRotation)
751
+ .multiply(new THREE.Quaternion().setFromAxisAngle(FLIPPER_LOCAL_UP, definition.routeAngles[0]!))
752
+
753
+ this.body = world.createRigidBody(
754
+ RAPIER.RigidBodyDesc.kinematicPositionBased()
755
+ .setTranslation(this.center.x, this.center.y, this.center.z)
756
+ .setRotation(toRapierRotation(this.rotation)),
757
+ )
758
+ world.createCollider(
759
+ RAPIER.ColliderDesc.cylinder(0.11, definition.radius).setFriction(0.18).setRestitution(0.01),
760
+ this.body,
761
+ )
762
+ const guideLength = definition.radius * 0.95
763
+ for (const side of [-1, 1] as const) {
764
+ world.createCollider(
765
+ RAPIER.ColliderDesc.cuboid(0.11, definition.wallHeight / 2, guideLength / 2)
766
+ .setTranslation(side * (definition.channelWidth / 2 - 0.11), definition.wallHeight / 2 + 0.1, guideLength / 2)
767
+ .setFriction(0.08)
768
+ .setRestitution(0.02),
769
+ this.body,
770
+ )
771
+ }
772
+ world.createCollider(
773
+ RAPIER.ColliderDesc.ball(definition.triggerRadius)
774
+ .setTranslation(this.center.x + up.x * 0.48, this.center.y + up.y * 0.48, this.center.z + up.z * 0.48)
775
+ .setSensor(true),
776
+ )
777
+
778
+ this.visual = new THREE.Group()
779
+ this.visual.name = `${id}-rotating-channel`
780
+ this.visual.position.copy(this.center)
781
+ this.visual.quaternion.copy(this.rotation)
782
+ this.visual.frustumCulled = false
783
+ const disk = new THREE.Mesh(
784
+ new VisualCylinderGeometry(definition.radius, definition.radius, 0.22, 36),
785
+ materials.structure,
786
+ )
787
+ disk.castShadow = true
788
+ disk.receiveShadow = true
789
+ this.visual.add(disk)
790
+ const guideGeometry = new THREE.BoxGeometry(0.22, definition.wallHeight, guideLength)
791
+ for (const side of [-1, 1] as const) {
792
+ const guide = new THREE.Mesh(guideGeometry, materials.hazard)
793
+ guide.position.set(side * (definition.channelWidth / 2 - 0.11), definition.wallHeight / 2 + 0.1, guideLength / 2)
794
+ guide.castShadow = true
795
+ guide.receiveShadow = true
796
+ this.visual.add(guide)
797
+ }
798
+ const hub = new THREE.Mesh(
799
+ new VisualCylinderGeometry(0.48, 0.48, definition.wallHeight + 0.34, 24),
800
+ materials.signal,
801
+ )
802
+ hub.position.y = definition.wallHeight / 2 + 0.1
803
+ hub.castShadow = true
804
+ this.visual.add(hub)
805
+ markDynamicTrackShadowRoot(this.visual)
806
+ root.add(this.visual)
807
+
808
+ const rimGroup = new THREE.Group()
809
+ rimGroup.name = `${id}-fixed-rim`
810
+ rimGroup.position.copy(this.center)
811
+ rimGroup.quaternion.copy(this.baseRotation)
812
+ const rim = new THREE.Mesh(new VisualTorusGeometry(definition.radius + 0.16, 0.14, 10, 40), materials.signal)
813
+ rim.rotation.x = Math.PI / 2
814
+ rim.castShadow = true
815
+ rimGroup.add(rim)
816
+ root.add(rimGroup)
817
+ }
818
+ }
819
+
820
+ class DipperMechanism implements TrackMechanismVisualAdapter {
821
+ readonly type = 'dipper'
822
+ readonly bodyCount = 1
823
+ readonly colliderCount = 3
824
+ readonly sensorCount = 1
825
+ private readonly body: RAPIER.RigidBody
826
+ private readonly visual: THREE.Group
827
+ private readonly hinge: THREE.Vector3
828
+ private readonly baseRotation: THREE.Quaternion
829
+
830
+ constructor(
831
+ readonly id: string,
832
+ world: RAPIER.World,
833
+ root: THREE.Group,
834
+ definition: DipperPieceDefinition,
835
+ materials: TrackMaterials,
836
+ ) {
837
+ const tangent = toVector3(definition.direction).normalize()
838
+ const right = new THREE.Vector3(tangent.z, 0, -tangent.x).normalize()
839
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
840
+ this.hinge = toVector3(definition.hinge)
841
+ const trigger = this.hinge
842
+ .clone()
843
+ .addScaledVector(tangent, definition.length * 0.58)
844
+ .addScaledVector(up, 0.62)
845
+ this.baseRotation = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(right, up, tangent))
846
+
847
+ this.body = world.createRigidBody(
848
+ RAPIER.RigidBodyDesc.kinematicPositionBased()
849
+ .setTranslation(this.hinge.x, this.hinge.y, this.hinge.z)
850
+ .setRotation(toRapierRotation(this.baseRotation)),
851
+ )
852
+ const floorThickness = 0.22
853
+ world.createCollider(
854
+ RAPIER.ColliderDesc.cuboid(definition.width / 2, floorThickness / 2, definition.length / 2)
855
+ .setTranslation(0, -floorThickness / 2, definition.length / 2)
856
+ .setFriction(0.62)
857
+ .setRestitution(0.01),
858
+ this.body,
859
+ )
860
+ for (const side of [-1, 1] as const) {
861
+ world.createCollider(
862
+ RAPIER.ColliderDesc.cuboid(0.12, definition.wallHeight / 2, definition.length / 2)
863
+ .setTranslation(side * (definition.width / 2 - 0.12), definition.wallHeight / 2, definition.length / 2)
864
+ .setFriction(0.08)
865
+ .setRestitution(0.01),
866
+ this.body,
867
+ )
868
+ }
869
+ world.createCollider(
870
+ RAPIER.ColliderDesc.ball(definition.triggerRadius)
871
+ .setTranslation(trigger.x, trigger.y, trigger.z)
872
+ .setSensor(true),
873
+ )
874
+
875
+ this.visual = new THREE.Group()
876
+ this.visual.name = `${id}-lowering-cup`
877
+ this.visual.position.copy(this.hinge)
878
+ this.visual.quaternion.copy(this.baseRotation)
879
+ this.visual.frustumCulled = false
880
+ const floor = new THREE.Mesh(
881
+ new THREE.BoxGeometry(definition.width, floorThickness, definition.length),
882
+ materials.structure,
883
+ )
884
+ floor.position.set(0, -floorThickness / 2, definition.length / 2)
885
+ floor.castShadow = true
886
+ floor.receiveShadow = true
887
+ this.visual.add(floor)
888
+ const sideGeometry = new THREE.BoxGeometry(0.24, definition.wallHeight, definition.length)
889
+ for (const side of [-1, 1] as const) {
890
+ const wall = new THREE.Mesh(sideGeometry, materials.hazard)
891
+ wall.position.set(side * (definition.width / 2 - 0.12), definition.wallHeight / 2, definition.length / 2)
892
+ wall.castShadow = true
893
+ wall.receiveShadow = true
894
+ this.visual.add(wall)
895
+ }
896
+ const counterweight = new THREE.Mesh(new VisualSphereGeometry(0.68, 20, 14), materials.signal)
897
+ counterweight.position.set(0, definition.wallHeight * 0.72, -0.72)
898
+ counterweight.castShadow = true
899
+ this.visual.add(counterweight)
900
+ markDynamicTrackShadowRoot(this.visual)
901
+ root.add(this.visual)
902
+
903
+ const support = new THREE.Group()
904
+ support.name = `${id}-support`
905
+ support.position.copy(this.hinge)
906
+ support.quaternion.copy(this.baseRotation)
907
+ const axle = new THREE.Mesh(new VisualCylinderGeometry(0.22, 0.22, definition.width + 0.6, 18), materials.signal)
908
+ axle.rotation.z = Math.PI / 2
909
+ axle.castShadow = true
910
+ support.add(axle)
911
+ root.add(support)
912
+ }
913
+ }
914
+
915
+ class ConveyorMechanism implements TrackMechanismVisualAdapter {
916
+ readonly type = 'conveyor'
917
+ readonly bodyCount: number
918
+ readonly colliderCount: number
919
+ readonly sensorCount = 0
920
+ private readonly bottom: THREE.Vector3
921
+ private readonly top: THREE.Vector3
922
+ private readonly height: number
923
+ private readonly baseRotation: THREE.Quaternion
924
+ private readonly physics: ConveyorPhysicsMechanism
925
+
926
+ constructor(
927
+ readonly id: string,
928
+ world: RAPIER.World,
929
+ root: THREE.Group,
930
+ definition: ConveyorPieceDefinition,
931
+ phase: number,
932
+ materials: TrackMaterials,
933
+ existingVisuals?: readonly THREE.Mesh[],
934
+ ) {
935
+ const reusesCanonicalVisuals = existingVisuals?.length === definition.shelfCount
936
+ this.physics = new ConveyorPhysicsMechanism(id, world, definition, phase)
937
+ this.bottom = toVector3(definition.bottom)
938
+ this.top = toVector3(definition.top)
939
+ this.height = this.physics.height
940
+ this.bodyCount = this.physics.bodyCount
941
+ this.colliderCount = this.physics.colliderCount
942
+ this.baseRotation = new THREE.Quaternion(...this.physics.baseRotation)
943
+
944
+ const towerCenterY = (this.bottom.y + this.top.y) / 2
945
+ const towerHeight = this.height + 1
946
+ const wallThickness = 0.24
947
+ const activeThemeId = materials.activeThemeId
948
+ for (const side of [-1, 1] as const) {
949
+ const position = this.bottom
950
+ .clone()
951
+ .add(
952
+ new THREE.Vector3(
953
+ side * (definition.width / 2 - wallThickness / 2),
954
+ towerCenterY - this.bottom.y,
955
+ 0,
956
+ ).applyQuaternion(this.baseRotation),
957
+ )
958
+ if (!reusesCanonicalVisuals) {
959
+ const wall = new THREE.Mesh(
960
+ new THREE.BoxGeometry(wallThickness, towerHeight, definition.shelfDepth + 0.16),
961
+ materials.jungleTempleStone ?? materials.rail,
962
+ )
963
+ wall.name = `${id}-side-${side < 0 ? 'left' : 'right'}`
964
+ wall.position.copy(position)
965
+ wall.quaternion.copy(this.baseRotation)
966
+ wall.castShadow = true
967
+ wall.receiveShadow = true
968
+ wall.userData.themeHidden = [
969
+ JUNGLE_TEMPLE_THEME_ID,
970
+ EGYPT_ELEVATOR_THEME_ID,
971
+ JAPAN_ELEVATOR_THEME_ID,
972
+ SPACE_ELEVATOR_THEME_ID,
973
+ ]
974
+ root.add(wall)
975
+ }
976
+ }
977
+
978
+ const outputWallBottom = this.bottom.y - 0.2
979
+ const outputWallTop = definition.releaseHeight - 0.3
980
+ const outputWallHeight = outputWallTop - outputWallBottom
981
+ const outputWallPosition = this.bottom
982
+ .clone()
983
+ .add(
984
+ new THREE.Vector3(
985
+ 0,
986
+ outputWallBottom + outputWallHeight / 2 - this.bottom.y,
987
+ -definition.shelfDepth / 2 + wallThickness / 2,
988
+ ).applyQuaternion(this.baseRotation),
989
+ )
990
+ if (!reusesCanonicalVisuals) {
991
+ const outputWall = new THREE.Mesh(
992
+ new THREE.BoxGeometry(definition.width, outputWallHeight, wallThickness),
993
+ materials.jungleTempleStone ?? materials.rail,
994
+ )
995
+ outputWall.name = `${id}-release-wall`
996
+ outputWall.position.copy(outputWallPosition)
997
+ outputWall.quaternion.copy(this.baseRotation)
998
+ outputWall.castShadow = true
999
+ outputWall.receiveShadow = true
1000
+ outputWall.userData.themeHidden = [
1001
+ JUNGLE_TEMPLE_THEME_ID,
1002
+ EGYPT_ELEVATOR_THEME_ID,
1003
+ JAPAN_ELEVATOR_THEME_ID,
1004
+ SPACE_ELEVATOR_THEME_ID,
1005
+ ]
1006
+ root.add(outputWall)
1007
+ }
1008
+
1009
+ const mineLiftMaterials = {
1010
+ timber:
1011
+ activeThemeId === WILD_WEST_THEME_ID ? (materials.mineTimber ?? materials.structure) : materials.structure,
1012
+ iron: activeThemeId === WILD_WEST_THEME_ID ? (materials.railroad ?? materials.edge) : materials.edge,
1013
+ signal: materials.signal,
1014
+ }
1015
+ const jungleTempleMaterials = {
1016
+ stone:
1017
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID ? (materials.jungleTempleStone ?? materials.rail) : materials.rail,
1018
+ facade:
1019
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1020
+ ? (materials.jungleTempleFacade ?? materials.jungleTempleStone ?? materials.rail)
1021
+ : materials.rail,
1022
+ timber:
1023
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1024
+ ? (materials.jungleTempleTimber ?? materials.structure)
1025
+ : materials.structure,
1026
+ platform:
1027
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1028
+ ? (materials.jungleTemplePlatform ?? materials.moving)
1029
+ : materials.moving,
1030
+ moss:
1031
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1032
+ ? (materials.jungleTempleMoss ?? materials.structure)
1033
+ : materials.structure,
1034
+ accent:
1035
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1036
+ ? (materials.jungleTempleBronze ?? materials.signal)
1037
+ : materials.signal,
1038
+ }
1039
+ const portalDerivedMaterials = {
1040
+ egypt: {
1041
+ get sandstone() {
1042
+ return materials.egyptPortalSandstone ?? materials.rail
1043
+ },
1044
+ get carvedStone() {
1045
+ return materials.egyptPortalCarvedSandstone ?? materials.rail
1046
+ },
1047
+ get pyramidStone() {
1048
+ return materials.egyptPyramidLimestone ?? materials.railBacking
1049
+ },
1050
+ get pyramidGold() {
1051
+ return materials.egyptPyramidGold ?? materials.egyptPortalAgedGold ?? materials.signal
1052
+ },
1053
+ get bronze() {
1054
+ return materials.edge
1055
+ },
1056
+ get gold() {
1057
+ return materials.egyptPortalAgedGold ?? materials.signal
1058
+ },
1059
+ get paving() {
1060
+ return materials.top
1061
+ },
1062
+ get signal() {
1063
+ return materials.signal
1064
+ },
1065
+ },
1066
+ japan: {
1067
+ vermilion:
1068
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1069
+ ? (materials.sakuraBridgeTimber ?? materials.structure)
1070
+ : materials.structure,
1071
+ timber:
1072
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1073
+ ? (materials.sakuraCastleTimber ?? materials.moving)
1074
+ : materials.moving,
1075
+ plaster: materials.railBacking,
1076
+ stone:
1077
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1078
+ ? (materials.sakuraPortalFoundationStone ?? materials.sakuraCastleMasonry ?? materials.structure)
1079
+ : materials.structure,
1080
+ kawara:
1081
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1082
+ ? (materials.sakuraCastleKawara ?? materials.structure)
1083
+ : materials.structure,
1084
+ hardware:
1085
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1086
+ ? (materials.sakuraBridgeHardware ?? materials.edge)
1087
+ : materials.edge,
1088
+ signal: materials.signal,
1089
+ },
1090
+ space: {
1091
+ chassis:
1092
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1093
+ ? (materials.spaceStationWallChassis ?? materials.structure)
1094
+ : materials.structure,
1095
+ panel:
1096
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1097
+ ? (materials.spaceStationWallPanel ?? materials.rail)
1098
+ : materials.rail,
1099
+ rib:
1100
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1101
+ ? (materials.spaceStationWallRib ?? materials.structure)
1102
+ : materials.structure,
1103
+ trim:
1104
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1105
+ ? (materials.spaceStationWallTrim ?? materials.edge)
1106
+ : materials.edge,
1107
+ light:
1108
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1109
+ ? (materials.spaceStationWallLight ?? materials.energy)
1110
+ : materials.energy,
1111
+ },
1112
+ }
1113
+
1114
+ if (!reusesCanonicalVisuals) {
1115
+ const beltBandGeometry = new THREE.BoxGeometry(0.3, this.height, 0.16)
1116
+ for (const side of [-1, 1] as const) {
1117
+ const beltBand = new THREE.Mesh(beltBandGeometry, materials.jungleTempleTimber ?? materials.structure)
1118
+ beltBand.name = `${id}-vertical-belt-${side < 0 ? 'left' : 'right'}`
1119
+ beltBand.position
1120
+ .copy(this.bottom)
1121
+ .add(
1122
+ new THREE.Vector3(
1123
+ side * (definition.width / 2 - 0.55),
1124
+ towerCenterY - this.bottom.y,
1125
+ definition.shelfDepth / 2 + 0.12,
1126
+ ).applyQuaternion(this.baseRotation),
1127
+ )
1128
+ beltBand.quaternion.copy(this.baseRotation)
1129
+ beltBand.castShadow = true
1130
+ beltBand.receiveShadow = true
1131
+ beltBand.userData.themeHidden = [
1132
+ JUNGLE_TEMPLE_THEME_ID,
1133
+ EGYPT_ELEVATOR_THEME_ID,
1134
+ JAPAN_ELEVATOR_THEME_ID,
1135
+ SPACE_ELEVATOR_THEME_ID,
1136
+ ]
1137
+ root.add(beltBand)
1138
+ }
1139
+
1140
+ const drumGeometry = new VisualCylinderGeometry(0.55, 0.55, definition.width - 0.7, 20)
1141
+ for (const [label, y] of [
1142
+ ['bottom', this.bottom.y],
1143
+ ['top', this.top.y],
1144
+ ] as const) {
1145
+ const drum = new THREE.Mesh(drumGeometry, materials.jungleTempleTimber ?? materials.signal)
1146
+ drum.name = `${id}-${label}-drum`
1147
+ const verticalOffset = label === 'bottom' ? -0.55 : 0.55
1148
+ drum.position
1149
+ .copy(this.bottom)
1150
+ .add(
1151
+ new THREE.Vector3(0, y + verticalOffset - this.bottom.y, definition.shelfDepth / 2 + 0.2).applyQuaternion(
1152
+ this.baseRotation,
1153
+ ),
1154
+ )
1155
+ drum.quaternion
1156
+ .copy(this.baseRotation)
1157
+ .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2))
1158
+ drum.castShadow = true
1159
+ drum.userData.decorative = true
1160
+ drum.userData.themeHidden = [
1161
+ WILD_WEST_THEME_ID,
1162
+ JUNGLE_TEMPLE_THEME_ID,
1163
+ EGYPT_ELEVATOR_THEME_ID,
1164
+ JAPAN_ELEVATOR_THEME_ID,
1165
+ SPACE_ELEVATOR_THEME_ID,
1166
+ ]
1167
+ root.add(drum)
1168
+ }
1169
+
1170
+ if (activeThemeId === WILD_WEST_THEME_ID)
1171
+ addWildWestMineLiftFrame(root, {
1172
+ id,
1173
+ bottom: this.bottom,
1174
+ rotation: this.baseRotation,
1175
+ width: definition.width,
1176
+ height: this.height,
1177
+ depth: definition.shelfDepth,
1178
+ releaseHeight: definition.releaseHeight - this.bottom.y,
1179
+ materials: mineLiftMaterials,
1180
+ })
1181
+ if (activeThemeId === JUNGLE_TEMPLE_THEME_ID)
1182
+ addJungleTempleLiftSkin(root, {
1183
+ id,
1184
+ bottom: this.bottom,
1185
+ rotation: this.baseRotation,
1186
+ width: definition.width,
1187
+ height: this.height,
1188
+ depth: definition.shelfDepth,
1189
+ releaseHeight: definition.releaseHeight - this.bottom.y,
1190
+ materials: jungleTempleMaterials,
1191
+ })
1192
+ addPortalDerivedElevatorSkins(root, {
1193
+ activeThemeId,
1194
+ id,
1195
+ bottom: this.bottom,
1196
+ rotation: this.baseRotation,
1197
+ width: definition.width,
1198
+ height: this.height,
1199
+ depth: definition.shelfDepth,
1200
+ releaseHeight: definition.releaseHeight - this.bottom.y,
1201
+ materials: portalDerivedMaterials,
1202
+ })
1203
+ }
1204
+
1205
+ const shelfThickness = 0.2
1206
+ for (let index = 0; index < definition.shelfCount; index += 1) {
1207
+ const body = this.physics.bodies[index]!
1208
+ const bodyPosition = body.translation()
1209
+ const bodyRotation = body.rotation()
1210
+ const position = new THREE.Vector3(bodyPosition.x, bodyPosition.y, bodyPosition.z)
1211
+ const shelfRotation = new THREE.Quaternion(bodyRotation.x, bodyRotation.y, bodyRotation.z, bodyRotation.w)
1212
+ const visual = reusesCanonicalVisuals
1213
+ ? existingVisuals![index]!
1214
+ : new THREE.Mesh(
1215
+ new THREE.BoxGeometry(definition.width - 0.56, shelfThickness, definition.shelfDepth),
1216
+ materials.jungleTemplePlatform ?? materials.moving,
1217
+ )
1218
+ visual.name ||= `${id}-shelf-${index + 1}`
1219
+ visual.position.copy(position)
1220
+ visual.quaternion.copy(shelfRotation)
1221
+ visual.castShadow = true
1222
+ visual.receiveShadow = true
1223
+ markDynamicTrackShadowRoot(visual)
1224
+ if (!reusesCanonicalVisuals) {
1225
+ if (activeThemeId === WILD_WEST_THEME_ID)
1226
+ decorateWildWestMineLiftShelf(
1227
+ visual,
1228
+ `${id}-shelf-${index + 1}`,
1229
+ definition.width - 0.56,
1230
+ definition.shelfDepth,
1231
+ mineLiftMaterials,
1232
+ )
1233
+ if (activeThemeId === JUNGLE_TEMPLE_THEME_ID)
1234
+ decorateJungleTempleShelf(
1235
+ visual,
1236
+ `${id}-shelf-${index + 1}`,
1237
+ definition.width - 0.56,
1238
+ definition.shelfDepth,
1239
+ jungleTempleMaterials,
1240
+ )
1241
+ decoratePortalDerivedElevatorShelf(
1242
+ visual,
1243
+ `${id}-shelf-${index + 1}`,
1244
+ definition.width - 0.56,
1245
+ definition.shelfDepth,
1246
+ portalDerivedMaterials,
1247
+ activeThemeId,
1248
+ )
1249
+ root.add(visual)
1250
+ }
1251
+ }
1252
+ }
1253
+ }
1254
+
1255
+ export function createSplitterMechanism(
1256
+ world: RAPIER.World,
1257
+ root: THREE.Group,
1258
+ definition: SplitterPieceDefinition,
1259
+ motion: SplitterMotionProfile,
1260
+ materials: TrackMaterials,
1261
+ existingVisual?: THREE.Group,
1262
+ ): TrackMechanismVisualAdapter {
1263
+ return new SplitterMechanism(definition.id, world, root, definition, motion, materials, existingVisual)
1264
+ }
1265
+
1266
+ export function createRisingWallForestMechanism(
1267
+ world: RAPIER.World,
1268
+ root: THREE.Group,
1269
+ definition: RisingWallForestPieceDefinition,
1270
+ simulationKey: string,
1271
+ materials: TrackMaterials,
1272
+ existingVisuals?: readonly THREE.Mesh[],
1273
+ ): TrackMechanismVisualAdapter {
1274
+ return new RisingWallForestMechanism(
1275
+ definition.id,
1276
+ world,
1277
+ definition,
1278
+ simulationKey,
1279
+ root,
1280
+ materials,
1281
+ existingVisuals,
1282
+ )
1283
+ }
1284
+
1285
+ export function createVortexMechanism(
1286
+ world: RAPIER.World,
1287
+ definition: VortexPieceDefinition,
1288
+ ): TrackMechanismVisualAdapter {
1289
+ return new VortexPhysicsMechanism(definition.id, world, definition)
1290
+ }
1291
+
1292
+ export function createPortalMechanism(
1293
+ root: THREE.Group,
1294
+ definition: PortalPieceDefinition,
1295
+ materials: TrackMaterials,
1296
+ qualityPolicy: VisualQualityPolicy = visualQualityPolicy('quality'),
1297
+ existingVisuals?: { entry: PortalEndpointVisual; exit: PortalEndpointVisual },
1298
+ ): TrackMechanismVisualAdapter {
1299
+ return new PortalMechanism(definition.id, root, definition, materials, qualityPolicy, existingVisuals)
1300
+ }
1301
+
1302
+ export function createConveyorMechanism(
1303
+ world: RAPIER.World,
1304
+ root: THREE.Group,
1305
+ definition: ConveyorPieceDefinition,
1306
+ phase: number,
1307
+ materials: TrackMaterials,
1308
+ existingVisuals?: readonly THREE.Mesh[],
1309
+ ): TrackMechanismVisualAdapter {
1310
+ return new ConveyorMechanism(definition.id, world, root, definition, phase, materials, existingVisuals)
1311
+ }
1312
+
1313
+ export function createFlipperBankMechanism(
1314
+ world: RAPIER.World,
1315
+ root: THREE.Group,
1316
+ definition: FlipperBankPieceDefinition,
1317
+ simulationKey: string,
1318
+ materials: TrackMaterials,
1319
+ existingVisuals?: readonly THREE.Group[],
1320
+ ): TrackMechanismVisualAdapter {
1321
+ return new FlipperBankMechanism(definition.id, world, root, definition, simulationKey, materials, existingVisuals)
1322
+ }
1323
+
1324
+ export function createReboundMechanism(
1325
+ world: RAPIER.World,
1326
+ root: THREE.Group,
1327
+ definition: ReboundPieceDefinition,
1328
+ materials: TrackMaterials,
1329
+ existingVisuals?: readonly THREE.Group[],
1330
+ ): TrackMechanismVisualAdapter {
1331
+ return new ReboundMechanism(definition.id, world, root, definition, materials, existingVisuals)
1332
+ }
1333
+
1334
+ export function createTurntableMechanism(
1335
+ world: RAPIER.World,
1336
+ root: THREE.Group,
1337
+ definition: TurntablePieceDefinition,
1338
+ materials: TrackMaterials,
1339
+ ): TrackMechanismVisualAdapter {
1340
+ return new TurntableMechanism(definition.id, world, root, definition, materials)
1341
+ }
1342
+
1343
+ export function createDipperMechanism(
1344
+ world: RAPIER.World,
1345
+ root: THREE.Group,
1346
+ definition: DipperPieceDefinition,
1347
+ materials: TrackMaterials,
1348
+ ): TrackMechanismVisualAdapter {
1349
+ return new DipperMechanism(definition.id, world, root, definition, materials)
1350
+ }