@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,1420 @@
1
+ import {
2
+ initialTrackCatalog,
3
+ laneGradeDrop,
4
+ portalPairNumber,
5
+ resolveTemplateConnectors,
6
+ TRACK_MODULE_LENGTH,
7
+ type JsonObject,
8
+ type TrackConnectorTemplate,
9
+ type TrackPieceInstance,
10
+ type TrackPieceTemplate,
11
+ type TrackVector,
12
+ } from '@marble.fun/physics-engine'
13
+ import * as THREE from 'three'
14
+
15
+ import { VisualSphereGeometry, VisualCylinderGeometry } from '../../../three/visual-geometry'
16
+
17
+ import type { TrackMaterials } from '../../play/race/track-mechanisms'
18
+ import { portalPairColor } from '../../play/race/portal-pair-color'
19
+ import {
20
+ createPortalEndpointVisual,
21
+ PORTAL_RENDER_TARGET_TEXTURE,
22
+ portalVisualStyleFromId,
23
+ type PortalEndpointVisual,
24
+ } from '../../play/race/portal-visual'
25
+ import { trackSurfaceBasis } from '../../play/race/track-surface-basis'
26
+ import {
27
+ defaultTrackTroughDepth,
28
+ type RailPieceDefinition,
29
+ type VortexPieceDefinition,
30
+ } from '../../play/race/track-definition'
31
+ import { addFinishGate, addRebounds, addRailPiece, addVortex } from '../../play/race/track-world'
32
+ import type { ThemeMaterialRegistry } from '../../../three/theme-material-registry'
33
+ import type { ThemeMaterialSlot } from '../../../three/track-theme'
34
+ import {
35
+ ANCIENT_EGYPT_PORTAL_AGED_GOLD_ID,
36
+ ANCIENT_EGYPT_PORTAL_CARVED_SANDSTONE_ID,
37
+ ANCIENT_EGYPT_PORTAL_SANDSTONE_ID,
38
+ ANCIENT_EGYPT_PYRAMID_LIMESTONE_ID,
39
+ WILD_WEST_COVERED_MINE_IRON_ID,
40
+ WILD_WEST_COVERED_MINE_TIMBER_ID,
41
+ } from '../../../three/theme-catalog'
42
+ import {
43
+ resolvedFloorMaterialIdForTheme,
44
+ resolvedRailMaterialId,
45
+ WILD_WEST_RAIL_MATERIAL_ID,
46
+ WILD_WEST_RAIL_TIE_MATERIAL_ID,
47
+ } from '../../../three/railroad-appearance'
48
+ import { WILD_WEST_TRESTLE_METAL_MATERIAL_ID } from '../../play/race/track-appearance-support'
49
+ import { resolveFloorSurfaceTreatment, resolveFloorSurfaceTreatments } from '../../play/race/track-floor-material-rules'
50
+ import {
51
+ SHARED_ROPE_WALL_BACKING_MATERIAL_ID,
52
+ SHARED_ROPE_WALL_END_GRAIN_MATERIAL_ID,
53
+ SHARED_ROPE_WALL_TIMBER_MATERIAL_ID,
54
+ } from '../../../three/rope-wall-appearance'
55
+ import {
56
+ JUNGLE_TEMPLE_BRONZE_MATERIAL_ID,
57
+ JUNGLE_TEMPLE_FACADE_MATERIAL_ID,
58
+ JUNGLE_TEMPLE_PLATFORM_MATERIAL_ID,
59
+ JUNGLE_TEMPLE_STONE_MATERIAL_ID,
60
+ JUNGLE_TEMPLE_TIMBER_MATERIAL_ID,
61
+ } from '../../../three/jungle-temple-appearance'
62
+ import {
63
+ JAPANESE_SAKURA_BRIDGE_HARDWARE_ID,
64
+ JAPANESE_SAKURA_BRIDGE_TIMBER_ID,
65
+ JAPANESE_SAKURA_CASTLE_KAWARA_ID,
66
+ JAPANESE_SAKURA_CASTLE_MASONRY_ID,
67
+ JAPANESE_SAKURA_PORTAL_FOUNDATION_STONE_ID,
68
+ JAPANESE_SAKURA_CASTLE_TIMBER_ID,
69
+ } from '../../../three/japanese-sakura-appearance'
70
+ import {
71
+ SPACE_STATION_ARMORED_WALL_ID,
72
+ SPACE_STATION_CENTER_GUIDE_ID,
73
+ SPACE_STATION_WALL_LIGHT_ID,
74
+ SPACE_STATION_WALL_PANEL_ID,
75
+ SPACE_STATION_WALL_RIB_ID,
76
+ SPACE_STATION_WALL_TRIM_ID,
77
+ } from '../../../three/space-station-appearance'
78
+ import {
79
+ CONE_FIELD_PEG_HEIGHT,
80
+ CONE_FIELD_PEG_RADIUS,
81
+ createConeFieldDeckProfile,
82
+ createConeFieldLayout,
83
+ } from '../cone-field-layout'
84
+ import {
85
+ TRACK_LAB_CONVEYOR_BOTTOM,
86
+ TRACK_LAB_CONVEYOR_INPUT,
87
+ TRACK_LAB_CONVEYOR_LOADING,
88
+ TRACK_LAB_CONVEYOR_RELEASE,
89
+ TRACK_LAB_CONVEYOR_SHELF_COUNT,
90
+ TRACK_LAB_CONVEYOR_SHELF_DEPTH,
91
+ TRACK_LAB_CONVEYOR_SHELF_TILT_RADIANS,
92
+ TRACK_LAB_CONVEYOR_TOP,
93
+ TRACK_LAB_CONVEYOR_WIDTH,
94
+ } from '../conveyor-layout'
95
+ import { createFlipperBankDeck, createFlipperBankLayout } from '../flipper-bank-layout'
96
+ import {
97
+ REBOUND_HEIGHT,
98
+ REBOUND_HOLD_SECONDS,
99
+ REBOUND_LENGTH,
100
+ REBOUND_COOLDOWN_SECONDS,
101
+ REBOUND_RESTITUTION,
102
+ REBOUND_RETURN_SECONDS,
103
+ REBOUND_STRIKE_DISTANCE,
104
+ REBOUND_STRIKE_SECONDS,
105
+ REBOUND_WIDTH,
106
+ createReboundDeck,
107
+ createReboundLayout,
108
+ createSingleFileSqueezeRail,
109
+ type ReboundPattern,
110
+ } from '../simple-obstacle-layout'
111
+ import {
112
+ RISING_WALL_DEPTH,
113
+ RISING_WALL_HEIGHT,
114
+ RISING_WALL_WIDTH,
115
+ createRisingWallDeck,
116
+ createRisingWallLayout,
117
+ createRisingWallSchedule,
118
+ risingWallExposedHeight,
119
+ risingWallHeightFraction,
120
+ type RisingWallDensity,
121
+ } from '../rising-wall-layout'
122
+ import {
123
+ ATLAS_VORTEX_CENTER,
124
+ ATLAS_VORTEX_DRAIN_OUTPUT_WIDTH,
125
+ ATLAS_VORTEX_DRAIN_CHANNEL,
126
+ ATLAS_VORTEX_DRAIN_PATH,
127
+ ATLAS_VORTEX_ENTRY,
128
+ ATLAS_VORTEX_ENTRY_APPROACH,
129
+ ATLAS_VORTEX_INPUT,
130
+ ATLAS_VORTEX_OUTPUT,
131
+ createAtlasVortexDefinition,
132
+ atlasVortexDrainProfile,
133
+ } from '../atlas-vortex-layout'
134
+ import {
135
+ applyRailConnectorFrames,
136
+ createIntersectionRailDefinitions,
137
+ createPublicLaneRailDefinition,
138
+ createRoutingMergeDeck,
139
+ createRoutingSplitterDeck,
140
+ splitterPivotLocalPosition,
141
+ TRACK_LAB_FINISH_WIDTH,
142
+ TRACK_LAB_SPLITTER_ARM_DEPTH,
143
+ TRACK_LAB_SPLITTER_ARM_HEIGHT,
144
+ TRACK_LAB_SPLITTER_ARM_LENGTH,
145
+ TRACK_LAB_WALL_HEIGHT,
146
+ type RailConnectorFrame,
147
+ } from '../track-piece-layout'
148
+ import { collectEditorSelectables, computeEditorBounds } from './editor-object-geometry'
149
+ import {
150
+ CANONICAL_FLIPPER_DIMENSIONS,
151
+ createEndPivotedFlipperPaddleVisual,
152
+ createFlipperHubVisual,
153
+ createFlipperPaddleVisual,
154
+ } from '../flipper-component'
155
+ import { createPegObstacleVisualFactory } from '../../play/race/peg-obstacle-visual'
156
+ import {
157
+ WILD_WEST_ANTIQUE_BRASS_MATERIAL_ID,
158
+ WILD_WEST_GUNMETAL_FLIPPER_MATERIAL_ID,
159
+ } from '../../../three/rugged-track-appearance'
160
+ import {
161
+ addWildWestMineLiftFrame,
162
+ decorateWildWestMineLiftShelf,
163
+ WILD_WEST_THEME_ID,
164
+ } from '../../play/race/wild-west-mine-lift'
165
+ import {
166
+ addJungleTempleLiftSkin,
167
+ decorateJungleTempleShelf,
168
+ JUNGLE_TEMPLE_THEME_ID,
169
+ } from '../../play/race/jungle-temple-lift'
170
+ import {
171
+ addPortalDerivedElevatorSkins,
172
+ decoratePortalDerivedElevatorShelf,
173
+ EGYPT_ELEVATOR_THEME_ID,
174
+ JAPAN_ELEVATOR_THEME_ID,
175
+ SPACE_ELEVATOR_THEME_ID,
176
+ } from '../../play/race/portal-derived-elevator-skins'
177
+ import { markDynamicTrackShadowRoot } from '../../play/race/track-shadow-batching'
178
+ export interface TrackPieceVisual {
179
+ root: THREE.Group
180
+ bounds: THREE.Box3
181
+ selectableObjects: readonly THREE.Object3D[]
182
+ decorationAnchors: readonly THREE.Object3D[]
183
+ portalPreview?: {
184
+ color: string
185
+ endpoint: PortalEndpointVisual
186
+ pairId: string
187
+ role: 'entry' | 'exit'
188
+ }
189
+ dispose(): void
190
+ adoptMaterialRegistry(materials: ThemeMaterialRegistry): void
191
+ }
192
+
193
+ interface TrackPieceVisualOptions {
194
+ connectedConnectorIds?: ReadonlySet<string>
195
+ continuousFluidConnectorIds?: ReadonlySet<string>
196
+ continuousSupportConnectorIds?: ReadonlySet<string>
197
+ connectorFrames?: ReadonlyMap<string, RailConnectorFrame>
198
+ overviewLod?: boolean
199
+ /** Hidden live tracks need only their authored theme; the editor keeps instant theme switching. */
200
+ prebuildThemeVariants?: boolean
201
+ materialConnectionPhase?: number
202
+ deferSupportUntilMaterialContinuity?: boolean
203
+ /** Editor-only docking markers. Runtime/race visuals keep these hidden. */
204
+ connectorMarkersVisible?: boolean
205
+ /** Skip editor-only bounds, ray-picking meshes, and connector markers for hidden race preparation. */
206
+ editorMetadata?: boolean
207
+ }
208
+
209
+ export function createTrackPieceVisual(
210
+ piece: TrackPieceInstance,
211
+ materials: ThemeMaterialRegistry,
212
+ options: TrackPieceVisualOptions = {},
213
+ ): TrackPieceVisual {
214
+ const template = initialTrackCatalog.require(piece)
215
+ const resolvedConnectors = resolveTemplateConnectors(template, piece.parameters)
216
+ const editorMetadata = options.editorMetadata !== false
217
+ const root = new THREE.Group()
218
+ root.name = piece.id
219
+ root.userData.pieceInstanceId = piece.id
220
+ root.position.set(...piece.transform.position)
221
+ root.rotation.y = (piece.transform.yawSteps * -Math.PI) / 2
222
+ root.userData.railConnectors = resolvedConnectors
223
+ root.userData.connectedConnectorIds = options.connectedConnectorIds
224
+ root.userData.continuousFluidConnectorIds = options.continuousFluidConnectorIds
225
+ root.userData.continuousSupportConnectorIds = options.continuousSupportConnectorIds
226
+ root.userData.connectorFrames = options.connectorFrames
227
+ root.userData.overviewLod = options.overviewLod === true
228
+ root.userData.prebuildThemeVariants = options.prebuildThemeVariants !== false
229
+ root.userData.editorBoundsSource = 'canonical-track-geometry'
230
+ root.userData.editorMetadata = editorMetadata
231
+ root.userData.materialConnectionPhase = options.deferSupportUntilMaterialContinuity
232
+ ? Number.NaN
233
+ : options.materialConnectionPhase
234
+ root.userData.deferStoneArchitectureUntilMaterialContinuity = options.deferSupportUntilMaterialContinuity === true
235
+ const resolvedFloorMaterialId = resolvedFloorMaterialIdForTheme(
236
+ materials.activeTheme,
237
+ piece.appearance?.floorMaterialId,
238
+ )
239
+ const resolvedRailId = resolvedRailMaterialId(piece.appearance)
240
+ const appearance = piece.appearance
241
+ ? {
242
+ ...piece.appearance,
243
+ ...(resolvedFloorMaterialId ? { floorMaterialId: resolvedFloorMaterialId } : { floorMaterialId: undefined }),
244
+ ...(resolvedRailId ? { railMaterialId: resolvedRailId } : {}),
245
+ }
246
+ : undefined
247
+ root.userData.pieceAppearance = appearance
248
+ let portalPreview: TrackPieceVisual['portalPreview']
249
+ const addMesh = (
250
+ geometry: THREE.BufferGeometry,
251
+ slot: ThemeMaterialSlot,
252
+ name: string,
253
+ configure?: (mesh: THREE.Mesh) => void,
254
+ appearanceMaterialId?: string,
255
+ ): THREE.Mesh => {
256
+ const mesh = new THREE.Mesh(geometry)
257
+ mesh.name = `${piece.id}-${name}`
258
+ mesh.userData.pieceInstanceId = piece.id
259
+ mesh.castShadow = true
260
+ mesh.receiveShadow = true
261
+ if (appearanceMaterialId) materials.bindAppearance(mesh, appearanceMaterialId, slot)
262
+ else materials.bind(mesh, slot)
263
+ configure?.(mesh)
264
+ root.add(mesh)
265
+ return mesh
266
+ }
267
+
268
+ const publicLane = createPublicLaneRailDefinition({
269
+ id: piece.id,
270
+ templateId: template.id,
271
+ runtimeKind: template.runtimeKind,
272
+ parameters: piece.parameters,
273
+ })
274
+ if (publicLane) {
275
+ addRailPreview(root, materials, piece.id, publicLane)
276
+ } else if (template.runtimeKind === 'finish') {
277
+ addFinishPreview(root, materials, piece.id)
278
+ } else if (template.runtimeKind === 'vortex') {
279
+ root.userData.editorOutlineMode = 'bounds'
280
+ addAtlasVortexPreview(root, materials, piece.id)
281
+ } else {
282
+ portalPreview = addMechanismVisual(
283
+ root,
284
+ materials,
285
+ piece.id,
286
+ template,
287
+ piece.parameters,
288
+ appearance,
289
+ options.connectedConnectorIds,
290
+ addMesh,
291
+ )
292
+ }
293
+
294
+ if (editorMetadata) {
295
+ const connectorMarkerGeometry = new VisualSphereGeometry(0.16, 10, 8)
296
+ const connectorMarkerMaterial = new THREE.MeshBasicMaterial({ color: '#ffffff', toneMapped: false })
297
+ // A render batch may clone this material while the course is faded out.
298
+ // Preserve its authored state so the clone reveals with the same opacity.
299
+ connectorMarkerMaterial.userData.themeBaseOpacity = connectorMarkerMaterial.opacity
300
+ connectorMarkerMaterial.userData.themeBaseTransparent = connectorMarkerMaterial.transparent
301
+ connectorMarkerMaterial.userData.themeBaseDepthWrite = connectorMarkerMaterial.depthWrite
302
+ for (const connector of resolvedConnectors) {
303
+ const mesh = new THREE.Mesh(connectorMarkerGeometry, connectorMarkerMaterial)
304
+ mesh.name = `${piece.id}-connector-${connector.id}`
305
+ mesh.position.set(...connector.localPosition)
306
+ mesh.userData.pieceInstanceId = piece.id
307
+ mesh.userData.materialSlot = 'editor.connector'
308
+ mesh.userData.excludeFromEditorBounds = true
309
+ mesh.userData.isTrackConnectorMarker = true
310
+ mesh.userData.ownsMaterial = true
311
+ // A connection always docks an output to an input at the same point.
312
+ // Keep the output-side marker as the single owner so the two spheres do
313
+ // not z-fight into a striped "gumdrop".
314
+ mesh.userData.connectorMarkerOwner =
315
+ !connector.kind.endsWith('input') || !options.connectedConnectorIds?.has(connector.id)
316
+ mesh.visible = options.connectorMarkersVisible === true && mesh.userData.connectorMarkerOwner
317
+ root.add(mesh)
318
+ }
319
+ }
320
+
321
+ materials.rebindFloorAppearanceForTheme(root, piece.appearance?.floorMaterialId)
322
+ let bindingRegistry = materials
323
+
324
+ if (editorMetadata) root.updateMatrixWorld(true)
325
+ const bounds = editorMetadata ? computeEditorBounds(root) : new THREE.Box3()
326
+ const selectableObjects = editorMetadata ? collectEditorSelectables(root) : []
327
+
328
+ return {
329
+ root,
330
+ bounds,
331
+ selectableObjects,
332
+ decorationAnchors: [],
333
+ ...(portalPreview ? { portalPreview } : {}),
334
+ adoptMaterialRegistry(nextMaterials) {
335
+ if (bindingRegistry === nextMaterials) return
336
+ nextMaterials.adoptBindingsFrom(bindingRegistry, root)
337
+ bindingRegistry = nextMaterials
338
+ },
339
+ dispose() {
340
+ bindingRegistry.releaseBindings(root)
341
+ const geometries = new Set<THREE.BufferGeometry>()
342
+ const ownedMaterials = new Set<THREE.Material>()
343
+ const ownedTextures = new Set<THREE.Texture>()
344
+ root.traverse(object => {
345
+ if (object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points) {
346
+ if (!object.userData.sharedGeometry) geometries.add(object.geometry)
347
+ if (object.userData.ownsMaterial) {
348
+ const meshMaterials = Array.isArray(object.material) ? object.material : [object.material]
349
+ meshMaterials.forEach(material => ownedMaterials.add(material))
350
+ }
351
+ }
352
+ })
353
+ geometries.forEach(geometry => geometry.dispose())
354
+ ownedMaterials.forEach(material => {
355
+ Object.values(material).forEach(value => {
356
+ if (value instanceof THREE.Texture && value.userData[PORTAL_RENDER_TARGET_TEXTURE] !== true) {
357
+ ownedTextures.add(value)
358
+ }
359
+ })
360
+ material.dispose()
361
+ })
362
+ ownedTextures.forEach(texture => texture.dispose())
363
+ root.removeFromParent()
364
+ },
365
+ }
366
+ }
367
+
368
+ function addAtlasVortexPreview(root: THREE.Group, materials: ThemeMaterialRegistry, pieceId: string): void {
369
+ const appearance = root.userData.pieceAppearance as TrackPieceInstance['appearance'] | undefined
370
+ const definition: VortexPieceDefinition = {
371
+ ...createAtlasVortexDefinition(pieceId, ATLAS_VORTEX_CENTER, ATLAS_VORTEX_ENTRY, ATLAS_VORTEX_DRAIN_CHANNEL, [
372
+ ATLAS_VORTEX_OUTPUT[0] - ATLAS_VORTEX_DRAIN_CHANNEL[0],
373
+ ATLAS_VORTEX_OUTPUT[1] - ATLAS_VORTEX_DRAIN_CHANNEL[1],
374
+ ATLAS_VORTEX_OUTPUT[2] - ATLAS_VORTEX_DRAIN_CHANNEL[2],
375
+ ]),
376
+ ...(appearance ? { appearance } : {}),
377
+ deferStoneArchitectureUntilMaterialContinuity: root.userData.deferStoneArchitectureUntilMaterialContinuity === true,
378
+ waterFlowInputConnected: (root.userData.continuousFluidConnectorIds as ReadonlySet<string> | undefined)?.has(
379
+ 'input',
380
+ ),
381
+ }
382
+ const entryRail: RailPieceDefinition = {
383
+ id: `${pieceId}-entry`,
384
+ label: 'Vortex tangential entry',
385
+ type: 'rail',
386
+ points: [ATLAS_VORTEX_INPUT, ...ATLAS_VORTEX_ENTRY_APPROACH],
387
+ width: 4,
388
+ // The mechanism owns this inlet, but its open terminal is still a
389
+ // standard lane connector. Reuse the complete canonical trough profile
390
+ // so its top, underside, and both wall bases dock vertex-for-vertex with
391
+ // a snapped straight lane.
392
+ troughDepth: defaultTrackTroughDepth(4),
393
+ troughProfile: 'full',
394
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
395
+ continuousFloorMaterialOnly: true,
396
+ verticalWalls: true,
397
+ supportsAppearanceCanopy: false,
398
+ ...(appearance ? { appearance } : {}),
399
+ }
400
+ const vortexMaterials = createPreviewTrackMaterials(
401
+ materials,
402
+ appearance,
403
+ root.userData.prebuildThemeVariants !== false,
404
+ )
405
+ const framedEntryRail = addRailPreview(root, materials, pieceId, entryRail, definition)
406
+ addVortex(root, undefined, definition, vortexMaterials, undefined, framedEntryRail)
407
+ addRailPreview(root, materials, pieceId, {
408
+ id: `${pieceId}-drain`,
409
+ label: 'Continuous Atlas vortex catch and single-file drain',
410
+ type: 'rail',
411
+ points: [...ATLAS_VORTEX_DRAIN_PATH],
412
+ width: ATLAS_VORTEX_DRAIN_OUTPUT_WIDTH,
413
+ pointWidths: atlasVortexDrainProfile(),
414
+ widthProfile: 'smoothstep',
415
+ troughDepth: defaultTrackTroughDepth(ATLAS_VORTEX_DRAIN_OUTPUT_WIDTH),
416
+ pointTroughDepths: atlasVortexDrainProfile().map(defaultTrackTroughDepth),
417
+ troughProfile: 'full',
418
+ troughFlatFraction: 0,
419
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
420
+ continuousFloorMaterialOnly: true,
421
+ supportsAppearanceCanopy: false,
422
+ // The tube drops above this receiving slope. Keep the floor and side-wall
423
+ // solids capped, but leave the rear open so no cross-wall is visible
424
+ // through the funnel bore.
425
+ capStart: true,
426
+ wallFriction: 0,
427
+ waterFlowStartConnected: Boolean(appearance?.vortexWaterId),
428
+ ...(appearance?.vortexWaterId ? { appearance: { ...appearance, waterOverlayId: appearance.vortexWaterId } } : {}),
429
+ })
430
+ root.traverse(object => {
431
+ object.userData.pieceInstanceId = pieceId
432
+ })
433
+ materials.captureBindings(root)
434
+ }
435
+
436
+ function addRailPreview(
437
+ root: THREE.Group,
438
+ materials: ThemeMaterialRegistry,
439
+ pieceId: string,
440
+ definition: RailPieceDefinition,
441
+ vortex?: VortexPieceDefinition,
442
+ ): RailPieceDefinition {
443
+ const connectors = (root.userData.railConnectors as readonly TrackConnectorTemplate[] | undefined) ?? []
444
+ const connectedConnectorIds = root.userData.connectedConnectorIds as ReadonlySet<string> | undefined
445
+ const continuousFluidConnectorIds = root.userData.continuousFluidConnectorIds as ReadonlySet<string> | undefined
446
+ const continuousSupportConnectorIds = root.userData.continuousSupportConnectorIds as ReadonlySet<string> | undefined
447
+ const connectorFrames = root.userData.connectorFrames as ReadonlyMap<string, RailConnectorFrame> | undefined
448
+ const framedDefinition = applyRailConnectorFrames(definition, connectors, connectedConnectorIds, connectorFrames)
449
+ const appearance = root.userData.pieceAppearance as TrackPieceInstance['appearance'] | undefined
450
+ const junctionBranches = framedDefinition.junctionBranches?.map(branch => ({
451
+ ...branch,
452
+ waterFlowStartConnected: continuousFluidConnectorIds?.has(branch.connectorId) === true,
453
+ points: connectorFrames?.get(branch.connectorId)?.position
454
+ ? [connectorFrames.get(branch.connectorId)!.position!, ...branch.points.slice(1)]
455
+ : branch.points,
456
+ startTangent: connectorFrames?.get(branch.connectorId)?.tangent ?? branch.startTangent,
457
+ }))
458
+ const previewDefinition = {
459
+ ...framedDefinition,
460
+ editorProxies: root.userData.editorMetadata !== false,
461
+ materialConnectors: connectors.map(connector => ({
462
+ id: connector.id,
463
+ position: connector.localPosition,
464
+ })),
465
+ materialConnectionPhase: root.userData.materialConnectionPhase as number | undefined,
466
+ deferStoneArchitectureUntilMaterialContinuity: root.userData.deferStoneArchitectureUntilMaterialContinuity === true,
467
+ waterFlowStartConnected: framedDefinition.junctionMainConnectorId
468
+ ? continuousFluidConnectorIds?.has(framedDefinition.junctionMainConnectorId) === true
469
+ : connectors.some(
470
+ connector => connector.kind.endsWith('input') && continuousFluidConnectorIds?.has(connector.id),
471
+ ),
472
+ waterFlowEndConnected: connectors.some(
473
+ connector => connector.kind.endsWith('output') && continuousFluidConnectorIds?.has(connector.id),
474
+ ),
475
+ ...(junctionBranches ? { junctionBranches } : {}),
476
+ verticalWalls: true,
477
+ ...(root.userData.overviewLod ? { previewProfileSamplePitch: 0.05 } : {}),
478
+ ...(appearance || framedDefinition.appearance
479
+ ? { appearance: { ...appearance, ...framedDefinition.appearance } }
480
+ : {}),
481
+ }
482
+ addRailPiece(
483
+ root,
484
+ undefined,
485
+ previewDefinition,
486
+ createPreviewTrackMaterials(materials, appearance, root.userData.prebuildThemeVariants !== false),
487
+ undefined,
488
+ vortex,
489
+ undefined,
490
+ {
491
+ capStart: !connectors.some(
492
+ connector => connector.kind.endsWith('input') && continuousSupportConnectorIds?.has(connector.id),
493
+ ),
494
+ capEnd: !connectors.some(
495
+ connector => connector.kind.endsWith('output') && continuousSupportConnectorIds?.has(connector.id),
496
+ ),
497
+ },
498
+ )
499
+ root.traverse(object => {
500
+ object.userData.pieceInstanceId = pieceId
501
+ })
502
+ materials.captureBindings(root)
503
+ return previewDefinition
504
+ }
505
+
506
+ function createPreviewTrackMaterials(
507
+ materials: ThemeMaterialRegistry,
508
+ appearance?: TrackPieceInstance['appearance'],
509
+ prebuildThemeVariants = true,
510
+ ): TrackMaterials {
511
+ return {
512
+ activeThemeId: materials.activeTheme.id,
513
+ prebuildThemeVariants,
514
+ get top() {
515
+ return materials.getAppearance(appearance?.floorMaterialId, 'lane.surface')
516
+ },
517
+ get edge() {
518
+ return materials.get('lane.edge')
519
+ },
520
+ get rail() {
521
+ return materials.getAppearance(appearance?.wallMaterialId, 'lane.guard')
522
+ },
523
+ get railBacking() {
524
+ return materials.get('lane.guard.backing')
525
+ },
526
+ get ropeWallBacking() {
527
+ return materials.getAppearance(SHARED_ROPE_WALL_BACKING_MATERIAL_ID, 'lane.guard.backing')
528
+ },
529
+ get ropeWallEndGrain() {
530
+ return materials.getAppearance(SHARED_ROPE_WALL_END_GRAIN_MATERIAL_ID, 'structure.primary')
531
+ },
532
+ get ropeWallTimber() {
533
+ return materials.getAppearance(SHARED_ROPE_WALL_TIMBER_MATERIAL_ID, 'structure.primary')
534
+ },
535
+ get structure() {
536
+ return materials.get('structure.primary')
537
+ },
538
+ get moving() {
539
+ return materials.get('mechanism.moving')
540
+ },
541
+ get signal() {
542
+ return materials.get('signal.route')
543
+ },
544
+ get hazard() {
545
+ return materials.get('signal.hazard')
546
+ },
547
+ get portal() {
548
+ return materials.get('effect.portal')
549
+ },
550
+ get energy() {
551
+ return materials.get('effect.energy')
552
+ },
553
+ get railroad() {
554
+ return materials.getAppearance(resolvedRailMaterialId(appearance) ?? WILD_WEST_RAIL_MATERIAL_ID, 'lane.edge')
555
+ },
556
+ get railDetailStyle() {
557
+ return materials.getAppearanceDetailStyle(resolvedRailMaterialId(appearance))
558
+ },
559
+ get railroadTimber() {
560
+ return materials.getAppearance(WILD_WEST_RAIL_TIE_MATERIAL_ID, 'structure.primary')
561
+ },
562
+ get obstacle() {
563
+ return materials.get('obstacle.primary')
564
+ },
565
+ get obstacleAccent() {
566
+ return materials.get('obstacle.secondary')
567
+ },
568
+ get obstacleBase() {
569
+ return materials.get('obstacle.base')
570
+ },
571
+ get flipperPrimary() {
572
+ return materials.get('mechanism.flipper.primary')
573
+ },
574
+ get flipperSecondary() {
575
+ return materials.get('mechanism.flipper.secondary')
576
+ },
577
+ get mineTimber() {
578
+ return materials.getAppearance('wild-west.mine-timber', 'structure.primary')
579
+ },
580
+ get coveredMineTimber() {
581
+ return materials.getAppearance(WILD_WEST_COVERED_MINE_TIMBER_ID, 'structure.primary')
582
+ },
583
+ get coveredMineIron() {
584
+ return materials.getAppearance(WILD_WEST_COVERED_MINE_IRON_ID, 'structure.secondary')
585
+ },
586
+ get portalIron() {
587
+ return materials.getAppearance(WILD_WEST_GUNMETAL_FLIPPER_MATERIAL_ID, 'structure.secondary')
588
+ },
589
+ get bone() {
590
+ return materials.getAppearance('wild-west.warm-ivory-bone', 'structure.primary')
591
+ },
592
+ get support() {
593
+ return materials.getAppearance(appearance?.supportMaterialId, 'structure.primary')
594
+ },
595
+ get floorSurfaceTreatment() {
596
+ return resolveFloorSurfaceTreatment(materials, appearance?.floorMaterialId)
597
+ },
598
+ get floorSurfaceTreatments() {
599
+ if (prebuildThemeVariants) {
600
+ return resolveFloorSurfaceTreatments(materials, appearance?.floorMaterialId)
601
+ }
602
+ const treatment = resolveFloorSurfaceTreatment(materials, appearance?.floorMaterialId)
603
+ return treatment ? [treatment] : []
604
+ },
605
+ get supportMetal() {
606
+ return materials.getAppearance(WILD_WEST_TRESTLE_METAL_MATERIAL_ID, 'structure.secondary')
607
+ },
608
+ get jungleTempleStone() {
609
+ return materials.getAppearance(JUNGLE_TEMPLE_STONE_MATERIAL_ID, 'lane.guard')
610
+ },
611
+ get jungleTempleFacade() {
612
+ return materials.getAppearance(JUNGLE_TEMPLE_FACADE_MATERIAL_ID, 'lane.guard')
613
+ },
614
+ get jungleTempleTimber() {
615
+ return materials.getAppearance(JUNGLE_TEMPLE_TIMBER_MATERIAL_ID, 'structure.primary')
616
+ },
617
+ get jungleTemplePlatform() {
618
+ return materials.getAppearance(JUNGLE_TEMPLE_PLATFORM_MATERIAL_ID, 'mechanism.moving')
619
+ },
620
+ get jungleTempleMoss() {
621
+ return materials.get('world.foliage')
622
+ },
623
+ get jungleTempleBronze() {
624
+ return materials.getAppearance(JUNGLE_TEMPLE_BRONZE_MATERIAL_ID, 'signal.route')
625
+ },
626
+ get wildWestBrass() {
627
+ return materials.getAppearance(WILD_WEST_ANTIQUE_BRASS_MATERIAL_ID, 'signal.route')
628
+ },
629
+ get egyptPortalSandstone() {
630
+ return materials.getAppearance(ANCIENT_EGYPT_PORTAL_SANDSTONE_ID, 'structure.primary')
631
+ },
632
+ get egyptPortalCarvedSandstone() {
633
+ return materials.getAppearance(ANCIENT_EGYPT_PORTAL_CARVED_SANDSTONE_ID, 'lane.guard')
634
+ },
635
+ get egyptPortalAgedGold() {
636
+ return materials.getAppearance(ANCIENT_EGYPT_PORTAL_AGED_GOLD_ID, 'signal.route')
637
+ },
638
+ get egyptPyramidLimestone() {
639
+ return materials.getAppearance(ANCIENT_EGYPT_PYRAMID_LIMESTONE_ID, 'structure.primary')
640
+ },
641
+ get egyptPyramidGold() {
642
+ return materials.get('obstacle.primary')
643
+ },
644
+ get sakuraCastleTimber() {
645
+ return materials.getAppearance(JAPANESE_SAKURA_CASTLE_TIMBER_ID, 'structure.primary')
646
+ },
647
+ get sakuraCastleMasonry() {
648
+ return materials.getAppearance(JAPANESE_SAKURA_CASTLE_MASONRY_ID, 'structure.secondary')
649
+ },
650
+ get sakuraCastleKawara() {
651
+ return materials.getAppearance(JAPANESE_SAKURA_CASTLE_KAWARA_ID, 'structure.secondary')
652
+ },
653
+ get sakuraBridgeTimber() {
654
+ return materials.getAppearance(JAPANESE_SAKURA_BRIDGE_TIMBER_ID, 'structure.primary')
655
+ },
656
+ get sakuraBridgeHardware() {
657
+ return materials.getAppearance(JAPANESE_SAKURA_BRIDGE_HARDWARE_ID, 'structure.secondary')
658
+ },
659
+ get spaceStationWallChassis() {
660
+ return materials.getAppearance(SPACE_STATION_ARMORED_WALL_ID, 'lane.guard')
661
+ },
662
+ get spaceStationWallPanel() {
663
+ return materials.getAppearance(SPACE_STATION_WALL_PANEL_ID, 'lane.guard')
664
+ },
665
+ get spaceStationWallRib() {
666
+ return materials.getAppearance(SPACE_STATION_WALL_RIB_ID, 'structure.primary')
667
+ },
668
+ get spaceStationWallTrim() {
669
+ return materials.getAppearance(SPACE_STATION_WALL_TRIM_ID, 'structure.secondary')
670
+ },
671
+ get spaceStationWallLight() {
672
+ return materials.getAppearance(SPACE_STATION_WALL_LIGHT_ID, 'effect.energy')
673
+ },
674
+ get spaceStationCenterGuide() {
675
+ return materials.getAppearance(SPACE_STATION_CENTER_GUIDE_ID, 'effect.energy')
676
+ },
677
+ }
678
+ }
679
+
680
+ function addFinishPreview(root: THREE.Group, materials: ThemeMaterialRegistry, pieceId: string): void {
681
+ addRailPreview(root, materials, pieceId, {
682
+ id: `${pieceId}-channel`,
683
+ label: 'Continuous narrowing ranked finish channel',
684
+ type: 'rail',
685
+ raceRole: 'finish',
686
+ points: [
687
+ [0, 0, 2.5],
688
+ [0, -2, -2],
689
+ [0, -2.7, -12],
690
+ ],
691
+ pointWidths: [4, TRACK_LAB_FINISH_WIDTH, TRACK_LAB_FINISH_WIDTH],
692
+ width: TRACK_LAB_FINISH_WIDTH,
693
+ pointTroughDepths: [
694
+ defaultTrackTroughDepth(4),
695
+ defaultTrackTroughDepth(TRACK_LAB_FINISH_WIDTH),
696
+ defaultTrackTroughDepth(TRACK_LAB_FINISH_WIDTH),
697
+ ],
698
+ troughDepth: defaultTrackTroughDepth(TRACK_LAB_FINISH_WIDTH),
699
+ troughProfile: 'full',
700
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
701
+ floorFriction: 0.08,
702
+ wallFriction: 0,
703
+ closedEnd: true,
704
+ })
705
+ const finishMaterials = {
706
+ ...createPreviewTrackMaterials(materials, undefined, root.userData.prebuildThemeVariants !== false),
707
+ signal: materials.get('signal.finish'),
708
+ }
709
+ addFinishGate(
710
+ root,
711
+ {
712
+ id: pieceId,
713
+ label: 'Ranked finish channel',
714
+ type: 'finish',
715
+ center: [0, -2, -2],
716
+ direction: [0, 0, -1],
717
+ width: TRACK_LAB_FINISH_WIDTH,
718
+ channelEnd: [0, -2.7, -12],
719
+ rankingSlots: 6,
720
+ rankingSpacing: 1.22,
721
+ },
722
+ finishMaterials,
723
+ )
724
+ root.traverse(object => {
725
+ object.userData.pieceInstanceId = pieceId
726
+ })
727
+ materials.captureBindings(root)
728
+ }
729
+
730
+ type AddMesh = (
731
+ geometry: THREE.BufferGeometry,
732
+ slot: ThemeMaterialSlot,
733
+ name: string,
734
+ configure?: (mesh: THREE.Mesh) => void,
735
+ appearanceMaterialId?: string,
736
+ ) => THREE.Mesh
737
+
738
+ function addMechanismVisual(
739
+ root: THREE.Group,
740
+ materials: ThemeMaterialRegistry,
741
+ pieceId: string,
742
+ template: TrackPieceTemplate,
743
+ parameters: JsonObject,
744
+ appearance: TrackPieceInstance['appearance'],
745
+ _connectedConnectorIds: ReadonlySet<string> | undefined,
746
+ addMesh: AddMesh,
747
+ ): TrackPieceVisual['portalPreview'] {
748
+ if (template.runtimeKind === 'start') {
749
+ addRailPreview(root, materials, pieceId, {
750
+ id: pieceId,
751
+ label: 'Start grid',
752
+ type: 'rail',
753
+ raceRole: 'start',
754
+ points: [
755
+ [0, 1.2, 4],
756
+ [0, 0, -2.5],
757
+ ],
758
+ startWidth: 6,
759
+ width: 4,
760
+ startTroughDepth: defaultTrackTroughDepth(6),
761
+ troughDepth: defaultTrackTroughDepth(4),
762
+ troughProfile: 'full',
763
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
764
+ floorFriction: 0.08,
765
+ })
766
+ return
767
+ }
768
+
769
+ if (template.runtimeKind === 'peg-field') {
770
+ const layout = createConeFieldLayout(
771
+ lengthUnitsFromParameters(parameters),
772
+ conePatternFromParameters(parameters),
773
+ gradeFromParameters(parameters),
774
+ )
775
+ const deckProfile = createConeFieldDeckProfile(layout, defaultTrackTroughDepth(4))
776
+ addRailPreview(root, materials, pieceId, {
777
+ id: `${pieceId}-deck`,
778
+ label: 'Continuous enclosed cone forest deck',
779
+ type: 'rail',
780
+ points: [...deckProfile.points],
781
+ width: 4,
782
+ pointWidths: [...deckProfile.pointWidths],
783
+ troughDepth: defaultTrackTroughDepth(4),
784
+ pointTroughDepths: [...deckProfile.pointTroughDepths],
785
+ troughProfile: 'full',
786
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
787
+ floorFriction: 0.012,
788
+ wallFriction: 0,
789
+ })
790
+ const up = vector(layout.up).normalize()
791
+ const rotation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), up)
792
+ const createVisual = createPegObstacleVisualFactory({
793
+ radius: CONE_FIELD_PEG_RADIUS,
794
+ height: CONE_FIELD_PEG_HEIGHT,
795
+ colliderShape: 'cone',
796
+ bodyMaterial: materials.get('obstacle.primary'),
797
+ trimMaterial: materials.get('obstacle.secondary'),
798
+ })
799
+ layout.pegBases.forEach((base, index) => {
800
+ const visual = createVisual(`${pieceId}:${index}`).group
801
+ visual.name = `${pieceId}-cone-${index}`
802
+ visual.position.copy(vector(base).addScaledVector(up, CONE_FIELD_PEG_HEIGHT / 2))
803
+ visual.quaternion.copy(rotation)
804
+ visual.traverse(object => {
805
+ object.userData.pieceInstanceId = pieceId
806
+ })
807
+ root.add(visual)
808
+ })
809
+ materials.captureBindings(root)
810
+ return
811
+ }
812
+
813
+ if (template.runtimeKind === 'single-file-squeeze') {
814
+ addRailPreview(root, materials, pieceId, createSingleFileSqueezeRail(pieceId, template.label, parameters))
815
+ return
816
+ }
817
+
818
+ if (template.runtimeKind === 'rebounds') {
819
+ const appearance = root.userData.pieceAppearance as TrackPieceInstance['appearance'] | undefined
820
+ const lengthUnits = lengthUnitsFromParameters(parameters)
821
+ const layout = createReboundLayout(lengthUnits, parameters, reboundPatternFromParameters(parameters))
822
+ addRailPreview(
823
+ root,
824
+ materials,
825
+ pieceId,
826
+ createReboundDeck(`${pieceId}-deck`, template.label, lengthUnits, parameters),
827
+ )
828
+ addRebounds(
829
+ root,
830
+ {
831
+ id: `${pieceId}-rebounds`,
832
+ label: template.label,
833
+ type: 'rebounds',
834
+ direction: layout.direction,
835
+ mounts: layout.mounts,
836
+ reboundLength: REBOUND_LENGTH,
837
+ reboundWidth: REBOUND_WIDTH,
838
+ reboundHeight: REBOUND_HEIGHT,
839
+ friction: 0.01,
840
+ restitution: REBOUND_RESTITUTION,
841
+ strikeDistance: REBOUND_STRIKE_DISTANCE,
842
+ strikeSeconds: REBOUND_STRIKE_SECONDS,
843
+ holdSeconds: REBOUND_HOLD_SECONDS,
844
+ returnSeconds: REBOUND_RETURN_SECONDS,
845
+ cooldownSeconds: REBOUND_COOLDOWN_SECONDS,
846
+ ...(appearance ? { appearance } : {}),
847
+ },
848
+ createPreviewTrackMaterials(materials, appearance, root.userData.prebuildThemeVariants !== false),
849
+ )
850
+ root.children
851
+ .filter(child => child.name.startsWith(`${pieceId}-rebounds-rebound-`))
852
+ .forEach(markDynamicTrackShadowRoot)
853
+ materials.captureBindings(root)
854
+ return
855
+ }
856
+
857
+ if (template.runtimeKind === 'rising-wall-forest') {
858
+ const layout = createRisingWallLayout(lengthUnitsFromParameters(parameters), parameters)
859
+ const density = risingWallDensityFromParameters(parameters)
860
+ const schedule = createRisingWallSchedule(layout, density, `preview:${pieceId}`)
861
+ const basis = trackSurfaceBasis(layout.direction)
862
+ const up = vector(basis.up)
863
+ const rotation = new THREE.Quaternion().setFromRotationMatrix(
864
+ new THREE.Matrix4().makeBasis(vector(basis.right), up, vector(basis.forward).negate()),
865
+ )
866
+ addRailPreview(root, materials, pieceId, createRisingWallDeck(`${pieceId}-deck`, template.label, layout))
867
+ const wallGeometry = new THREE.BoxGeometry(RISING_WALL_WIDTH, RISING_WALL_HEIGHT, RISING_WALL_DEPTH)
868
+ const slotGeometry = new THREE.BoxGeometry(RISING_WALL_WIDTH + 0.14, 0.035, RISING_WALL_DEPTH + 0.14)
869
+ layout.anchors.forEach((anchor, index) => {
870
+ const fraction = risingWallHeightFraction(schedule, index, 0)
871
+ const exposedHeight = risingWallExposedHeight(RISING_WALL_HEIGHT, fraction)
872
+ const wall = addMesh(wallGeometry, 'obstacle.primary', `rising-wall-${index}`, mesh => {
873
+ mesh.position.copy(vector(anchor.position).addScaledVector(up, exposedHeight / 2))
874
+ mesh.quaternion.copy(rotation)
875
+ mesh.scale.set(1, exposedHeight / RISING_WALL_HEIGHT, 1)
876
+ mesh.visible = exposedHeight > 0.001
877
+ })
878
+ markDynamicTrackShadowRoot(wall)
879
+ addMesh(slotGeometry, 'obstacle.base', `rising-wall-slot-${index}`, mesh => {
880
+ mesh.position.copy(vector(anchor.position).addScaledVector(up, 0.018))
881
+ mesh.quaternion.copy(rotation)
882
+ })
883
+ })
884
+ return
885
+ }
886
+
887
+ if (template.runtimeKind === 'splitter') {
888
+ const drop = laneGradeDrop({ ...parameters, grade: parameters.grade ?? 'slope', lengthUnits: 1 })
889
+ const basis = trackSurfaceBasis([0, -drop, -TRACK_MODULE_LENGTH])
890
+ const forward = vector(basis.forward)
891
+ const surfaceRotation = new THREE.Quaternion().setFromRotationMatrix(
892
+ new THREE.Matrix4().makeBasis(forward, vector(basis.up), vector(basis.right)),
893
+ )
894
+ const center = vector(splitterPivotLocalPosition(drop))
895
+ addRailPreview(
896
+ root,
897
+ materials,
898
+ pieceId,
899
+ createRoutingSplitterDeck(`${pieceId}-deck`, 'Splitter routing deck', drop),
900
+ )
901
+ const flipperPrimary = materials.get('mechanism.flipper.primary')
902
+ const flipperSecondary = materials.get('mechanism.flipper.secondary')
903
+ const paddle = createEndPivotedFlipperPaddleVisual(
904
+ `${pieceId}-splitter-paddle`,
905
+ flipperPrimary,
906
+ {
907
+ length: TRACK_LAB_SPLITTER_ARM_LENGTH,
908
+ width: TRACK_LAB_SPLITTER_ARM_DEPTH,
909
+ height: TRACK_LAB_SPLITTER_ARM_HEIGHT,
910
+ },
911
+ flipperSecondary,
912
+ `${pieceId}-splitter-hub`,
913
+ 0,
914
+ )
915
+ paddle.position.copy(center)
916
+ paddle.quaternion.copy(surfaceRotation)
917
+ paddle.userData.pieceInstanceId = pieceId
918
+ markDynamicTrackShadowRoot(paddle)
919
+ const paddleSurface = paddle.getObjectByName(`${pieceId}-splitter-paddle-surface`)
920
+ if (!(paddleSurface instanceof THREE.Mesh)) throw new Error('Canonical splitter paddle surface is missing')
921
+ materials.bind(paddleSurface, 'mechanism.flipper.primary')
922
+ const paddleHub = paddle.getObjectByName(`${pieceId}-splitter-hub`)
923
+ if (!(paddleHub instanceof THREE.Mesh)) throw new Error('Canonical splitter hub is missing')
924
+ materials.bind(paddleHub, 'mechanism.flipper.secondary')
925
+ root.add(paddle)
926
+ return
927
+ }
928
+
929
+ if (template.runtimeKind === 'merge') {
930
+ const drop = laneGradeDrop({ ...parameters, grade: parameters.grade ?? 'slope', lengthUnits: 1 })
931
+ addRailPreview(root, materials, pieceId, createRoutingMergeDeck(pieceId, 'Route merge catch deck', drop))
932
+ return
933
+ }
934
+
935
+ if (template.runtimeKind === 'intersection') {
936
+ const connectors = resolveTemplateConnectors(template, parameters)
937
+ const output = connectors.find(connector => connector.kind.endsWith('output'))
938
+ const allInputs = connectors.filter(connector => connector.kind.endsWith('input'))
939
+ if (!output || allInputs.length === 0) return
940
+ createIntersectionRailDefinitions(pieceId, template.label, allInputs, output).forEach(definition => {
941
+ addRailPreview(root, materials, pieceId, definition)
942
+ })
943
+ return
944
+ }
945
+
946
+ if (template.runtimeKind === 'flipper-bank') {
947
+ const layout = createFlipperBankLayout(lengthUnitsFromParameters(parameters), gradeFromParameters(parameters))
948
+ addRailPreview(
949
+ root,
950
+ materials,
951
+ pieceId,
952
+ createFlipperBankDeck(
953
+ `${pieceId}-deck`,
954
+ 'Continuous enclosed flipper deck',
955
+ lengthUnitsFromParameters(parameters),
956
+ gradeFromParameters(parameters),
957
+ ),
958
+ )
959
+ const mounts = layout.mounts
960
+ const { length: paddleLength, height: paddleHeight } = CANONICAL_FLIPPER_DIMENSIONS
961
+ const flipperPrimary = materials.get('mechanism.flipper.primary')
962
+ const flipperSecondary = materials.get('mechanism.flipper.secondary')
963
+ mounts.forEach((mount, index) => {
964
+ const tangent = vector(layout.direction).normalize()
965
+ const right = new THREE.Vector3(tangent.z, 0, -tangent.x).normalize()
966
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
967
+ const paddleDepth = new THREE.Vector3().crossVectors(tangent, up).normalize()
968
+ const paddleRotation = new THREE.Quaternion().setFromRotationMatrix(
969
+ new THREE.Matrix4().makeBasis(tangent, up, paddleDepth),
970
+ )
971
+ const hubRotation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), up)
972
+ const hinge = vector(mount.anchor).addScaledVector(up, paddleHeight / 2)
973
+ const center = hinge.clone().addScaledVector(tangent, paddleLength / 2)
974
+ const paddle = createFlipperPaddleVisual(
975
+ `${pieceId}-flipper-${index + 1}`,
976
+ flipperPrimary,
977
+ CANONICAL_FLIPPER_DIMENSIONS,
978
+ )
979
+ paddle.position.copy(center)
980
+ paddle.quaternion.copy(paddleRotation)
981
+ paddle.userData.pieceInstanceId = pieceId
982
+ markDynamicTrackShadowRoot(paddle)
983
+ const paddleSurface = paddle.getObjectByName(`${pieceId}-flipper-${index + 1}-surface`)
984
+ if (!(paddleSurface instanceof THREE.Mesh)) throw new Error('Canonical flipper paddle surface is missing')
985
+ materials.bind(paddleSurface, 'mechanism.flipper.primary')
986
+ const hub = createFlipperHubVisual(
987
+ `${pieceId}-flipper-hub-${index + 1}`,
988
+ flipperSecondary,
989
+ CANONICAL_FLIPPER_DIMENSIONS,
990
+ )
991
+ hub.position.copy(hinge)
992
+ hub.quaternion.copy(hubRotation)
993
+ hub.userData.pieceInstanceId = pieceId
994
+ materials.bind(hub, 'mechanism.flipper.secondary')
995
+ root.add(paddle, hub)
996
+ })
997
+ return
998
+ }
999
+
1000
+ if (template.runtimeKind === 'conveyor') {
1001
+ addConveyorPreview(root, materials, pieceId, addMesh)
1002
+ return
1003
+ }
1004
+
1005
+ if (template.runtimeKind === 'portal-entry-preview' || template.runtimeKind === 'portal-exit-preview') {
1006
+ const isEntry = template.runtimeKind === 'portal-entry-preview'
1007
+ addRailPreview(root, materials, pieceId, {
1008
+ id: `${pieceId}-deck`,
1009
+ label: isEntry ? 'Portal entry channel' : 'Portal exit channel',
1010
+ type: 'rail',
1011
+ points: isEntry
1012
+ ? [
1013
+ [0, 0, TRACK_MODULE_LENGTH / 2],
1014
+ [0, 0, 0],
1015
+ ]
1016
+ : [
1017
+ [0, 0, 0],
1018
+ [0, 0, -TRACK_MODULE_LENGTH / 2],
1019
+ ],
1020
+ width: 4,
1021
+ troughDepth: defaultTrackTroughDepth(4),
1022
+ troughProfile: 'full',
1023
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
1024
+ floorFriction: 0.04,
1025
+ })
1026
+ const color = portalPairColor(typeof parameters.pairId === 'string' ? parameters.pairId : '1')
1027
+ const activeThemeId = materials.activeTheme.id
1028
+ const endpoint = createPortalEndpointVisual(
1029
+ `${pieceId}-portal`,
1030
+ portalVisualStyleFromId(appearance?.portalVisualId),
1031
+ color,
1032
+ {
1033
+ activeThemeId,
1034
+ structure: materials.get('structure.primary'),
1035
+ timber: materials.getAppearance('wild-west.mine-timber', 'structure.primary'),
1036
+ metal: materials.getAppearance(WILD_WEST_GUNMETAL_FLIPPER_MATERIAL_ID, 'structure.secondary'),
1037
+ portal: materials.get('effect.portal'),
1038
+ energy: materials.get('effect.energy'),
1039
+ jungle:
1040
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1041
+ ? {
1042
+ stone: materials.getAppearance(JUNGLE_TEMPLE_STONE_MATERIAL_ID, 'lane.guard'),
1043
+ facade: materials.getAppearance(JUNGLE_TEMPLE_FACADE_MATERIAL_ID, 'lane.guard'),
1044
+ bronze: materials.getAppearance(JUNGLE_TEMPLE_BRONZE_MATERIAL_ID, 'signal.route'),
1045
+ foliage: materials.get('world.foliage'),
1046
+ }
1047
+ : undefined,
1048
+ egypt:
1049
+ activeThemeId === EGYPT_ELEVATOR_THEME_ID
1050
+ ? {
1051
+ sandstone: materials.getAppearance(ANCIENT_EGYPT_PORTAL_SANDSTONE_ID, 'structure.primary'),
1052
+ carvedStone: materials.getAppearance(ANCIENT_EGYPT_PORTAL_CARVED_SANDSTONE_ID, 'lane.guard'),
1053
+ agedGold: materials.getAppearance(ANCIENT_EGYPT_PORTAL_AGED_GOLD_ID, 'signal.route'),
1054
+ }
1055
+ : undefined,
1056
+ sakura:
1057
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1058
+ ? {
1059
+ vermilion: materials.getAppearance(JAPANESE_SAKURA_BRIDGE_TIMBER_ID, 'structure.primary'),
1060
+ plaster: materials.get('lane.guard.backing'),
1061
+ foundationStone: materials.getAppearance(
1062
+ JAPANESE_SAKURA_PORTAL_FOUNDATION_STONE_ID,
1063
+ 'structure.secondary',
1064
+ ),
1065
+ kawara: materials.getAppearance(JAPANESE_SAKURA_CASTLE_KAWARA_ID, 'structure.secondary'),
1066
+ hardware: materials.getAppearance(JAPANESE_SAKURA_BRIDGE_HARDWARE_ID, 'structure.secondary'),
1067
+ }
1068
+ : undefined,
1069
+ spaceStation:
1070
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1071
+ ? {
1072
+ chassis: materials.getAppearance(SPACE_STATION_ARMORED_WALL_ID, 'lane.guard'),
1073
+ panel: materials.getAppearance(SPACE_STATION_WALL_PANEL_ID, 'lane.guard'),
1074
+ rib: materials.getAppearance(SPACE_STATION_WALL_RIB_ID, 'structure.primary'),
1075
+ trim: materials.getAppearance(SPACE_STATION_WALL_TRIM_ID, 'structure.secondary'),
1076
+ light: materials.getAppearance(SPACE_STATION_WALL_LIGHT_ID, 'effect.energy'),
1077
+ }
1078
+ : undefined,
1079
+ },
1080
+ )
1081
+ endpoint.group.position.y = 1.8
1082
+ if (!isEntry) endpoint.group.rotation.y = Math.PI
1083
+ const requestedPairId = typeof parameters.pairId === 'string' ? parameters.pairId : '1'
1084
+ const pairNumber = portalPairNumber(requestedPairId)
1085
+ const portalPreview: NonNullable<TrackPieceVisual['portalPreview']> = {
1086
+ color,
1087
+ endpoint,
1088
+ // Runtime portal ids use the canonical numeric pair id (A -> 1,
1089
+ // B -> 2, ...). Keep the editor preview on the same identity so the
1090
+ // race handoff leases these exact endpoint visuals instead of building
1091
+ // an overlapping second portal pair.
1092
+ pairId: pairNumber === Number.MAX_SAFE_INTEGER ? requestedPairId : String(pairNumber),
1093
+ role: isEntry ? 'entry' : 'exit',
1094
+ }
1095
+ endpoint.group.traverse(object => {
1096
+ object.userData.pieceInstanceId = pieceId
1097
+ })
1098
+ root.add(endpoint.group)
1099
+ materials.captureBindings(endpoint.group)
1100
+ return portalPreview
1101
+ }
1102
+
1103
+ const bounds = template.bounds(parameters)
1104
+ const width = bounds.maximum[0] - bounds.minimum[0]
1105
+ const height = Math.max(0.8, bounds.maximum[1] - bounds.minimum[1])
1106
+ const length = bounds.maximum[2] - bounds.minimum[2]
1107
+ addMesh(new THREE.BoxGeometry(width, 0.35, length), 'lane.surface', 'mechanism-deck')
1108
+ addMesh(
1109
+ new VisualCylinderGeometry(Math.min(width, length) * 0.28, Math.min(width, length) * 0.28, height * 0.55, 24),
1110
+ 'mechanism.body',
1111
+ 'mechanism-body',
1112
+ mesh => {
1113
+ mesh.position.y = height * 0.3
1114
+ },
1115
+ )
1116
+ addMesh(new THREE.BoxGeometry(width * 0.72, 0.24, 0.45), 'mechanism.moving', 'moving-element', mesh => {
1117
+ mesh.position.y = Math.max(0.8, height * 0.58)
1118
+ })
1119
+ }
1120
+
1121
+ function addConveyorPreview(
1122
+ root: THREE.Group,
1123
+ materials: ThemeMaterialRegistry,
1124
+ pieceId: string,
1125
+ addMesh: AddMesh,
1126
+ ): void {
1127
+ addRailPreview(root, materials, pieceId, {
1128
+ id: `${pieceId}-approach`,
1129
+ label: 'Conveyor approach channel',
1130
+ type: 'rail',
1131
+ points: [TRACK_LAB_CONVEYOR_INPUT, TRACK_LAB_CONVEYOR_LOADING],
1132
+ startWidth: 4,
1133
+ width: TRACK_LAB_CONVEYOR_WIDTH,
1134
+ startTroughDepth: defaultTrackTroughDepth(4),
1135
+ troughDepth: defaultTrackTroughDepth(TRACK_LAB_CONVEYOR_WIDTH),
1136
+ troughProfile: 'full',
1137
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
1138
+ floorFriction: 0.08,
1139
+ })
1140
+ addRailPreview(root, materials, pieceId, {
1141
+ id: `${pieceId}-release`,
1142
+ label: 'Conveyor release channel',
1143
+ type: 'rail',
1144
+ points: [TRACK_LAB_CONVEYOR_RELEASE, [0, 6, -2.5]],
1145
+ startWidth: TRACK_LAB_CONVEYOR_WIDTH,
1146
+ width: 4,
1147
+ startTroughDepth: defaultTrackTroughDepth(TRACK_LAB_CONVEYOR_WIDTH),
1148
+ troughDepth: defaultTrackTroughDepth(4),
1149
+ troughProfile: 'full',
1150
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
1151
+ floorFriction: 0.08,
1152
+ })
1153
+
1154
+ const liftHeight = TRACK_LAB_CONVEYOR_TOP[1] - TRACK_LAB_CONVEYOR_BOTTOM[1]
1155
+ const liftMiddleY = (TRACK_LAB_CONVEYOR_TOP[1] + TRACK_LAB_CONVEYOR_BOTTOM[1]) / 2
1156
+ const liftZ = TRACK_LAB_CONVEYOR_BOTTOM[2]
1157
+ const activeThemeId = materials.activeTheme.id
1158
+ const mineLiftMaterials = {
1159
+ timber:
1160
+ activeThemeId === WILD_WEST_THEME_ID
1161
+ ? materials.getAppearance('wild-west.mine-timber', 'structure.primary')
1162
+ : materials.get('structure.primary'),
1163
+ iron:
1164
+ activeThemeId === WILD_WEST_THEME_ID
1165
+ ? materials.getAppearance('wild-west.railroad-iron', 'lane.edge')
1166
+ : materials.get('lane.edge'),
1167
+ signal: materials.get('signal.route'),
1168
+ }
1169
+ const mineLiftFrame =
1170
+ activeThemeId === WILD_WEST_THEME_ID
1171
+ ? addWildWestMineLiftFrame(root, {
1172
+ id: pieceId,
1173
+ bottom: vector(TRACK_LAB_CONVEYOR_BOTTOM),
1174
+ rotation: new THREE.Quaternion(),
1175
+ width: TRACK_LAB_CONVEYOR_WIDTH,
1176
+ height: liftHeight,
1177
+ depth: TRACK_LAB_CONVEYOR_SHELF_DEPTH,
1178
+ releaseHeight: TRACK_LAB_CONVEYOR_RELEASE[1] - TRACK_LAB_CONVEYOR_BOTTOM[1],
1179
+ materials: mineLiftMaterials,
1180
+ })
1181
+ : undefined
1182
+ if (mineLiftFrame) materials.captureBindings(mineLiftFrame)
1183
+ const jungleTempleMaterials = {
1184
+ stone:
1185
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1186
+ ? materials.getAppearance(JUNGLE_TEMPLE_STONE_MATERIAL_ID, 'lane.guard')
1187
+ : materials.get('lane.guard'),
1188
+ facade:
1189
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1190
+ ? materials.getAppearance(JUNGLE_TEMPLE_FACADE_MATERIAL_ID, 'lane.guard')
1191
+ : materials.get('lane.guard'),
1192
+ timber:
1193
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1194
+ ? materials.getAppearance(JUNGLE_TEMPLE_TIMBER_MATERIAL_ID, 'structure.primary')
1195
+ : materials.get('structure.primary'),
1196
+ platform:
1197
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1198
+ ? materials.getAppearance(JUNGLE_TEMPLE_PLATFORM_MATERIAL_ID, 'mechanism.moving')
1199
+ : materials.get('mechanism.moving'),
1200
+ moss: materials.get('world.foliage'),
1201
+ accent:
1202
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1203
+ ? materials.getAppearance(JUNGLE_TEMPLE_BRONZE_MATERIAL_ID, 'signal.route')
1204
+ : materials.get('signal.route'),
1205
+ }
1206
+ const jungleTempleSkin =
1207
+ activeThemeId === JUNGLE_TEMPLE_THEME_ID
1208
+ ? addJungleTempleLiftSkin(root, {
1209
+ id: pieceId,
1210
+ bottom: vector(TRACK_LAB_CONVEYOR_BOTTOM),
1211
+ rotation: new THREE.Quaternion(),
1212
+ width: TRACK_LAB_CONVEYOR_WIDTH,
1213
+ height: liftHeight,
1214
+ depth: TRACK_LAB_CONVEYOR_SHELF_DEPTH,
1215
+ releaseHeight: TRACK_LAB_CONVEYOR_RELEASE[1] - TRACK_LAB_CONVEYOR_BOTTOM[1],
1216
+ materials: jungleTempleMaterials,
1217
+ })
1218
+ : undefined
1219
+ if (jungleTempleSkin) materials.captureBindings(jungleTempleSkin)
1220
+ const portalDerivedMaterials = {
1221
+ egypt: {
1222
+ sandstone:
1223
+ activeThemeId === EGYPT_ELEVATOR_THEME_ID
1224
+ ? materials.getAppearance(ANCIENT_EGYPT_PORTAL_SANDSTONE_ID, 'structure.primary')
1225
+ : materials.get('structure.primary'),
1226
+ carvedStone:
1227
+ activeThemeId === EGYPT_ELEVATOR_THEME_ID
1228
+ ? materials.getAppearance(ANCIENT_EGYPT_PORTAL_CARVED_SANDSTONE_ID, 'lane.guard')
1229
+ : materials.get('lane.guard'),
1230
+ pyramidStone:
1231
+ activeThemeId === EGYPT_ELEVATOR_THEME_ID
1232
+ ? materials.getAppearance(ANCIENT_EGYPT_PYRAMID_LIMESTONE_ID, 'structure.primary')
1233
+ : materials.get('structure.primary'),
1234
+ pyramidGold: materials.get('obstacle.primary'),
1235
+ bronze: materials.get('lane.edge'),
1236
+ gold:
1237
+ activeThemeId === EGYPT_ELEVATOR_THEME_ID
1238
+ ? materials.getAppearance(ANCIENT_EGYPT_PORTAL_AGED_GOLD_ID, 'signal.route')
1239
+ : materials.get('signal.route'),
1240
+ paving: materials.get('lane.surface'),
1241
+ signal: materials.get('signal.route'),
1242
+ },
1243
+ japan: {
1244
+ vermilion:
1245
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1246
+ ? materials.getAppearance(JAPANESE_SAKURA_BRIDGE_TIMBER_ID, 'structure.primary')
1247
+ : materials.get('structure.primary'),
1248
+ timber:
1249
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1250
+ ? materials.getAppearance(JAPANESE_SAKURA_CASTLE_TIMBER_ID, 'structure.primary')
1251
+ : materials.get('structure.primary'),
1252
+ plaster: materials.get('lane.guard.backing'),
1253
+ stone:
1254
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1255
+ ? materials.getAppearance(JAPANESE_SAKURA_PORTAL_FOUNDATION_STONE_ID, 'structure.secondary')
1256
+ : materials.get('structure.secondary'),
1257
+ kawara:
1258
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1259
+ ? materials.getAppearance(JAPANESE_SAKURA_CASTLE_KAWARA_ID, 'structure.secondary')
1260
+ : materials.get('structure.secondary'),
1261
+ hardware:
1262
+ activeThemeId === JAPAN_ELEVATOR_THEME_ID
1263
+ ? materials.getAppearance(JAPANESE_SAKURA_BRIDGE_HARDWARE_ID, 'structure.secondary')
1264
+ : materials.get('structure.secondary'),
1265
+ signal: materials.get('signal.route'),
1266
+ },
1267
+ space: {
1268
+ chassis:
1269
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1270
+ ? materials.getAppearance(SPACE_STATION_ARMORED_WALL_ID, 'lane.guard')
1271
+ : materials.get('lane.guard'),
1272
+ panel:
1273
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1274
+ ? materials.getAppearance(SPACE_STATION_WALL_PANEL_ID, 'lane.guard')
1275
+ : materials.get('lane.guard'),
1276
+ rib:
1277
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1278
+ ? materials.getAppearance(SPACE_STATION_WALL_RIB_ID, 'structure.primary')
1279
+ : materials.get('structure.primary'),
1280
+ trim:
1281
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1282
+ ? materials.getAppearance(SPACE_STATION_WALL_TRIM_ID, 'structure.secondary')
1283
+ : materials.get('structure.secondary'),
1284
+ light:
1285
+ activeThemeId === SPACE_ELEVATOR_THEME_ID
1286
+ ? materials.getAppearance(SPACE_STATION_WALL_LIGHT_ID, 'effect.energy')
1287
+ : materials.get('effect.energy'),
1288
+ },
1289
+ }
1290
+ const portalDerivedSkins = addPortalDerivedElevatorSkins(root, {
1291
+ activeThemeId,
1292
+ id: pieceId,
1293
+ bottom: vector(TRACK_LAB_CONVEYOR_BOTTOM),
1294
+ rotation: new THREE.Quaternion(),
1295
+ width: TRACK_LAB_CONVEYOR_WIDTH,
1296
+ height: liftHeight,
1297
+ depth: TRACK_LAB_CONVEYOR_SHELF_DEPTH,
1298
+ releaseHeight: TRACK_LAB_CONVEYOR_RELEASE[1] - TRACK_LAB_CONVEYOR_BOTTOM[1],
1299
+ materials: portalDerivedMaterials,
1300
+ })
1301
+ portalDerivedSkins.forEach(skin => materials.captureBindings(skin))
1302
+ const themedElevatorIds = [
1303
+ JUNGLE_TEMPLE_THEME_ID,
1304
+ EGYPT_ELEVATOR_THEME_ID,
1305
+ JAPAN_ELEVATOR_THEME_ID,
1306
+ SPACE_ELEVATOR_THEME_ID,
1307
+ ]
1308
+ for (const side of [-1, 1] as const) {
1309
+ addMesh(
1310
+ new THREE.BoxGeometry(0.24, liftHeight + 1, TRACK_LAB_CONVEYOR_SHELF_DEPTH + 0.16),
1311
+ 'lane.guard',
1312
+ `conveyor-wall-${side}`,
1313
+ mesh => {
1314
+ mesh.position.set(side * (TRACK_LAB_CONVEYOR_WIDTH / 2 - 0.12), liftMiddleY, liftZ)
1315
+ mesh.userData.themeHidden = themedElevatorIds
1316
+ },
1317
+ JUNGLE_TEMPLE_STONE_MATERIAL_ID,
1318
+ )
1319
+ addMesh(
1320
+ new THREE.BoxGeometry(0.38, liftHeight, 0.34),
1321
+ 'mechanism.body',
1322
+ `conveyor-belt-${side}`,
1323
+ mesh => {
1324
+ mesh.position.set(side * (TRACK_LAB_CONVEYOR_WIDTH / 2 - 0.58), liftMiddleY, liftZ + 1.7)
1325
+ mesh.userData.themeHidden = themedElevatorIds
1326
+ },
1327
+ JUNGLE_TEMPLE_TIMBER_MATERIAL_ID,
1328
+ )
1329
+ }
1330
+
1331
+ for (const y of [TRACK_LAB_CONVEYOR_BOTTOM[1] - 0.5, TRACK_LAB_CONVEYOR_TOP[1] + 0.5]) {
1332
+ const drum = addMesh(
1333
+ new VisualCylinderGeometry(0.56, 0.56, TRACK_LAB_CONVEYOR_WIDTH - 0.7, 20),
1334
+ 'signal.route',
1335
+ `conveyor-drum-${y}`,
1336
+ mesh => {
1337
+ mesh.position.set(0, y, liftZ)
1338
+ mesh.rotation.z = Math.PI / 2
1339
+ mesh.userData.decorative = true
1340
+ mesh.userData.themeHidden = [WILD_WEST_THEME_ID, ...themedElevatorIds]
1341
+ },
1342
+ JUNGLE_TEMPLE_TIMBER_MATERIAL_ID,
1343
+ )
1344
+ materials.captureBindings(drum)
1345
+ }
1346
+
1347
+ for (let index = 0; index < TRACK_LAB_CONVEYOR_SHELF_COUNT; index += 1) {
1348
+ const progress = (index + 0.5) / TRACK_LAB_CONVEYOR_SHELF_COUNT
1349
+ const shelf = addMesh(
1350
+ new THREE.BoxGeometry(TRACK_LAB_CONVEYOR_WIDTH - 0.56, 0.2, TRACK_LAB_CONVEYOR_SHELF_DEPTH),
1351
+ 'mechanism.moving',
1352
+ `conveyor-shelf-${index + 1}`,
1353
+ mesh => {
1354
+ const shelfHeight = THREE.MathUtils.lerp(TRACK_LAB_CONVEYOR_BOTTOM[1], TRACK_LAB_CONVEYOR_TOP[1], progress)
1355
+ const tiltTransition = THREE.MathUtils.smoothstep(shelfHeight - TRACK_LAB_CONVEYOR_BOTTOM[1], 0.45, 3.2)
1356
+ mesh.position.set(0, shelfHeight, liftZ)
1357
+ mesh.rotation.x = THREE.MathUtils.lerp(
1358
+ TRACK_LAB_CONVEYOR_SHELF_TILT_RADIANS,
1359
+ -TRACK_LAB_CONVEYOR_SHELF_TILT_RADIANS,
1360
+ tiltTransition,
1361
+ )
1362
+ },
1363
+ JUNGLE_TEMPLE_PLATFORM_MATERIAL_ID,
1364
+ )
1365
+ markDynamicTrackShadowRoot(shelf)
1366
+ if (activeThemeId === WILD_WEST_THEME_ID)
1367
+ decorateWildWestMineLiftShelf(
1368
+ shelf,
1369
+ `${pieceId}-conveyor-shelf-${index + 1}`,
1370
+ TRACK_LAB_CONVEYOR_WIDTH - 0.56,
1371
+ TRACK_LAB_CONVEYOR_SHELF_DEPTH,
1372
+ mineLiftMaterials,
1373
+ )
1374
+ if (activeThemeId === JUNGLE_TEMPLE_THEME_ID)
1375
+ decorateJungleTempleShelf(
1376
+ shelf,
1377
+ `${pieceId}-conveyor-shelf-${index + 1}`,
1378
+ TRACK_LAB_CONVEYOR_WIDTH - 0.56,
1379
+ TRACK_LAB_CONVEYOR_SHELF_DEPTH,
1380
+ jungleTempleMaterials,
1381
+ )
1382
+ decoratePortalDerivedElevatorShelf(
1383
+ shelf,
1384
+ `${pieceId}-conveyor-shelf-${index + 1}`,
1385
+ TRACK_LAB_CONVEYOR_WIDTH - 0.56,
1386
+ TRACK_LAB_CONVEYOR_SHELF_DEPTH,
1387
+ portalDerivedMaterials,
1388
+ activeThemeId,
1389
+ )
1390
+ materials.captureBindings(shelf)
1391
+ }
1392
+ // Theme visibility metadata is assigned by the mesh configurators after the
1393
+ // initial slot binding, so recapture the completed lift construction once.
1394
+ materials.captureBindings(root)
1395
+ }
1396
+
1397
+ function lengthUnitsFromParameters(parameters: JsonObject): number {
1398
+ const value = parameters.lengthUnits
1399
+ return typeof value === 'number' && Number.isFinite(value) ? value : 1
1400
+ }
1401
+
1402
+ function gradeFromParameters(parameters: JsonObject): 'flat' | 'slope' | 'steep' {
1403
+ return parameters.grade === 'flat' || parameters.grade === 'steep' ? parameters.grade : 'slope'
1404
+ }
1405
+
1406
+ function conePatternFromParameters(parameters: JsonObject): 'classic' | 'dense' {
1407
+ return parameters.pattern === 'classic' ? 'classic' : 'dense'
1408
+ }
1409
+
1410
+ function reboundPatternFromParameters(parameters: JsonObject): ReboundPattern {
1411
+ return parameters.pattern === 'zigzag' ? 'zigzag' : 'alternating'
1412
+ }
1413
+
1414
+ function risingWallDensityFromParameters(parameters: JsonObject): RisingWallDensity {
1415
+ return parameters.density === 'sparse' ? 'sparse' : 'dense'
1416
+ }
1417
+
1418
+ function vector(value: TrackVector): THREE.Vector3 {
1419
+ return new THREE.Vector3(...value)
1420
+ }