@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,1152 @@
1
+ import type {
2
+ CompiledTrack,
3
+ CompiledTrackPiece,
4
+ PhysicsTrackDefinition,
5
+ PhysicsTrackPieceDefinition,
6
+ TrackDocument,
7
+ TrackVector,
8
+ } from '@marble.fun/physics-engine/browser'
9
+ import {
10
+ collectCompletePortalPairs,
11
+ hasAuthoritativeTrackLabPhysics,
12
+ laneGradeDrop,
13
+ portalPairNumber,
14
+ trackWidthParameterUnits,
15
+ } from '@marble.fun/physics-engine/browser'
16
+ import { createTrackLabPhysicsTrack } from '@marble.fun/physics-engine/track-lab-physics-adapter'
17
+
18
+ import {
19
+ defaultTrackTroughDepth,
20
+ NEUTRAL_STUDIO_SKIN,
21
+ type ConveyorPieceDefinition,
22
+ type ReboundPieceDefinition,
23
+ type FlipperBankPieceDefinition,
24
+ type PegFieldPieceDefinition,
25
+ type PortalPieceDefinition,
26
+ type RailPieceDefinition,
27
+ type RisingWallForestPieceDefinition,
28
+ type SplitterPieceDefinition,
29
+ type TrackDefinition,
30
+ type TrackGuidePoint,
31
+ type TrackPieceDefinition,
32
+ type VortexPieceDefinition,
33
+ } from '../play/race/track-definition'
34
+ import { portalPairColor } from '../play/race/portal-pair-color'
35
+ import { portalVisualStyleFromId } from '../play/race/portal-visual'
36
+ import { connectedTrackMaterialPhases } from '../../three/track-material-continuity'
37
+ import { fluidSurfaceStyleFromId } from '../../three/water-appearance'
38
+ import {
39
+ CONE_FIELD_PEG_HEIGHT,
40
+ CONE_FIELD_PEG_RADIUS,
41
+ CONE_FIELD_WIDTH,
42
+ createConeFieldDeckProfile,
43
+ createConeFieldLayout,
44
+ } from './cone-field-layout'
45
+ import {
46
+ TRACK_LAB_CONVEYOR_BOTTOM,
47
+ TRACK_LAB_CONVEYOR_DIRECTION,
48
+ TRACK_LAB_CONVEYOR_LOADING,
49
+ TRACK_LAB_CONVEYOR_RELEASE,
50
+ TRACK_LAB_CONVEYOR_SHELF_COUNT,
51
+ TRACK_LAB_CONVEYOR_SHELF_DEPTH,
52
+ TRACK_LAB_CONVEYOR_SHELF_TILT_RADIANS,
53
+ TRACK_LAB_CONVEYOR_SPEED,
54
+ TRACK_LAB_CONVEYOR_TOP,
55
+ TRACK_LAB_CONVEYOR_WIDTH,
56
+ } from './conveyor-layout'
57
+ import { createFlipperBankDeck, createFlipperBankLayout } from './flipper-bank-layout'
58
+ import {
59
+ CANONICAL_FLIPPER_DIMENSIONS,
60
+ CANONICAL_FLIPPER_SWEEP_RADIANS,
61
+ FLIPPER_MINIMUM_ONE_WAY_SWEEP_SECONDS,
62
+ } from './flipper-component'
63
+ import {
64
+ REBOUND_HEIGHT,
65
+ REBOUND_HOLD_SECONDS,
66
+ REBOUND_LENGTH,
67
+ REBOUND_COOLDOWN_SECONDS,
68
+ REBOUND_RESTITUTION,
69
+ REBOUND_RETURN_SECONDS,
70
+ REBOUND_STRIKE_DISTANCE,
71
+ REBOUND_STRIKE_SECONDS,
72
+ REBOUND_WIDTH,
73
+ createReboundDeck,
74
+ createReboundLayout,
75
+ createSingleFileSqueezeRail,
76
+ type ReboundPattern,
77
+ } from './simple-obstacle-layout'
78
+ import {
79
+ RISING_WALL_CYCLE_SECONDS,
80
+ RISING_WALL_DEPTH,
81
+ RISING_WALL_HEIGHT,
82
+ RISING_WALL_WIDTH,
83
+ createRisingWallDeck,
84
+ createRisingWallLayout,
85
+ type RisingWallDensity,
86
+ } from './rising-wall-layout'
87
+ import {
88
+ ATLAS_VORTEX_CENTER,
89
+ ATLAS_VORTEX_DRAIN_OUTPUT_WIDTH,
90
+ ATLAS_VORTEX_DRAIN_PATH,
91
+ ATLAS_VORTEX_DRAIN_CHANNEL,
92
+ ATLAS_VORTEX_ENTRY,
93
+ ATLAS_VORTEX_ENTRY_APPROACH,
94
+ ATLAS_VORTEX_OUTPUT,
95
+ createAtlasVortexDefinition,
96
+ atlasVortexDrainProfile,
97
+ } from './atlas-vortex-layout'
98
+ import {
99
+ applyRailConnectorFrames,
100
+ createIntersectionRailDefinitions,
101
+ createRoutingMergeDeck,
102
+ createRoutingSplitterDeck,
103
+ createSharedRailConnectorFrame,
104
+ createPublicLaneRailDefinition,
105
+ requiresHighSpeedMarbleCcd,
106
+ splitterPivotLocalPosition,
107
+ TRACK_LAB_FINISH_TERMINAL_HEIGHT,
108
+ TRACK_LAB_FINISH_WIDTH,
109
+ TRACK_LAB_SPLITTER_ARM_HEIGHT,
110
+ TRACK_LAB_SPLITTER_ARM_DEPTH,
111
+ TRACK_LAB_SPLITTER_ARM_LENGTH,
112
+ TRACK_LAB_SPLITTER_CYCLE_SECONDS,
113
+ TRACK_LAB_SPLITTER_SWING_RADIANS,
114
+ TRACK_LAB_WALL_HEIGHT,
115
+ type RailConnectorFrame,
116
+ } from './track-piece-layout'
117
+
118
+ export interface TrackRuntimeAdapterSuccess {
119
+ ok: true
120
+ track: TrackDefinition
121
+ }
122
+
123
+ export interface TrackRuntimeAdapterFailure {
124
+ ok: false
125
+ errors: readonly string[]
126
+ }
127
+
128
+ export type TrackRuntimeAdapterResult = TrackRuntimeAdapterSuccess | TrackRuntimeAdapterFailure
129
+
130
+ export function createTrackLabRuntimeTrack(
131
+ document: TrackDocument,
132
+ compiled: CompiledTrack,
133
+ ): TrackRuntimeAdapterResult {
134
+ const physicsResult = createTrackLabPhysicsTrack(document, compiled)
135
+ if (!physicsResult.ok) return physicsResult
136
+
137
+ const unsupported = compiled.pieces.filter(piece => !hasAuthoritativeTrackLabPhysics(piece.runtimeKind))
138
+ if (unsupported.length > 0) {
139
+ return {
140
+ ok: false,
141
+ errors: unsupported.map(piece => `${piece.id} (${piece.templateId}) does not have Test Run physics yet.`),
142
+ }
143
+ }
144
+
145
+ const finish = compiled.pieces.find(piece => piece.id === compiled.route.finishPieceId)
146
+ const start = compiled.pieces.find(piece => piece.id === compiled.route.startPieceId)
147
+ if (!finish || !start) {
148
+ return { ok: false, errors: ['The compiled route has no start or finish piece.'] }
149
+ }
150
+ const highSpeedMarbleCcd = requiresHighSpeedMarbleCcd(document.pieces)
151
+
152
+ const connectedConnectorKeys = new Set(
153
+ compiled.connections.flatMap(connection => [
154
+ `${connection.from.pieceId}:${connection.from.connectorId}`,
155
+ `${connection.to.pieceId}:${connection.to.connectorId}`,
156
+ ]),
157
+ )
158
+ const compiledPiecesById = new Map(compiled.pieces.map(piece => [piece.id, piece]))
159
+ const connectorFramesByPiece = new Map<string, Map<string, RailConnectorFrame>>()
160
+ for (const connection of compiled.connections) {
161
+ const fromPiece = compiledPiecesById.get(connection.from.pieceId)
162
+ const toPiece = compiledPiecesById.get(connection.to.pieceId)
163
+ const fromConnector = fromPiece?.connectors.find(connector => connector.id === connection.from.connectorId)
164
+ const toConnector = toPiece?.connectors.find(connector => connector.id === connection.to.connectorId)
165
+ if (!fromPiece || !toPiece || !fromConnector || !toConnector) continue
166
+ if (fromConnector.troughProfile !== 'concave' || toConnector.troughProfile !== 'concave') continue
167
+ const fromSupportsCanonicalPorts = supportsCanonicalRailPorts(fromPiece)
168
+ const toSupportsCanonicalPorts = supportsCanonicalRailPorts(toPiece)
169
+ if (!fromSupportsCanonicalPorts && !toSupportsCanonicalPorts) continue
170
+ const sharedFrame = createSharedRailConnectorFrame(fromConnector, toConnector, fromConnector.worldDirection)
171
+ // A single-file squeeze owns a genuinely planar deck. Negotiate that
172
+ // zero-depth cross-section onto both sides of each shared terminal so the
173
+ // generic connector pass cannot inject a one-sample concave kink back
174
+ // into the visible ribbon or its matching Rapier mesh.
175
+ if (fromPiece.runtimeKind === 'single-file-squeeze' || toPiece.runtimeKind === 'single-file-squeeze') {
176
+ sharedFrame.troughDepth = 0
177
+ }
178
+ sharedFrame.position = [
179
+ (fromConnector.worldPosition[0] + toConnector.worldPosition[0]) / 2,
180
+ (fromConnector.worldPosition[1] + toConnector.worldPosition[1]) / 2,
181
+ (fromConnector.worldPosition[2] + toConnector.worldPosition[2]) / 2,
182
+ ]
183
+ for (const [pieceId, connectorId, supportsCanonicalPorts] of [
184
+ [fromPiece.id, fromConnector.id, fromSupportsCanonicalPorts],
185
+ [toPiece.id, toConnector.id, toSupportsCanonicalPorts],
186
+ ] as const) {
187
+ if (!supportsCanonicalPorts) continue
188
+ const frames = connectorFramesByPiece.get(pieceId) ?? new Map<string, RailConnectorFrame>()
189
+ frames.set(connectorId, sharedFrame)
190
+ connectorFramesByPiece.set(pieceId, frames)
191
+ }
192
+ }
193
+ normalizeCompoundConnectorFrames(compiled, connectorFramesByPiece)
194
+ const railsBySourcePiece = new Map<string, RailPieceDefinition[]>()
195
+ const rails = compiled.pieces.flatMap(piece => {
196
+ const pieceRails = createRuntimeRails(piece, connectedConnectorKeys, connectorFramesByPiece.get(piece.id))
197
+ railsBySourcePiece.set(piece.id, pieceRails)
198
+ return pieceRails
199
+ })
200
+ const materialMetricLengths = new Map(
201
+ compiled.pieces.map(piece => {
202
+ const representativeLength = Math.max(
203
+ 0,
204
+ ...(railsBySourcePiece.get(piece.id) ?? []).map(rail =>
205
+ rail.points.slice(1).reduce((sum, point, index) => {
206
+ const previous = rail.points[index]!
207
+ return sum + Math.hypot(point[0] - previous[0], point[1] - previous[1], point[2] - previous[2])
208
+ }, 0),
209
+ ),
210
+ )
211
+ return [piece.id, representativeLength] as const
212
+ }),
213
+ )
214
+ const materialPhases = connectedTrackMaterialPhases(
215
+ compiled.pieces.map(piece => piece.id),
216
+ compiled.connections,
217
+ materialMetricLengths,
218
+ new Map(),
219
+ new Map(
220
+ [...railsBySourcePiece].flatMap(([pieceId, rails]) => {
221
+ const connectorId = rails.find(rail => rail.junctionMainConnectorId)?.junctionMainConnectorId
222
+ return connectorId ? [[pieceId, connectorId] as const] : []
223
+ }),
224
+ ),
225
+ )
226
+ const incomingMaterialPhase = (pieceId: string, connectorId: string): number | undefined => {
227
+ const connection = compiled.connections.find(
228
+ candidate => candidate.to.pieceId === pieceId && candidate.to.connectorId === connectorId,
229
+ )
230
+ if (!connection) return undefined
231
+ return (
232
+ (materialPhases.get(connection.from.pieceId) ?? 0) + (materialMetricLengths.get(connection.from.pieceId) ?? 0)
233
+ )
234
+ }
235
+ for (const [sourcePieceId, pieceRails] of railsBySourcePiece) {
236
+ const phase = materialPhases.get(sourcePieceId) ?? 0
237
+ pieceRails.forEach(rail => {
238
+ rail.materialConnectionPhase = rail.junctionMainConnectorId
239
+ ? (incomingMaterialPhase(sourcePieceId, rail.junctionMainConnectorId) ?? phase)
240
+ : phase
241
+ if (rail.junctionBranches) {
242
+ rail.junctionBranches = rail.junctionBranches.map(branch => ({
243
+ ...branch,
244
+ materialConnectionPhase:
245
+ incomingMaterialPhase(sourcePieceId, branch.connectorId) ?? branch.materialConnectionPhase,
246
+ }))
247
+ }
248
+ })
249
+ }
250
+ const pegFields = compiled.pieces.flatMap(piece => createRuntimePegFields(piece))
251
+ const vortices = compiled.pieces.flatMap(piece => createRuntimeVortices(piece, compiled))
252
+ const splitters = compiled.pieces.flatMap(piece => createRuntimeSplitters(piece))
253
+ const rebounds = compiled.pieces.flatMap(piece => createRuntimeRebounds(piece))
254
+ const risingWallForests = compiled.pieces.flatMap(piece => createRuntimeRisingWallForests(piece))
255
+ const flipperBanks = compiled.pieces.flatMap(piece => createRuntimeFlipperBanks(piece))
256
+ const conveyors = compiled.pieces.flatMap(piece => createRuntimeConveyors(piece))
257
+ const portals = createRuntimePortals(document, compiled)
258
+ const finishDirection = rotateVector([0, 0, -1], finish.transform.yawSteps)
259
+ const finishCenter = transformLocalPoint([0, -2, -2], finish)
260
+ const finishInput =
261
+ finish.connectors.find(connector => connector.kind.endsWith('input'))?.worldPosition ?? finishCenter
262
+ const channelEnd = addVector(finishCenter, scaleVector(finishDirection, 10))
263
+ channelEnd[1] -= 0.7
264
+ rails.push(
265
+ applyRailConnectorFrames(
266
+ {
267
+ id: `${finish.id}-channel`,
268
+ label: 'Continuous narrowing ranked finish channel',
269
+ type: 'rail',
270
+ raceRole: 'finish',
271
+ materialConnectionPhase: materialPhases.get(finish.id) ?? 0,
272
+ ...(finish.appearance ? { appearance: finish.appearance } : {}),
273
+ points: [finishInput, finishCenter, channelEnd],
274
+ width: TRACK_LAB_FINISH_WIDTH,
275
+ pointWidths: [4, TRACK_LAB_FINISH_WIDTH, TRACK_LAB_FINISH_WIDTH],
276
+ troughDepth: defaultTrackTroughDepth(TRACK_LAB_FINISH_WIDTH),
277
+ pointTroughDepths: [
278
+ defaultTrackTroughDepth(4),
279
+ defaultTrackTroughDepth(TRACK_LAB_FINISH_WIDTH),
280
+ defaultTrackTroughDepth(TRACK_LAB_FINISH_WIDTH),
281
+ ],
282
+ troughProfile: 'full',
283
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
284
+ wallColliderHeight: highSpeedMarbleCcd ? TRACK_LAB_FINISH_TERMINAL_HEIGHT : TRACK_LAB_WALL_HEIGHT,
285
+ // A single-file taper can hold two side-by-side rigid marbles as a
286
+ // self-locking wedge. The guide walls stay polished while the deck
287
+ // retains the ordinary rolling grip used by the visible finish lane.
288
+ floorFriction: 0.06,
289
+ wallFriction: 0,
290
+ closedEnd: true,
291
+ closedEndHeight: highSpeedMarbleCcd ? TRACK_LAB_FINISH_TERMINAL_HEIGHT : TRACK_LAB_WALL_HEIGHT,
292
+ },
293
+ finish.connectors.map(connector => ({
294
+ ...connector,
295
+ localPosition: connector.worldPosition,
296
+ localDirection: connector.worldDirection,
297
+ })),
298
+ new Set(
299
+ finish.connectors
300
+ .filter(connector => connectedConnectorKeys.has(`${finish.id}:${connector.id}`))
301
+ .map(connector => connector.id),
302
+ ),
303
+ connectorFramesByPiece.get(finish.id),
304
+ ),
305
+ )
306
+ rails.forEach(rail => {
307
+ rail.verticalWalls = true
308
+ })
309
+
310
+ const startEntry = rails.find(rail => rail.id === start.id)?.points[0] ?? start.transform.position
311
+ const guidePoints = createRuntimeGuide(compiled, startEntry, finishCenter, channelEnd)
312
+ if (guidePoints.length < 2) {
313
+ return { ok: false, errors: ['The primary route could not produce a runtime guide.'] }
314
+ }
315
+
316
+ const presentationTrack: TrackDefinition = {
317
+ id: `track-lab-${compiled.simulationHash}`,
318
+ version: 1,
319
+ highSpeedMarbleCcd,
320
+ label: document.metadata.title,
321
+ description: document.metadata.description,
322
+ targetDurationSeconds: document.settings.targetDurationSeconds,
323
+ spawnProgress: Math.min(0.8, guidePoints.at(-1)!.progress * 0.08),
324
+ spawnWidth: widthFromPiece(start),
325
+ themeId: document.presentation.defaultThemeId,
326
+ skin: NEUTRAL_STUDIO_SKIN,
327
+ pieces: [
328
+ ...rails,
329
+ ...pegFields,
330
+ ...vortices,
331
+ ...splitters,
332
+ ...rebounds,
333
+ ...risingWallForests,
334
+ ...flipperBanks,
335
+ ...conveyors,
336
+ ...portals,
337
+ {
338
+ id: finish.id,
339
+ label: 'Track Lab finish',
340
+ type: 'finish',
341
+ center: finishCenter,
342
+ direction: finishDirection,
343
+ width: TRACK_LAB_FINISH_WIDTH,
344
+ channelEnd,
345
+ rankingSlots: Math.min(6, document.settings.spawnMarbleCapacity),
346
+ rankingSpacing: 1.22,
347
+ },
348
+ ],
349
+ cameraPath: {
350
+ id: 'track-lab-camera',
351
+ points: guidePoints.map(point => ({
352
+ ...point,
353
+ position: [point.position[0], point.position[1] + 0.8, point.position[2]],
354
+ })),
355
+ },
356
+ projectionPaths: [
357
+ {
358
+ id: 'track-lab-primary-route',
359
+ points: guidePoints,
360
+ },
361
+ ],
362
+ }
363
+ // Keep authored visual context available to renderers without changing the
364
+ // serialized simulation definition or its deterministic fingerprint.
365
+ const track = decoratePhysicsTrack(physicsResult.track, presentationTrack)
366
+ Object.defineProperty(track, 'sourceDocument', {
367
+ configurable: false,
368
+ enumerable: false,
369
+ value: document,
370
+ })
371
+ return {
372
+ ok: true,
373
+ track,
374
+ }
375
+ }
376
+
377
+ /**
378
+ * The runtime is allowed to decorate authoritative definitions, never to
379
+ * supply their physical values. Keeping the allowlist here means drift in a
380
+ * renderer-side layout helper cannot silently change a race result.
381
+ */
382
+ function decoratePhysicsTrack(physics: PhysicsTrackDefinition, presentation: TrackDefinition): TrackDefinition {
383
+ const presentationByKey = new Map(presentation.pieces.map(piece => [`${piece.type}:${piece.id}`, piece] as const))
384
+ const pieces = physics.pieces.map(piece => {
385
+ const visual = presentationByKey.get(`${piece.type}:${piece.id}`)
386
+ if (!visual) throw new Error(`Runtime presentation is missing ${piece.type}:${piece.id}`)
387
+ return decoratePhysicsPiece(piece, visual)
388
+ })
389
+ return {
390
+ ...physics,
391
+ label: presentation.label,
392
+ description: presentation.description,
393
+ targetDurationSeconds: presentation.targetDurationSeconds,
394
+ ...(presentation.themeId ? { themeId: presentation.themeId } : {}),
395
+ skin: presentation.skin,
396
+ pieces,
397
+ cameraPath: physics.cameraPath,
398
+ projectionPaths: physics.projectionPaths,
399
+ }
400
+ }
401
+
402
+ function decoratePhysicsPiece(
403
+ physics: PhysicsTrackPieceDefinition,
404
+ visual: TrackPieceDefinition,
405
+ ): TrackPieceDefinition {
406
+ const base = {
407
+ ...physics,
408
+ label: visual.label,
409
+ ...(visual.appearance ? { appearance: visual.appearance } : {}),
410
+ ...(visual.materialConnectionPhase !== undefined
411
+ ? { materialConnectionPhase: visual.materialConnectionPhase }
412
+ : {}),
413
+ ...(visual.deferStoneArchitectureUntilMaterialContinuity !== undefined
414
+ ? { deferStoneArchitectureUntilMaterialContinuity: visual.deferStoneArchitectureUntilMaterialContinuity }
415
+ : {}),
416
+ }
417
+ if (physics.type === 'rail' && visual.type === 'rail') {
418
+ const junctionBranches = physics.junctionBranches?.map(branch => {
419
+ const visualBranch = visual.junctionBranches?.find(candidate => candidate.connectorId === branch.connectorId)
420
+ return {
421
+ ...branch,
422
+ ...(visualBranch?.materialConnectionPhase !== undefined
423
+ ? { materialConnectionPhase: visualBranch.materialConnectionPhase }
424
+ : {}),
425
+ ...(visualBranch?.waterFlowStartConnected !== undefined
426
+ ? { waterFlowStartConnected: visualBranch.waterFlowStartConnected }
427
+ : {}),
428
+ }
429
+ })
430
+ const centerDivider = physics.centerDivider
431
+ ? {
432
+ ...physics.centerDivider,
433
+ ...(visual.centerDivider?.visualRole ? { visualRole: visual.centerDivider.visualRole } : {}),
434
+ }
435
+ : undefined
436
+ return {
437
+ ...base,
438
+ ...(visual.materialConnectors ? { materialConnectors: visual.materialConnectors } : {}),
439
+ ...(visual.previewProfileSamplePitch !== undefined
440
+ ? { previewProfileSamplePitch: visual.previewProfileSamplePitch }
441
+ : {}),
442
+ ...(visual.waterFlowStartConnected !== undefined
443
+ ? { waterFlowStartConnected: visual.waterFlowStartConnected }
444
+ : {}),
445
+ ...(visual.waterFlowEndConnected !== undefined ? { waterFlowEndConnected: visual.waterFlowEndConnected } : {}),
446
+ ...(visual.continuousFloorMaterialOnly !== undefined
447
+ ? { continuousFloorMaterialOnly: visual.continuousFloorMaterialOnly }
448
+ : {}),
449
+ ...(visual.raceRole ? { raceRole: visual.raceRole } : {}),
450
+ ...(visual.railroadProfile ? { railroadProfile: visual.railroadProfile } : {}),
451
+ ...(junctionBranches ? { junctionBranches } : {}),
452
+ ...(centerDivider ? { centerDivider } : {}),
453
+ } as unknown as TrackPieceDefinition
454
+ }
455
+ if (physics.type === 'vortex' && visual.type === 'vortex') {
456
+ return {
457
+ ...base,
458
+ ...(visual.waterFlowInputConnected !== undefined
459
+ ? { waterFlowInputConnected: visual.waterFlowInputConnected }
460
+ : {}),
461
+ } as unknown as TrackPieceDefinition
462
+ }
463
+ if (physics.type === 'portal' && visual.type === 'portal') {
464
+ return {
465
+ ...base,
466
+ color: visual.color,
467
+ entryVisualCenter: visual.entryVisualCenter,
468
+ entryVisualDirection: visual.entryVisualDirection,
469
+ exitVisualCenter: visual.exitVisualCenter,
470
+ exitVisualDirection: visual.exitVisualDirection,
471
+ ...(visual.entryVisualStyle ? { entryVisualStyle: visual.entryVisualStyle } : {}),
472
+ ...(visual.exitVisualStyle ? { exitVisualStyle: visual.exitVisualStyle } : {}),
473
+ } as unknown as TrackPieceDefinition
474
+ }
475
+ if (physics.type === 'finish' && visual.type === 'finish') {
476
+ return {
477
+ ...base,
478
+ channelEnd: visual.channelEnd,
479
+ rankingSlots: visual.rankingSlots,
480
+ rankingSpacing: visual.rankingSpacing,
481
+ } as unknown as TrackPieceDefinition
482
+ }
483
+ return base as TrackPieceDefinition
484
+ }
485
+
486
+ function connectorFramePitch(frame: RailConnectorFrame): number {
487
+ const [x, y, z] = frame.tangent
488
+ return Math.abs(y) / Math.max(0.000_001, Math.hypot(x, z))
489
+ }
490
+
491
+ /**
492
+ * A compound split/merge deck owns one broad planar terminal, so its two
493
+ * branch ports cannot carry different terminal pitches simultaneously. Use
494
+ * the steepest negotiated branch frame for that shared plane and propagate
495
+ * only its tangent back to both connected neighbors. Width and trough profile
496
+ * remain specific to each connection.
497
+ */
498
+ export function normalizeCompoundConnectorFrames(
499
+ compiled: Pick<CompiledTrack, 'pieces' | 'connections'>,
500
+ connectorFramesByPiece: Map<string, Map<string, RailConnectorFrame>>,
501
+ ): void {
502
+ const piecesById = new Map(compiled.pieces.map(piece => [piece.id, piece]))
503
+ for (const piece of compiled.pieces) {
504
+ const compoundRole =
505
+ piece.runtimeKind === 'splitter' ? 'output' : piece.runtimeKind === 'merge' ? 'input' : undefined
506
+ if (!compoundRole) continue
507
+
508
+ const connectorIds = piece.connectors
509
+ .filter(connector => connector.role === compoundRole)
510
+ .map(connector => connector.id)
511
+ const pieceFrames = connectorFramesByPiece.get(piece.id)
512
+ const connectedFrames = connectorIds
513
+ .map(connectorId => pieceFrames?.get(connectorId))
514
+ .filter((frame): frame is RailConnectorFrame => frame !== undefined)
515
+ if (connectedFrames.length < 2) continue
516
+ const sharedTangent = connectedFrames.reduce((steepest, frame) =>
517
+ connectorFramePitch(frame) > connectorFramePitch(steepest) ? frame : steepest,
518
+ ).tangent
519
+
520
+ for (const connectorId of connectorIds) {
521
+ const compoundFrame = pieceFrames?.get(connectorId)
522
+ if (compoundFrame) pieceFrames!.set(connectorId, { ...compoundFrame, tangent: sharedTangent })
523
+ const connection = compiled.connections.find(
524
+ candidate =>
525
+ (candidate.from.pieceId === piece.id && candidate.from.connectorId === connectorId) ||
526
+ (candidate.to.pieceId === piece.id && candidate.to.connectorId === connectorId),
527
+ )
528
+ if (!connection) continue
529
+ const neighbourEndpoint =
530
+ connection.from.pieceId === piece.id && connection.from.connectorId === connectorId
531
+ ? connection.to
532
+ : connection.from
533
+ const neighbourPiece = piecesById.get(neighbourEndpoint.pieceId)
534
+ if (!neighbourPiece || !supportsCanonicalRailPorts(neighbourPiece)) continue
535
+ const neighbourFrames = connectorFramesByPiece.get(neighbourPiece.id)
536
+ const neighbourFrame = neighbourFrames?.get(neighbourEndpoint.connectorId)
537
+ if (neighbourFrame) {
538
+ neighbourFrames!.set(neighbourEndpoint.connectorId, { ...neighbourFrame, tangent: sharedTangent })
539
+ }
540
+ }
541
+ }
542
+ }
543
+
544
+ function createRuntimePortals(document: TrackDocument, compiled: CompiledTrack): PortalPieceDefinition[] {
545
+ const compiledById = new Map(compiled.pieces.map(piece => [piece.id, piece]))
546
+ return collectCompletePortalPairs(document).flatMap(pair => {
547
+ const entry = compiledById.get(pair.entry.id)
548
+ const exit = compiledById.get(pair.exit.id)
549
+ if (!entry || !exit) return []
550
+ return [
551
+ {
552
+ id: `portal-${pair.pairId}`,
553
+ label: `Portal Pair ${portalPairNumber(pair.pairId)}`,
554
+ type: 'portal',
555
+ entryVisualStyle: portalVisualStyleFromId(entry.appearance?.portalVisualId),
556
+ exitVisualStyle: portalVisualStyleFromId(exit.appearance?.portalVisualId),
557
+ color: portalPairColor(pair.pairId),
558
+ entry: transformLocalPoint([0, 1.65, 0], entry),
559
+ entryDirection: rotateVector([0, 0, 1], entry.transform.yawSteps),
560
+ exit: transformLocalPoint([0, 0.65, -0.75], exit),
561
+ exitDirection: rotateVector([0, 0, -1], exit.transform.yawSteps),
562
+ entryVisualCenter: transformLocalPoint([0, 1.65, 0], entry),
563
+ entryVisualDirection: rotateVector([0, 0, 1], entry.transform.yawSteps),
564
+ exitVisualCenter: transformLocalPoint([0, 1.65, 0], exit),
565
+ exitVisualDirection: rotateVector([0, 0, -1], exit.transform.yawSteps),
566
+ captureHalfExtents: [1.96, 1.7, 0.48],
567
+ releaseSpeed: 2.4,
568
+ },
569
+ ]
570
+ })
571
+ }
572
+
573
+ function createRuntimeConveyors(piece: CompiledTrackPiece): ConveyorPieceDefinition[] {
574
+ if (piece.runtimeKind !== 'conveyor') return []
575
+ return [
576
+ {
577
+ id: piece.id,
578
+ label: piece.templateId,
579
+ type: 'conveyor',
580
+ bottom: transformLocalPoint(TRACK_LAB_CONVEYOR_BOTTOM, piece),
581
+ top: transformLocalPoint(TRACK_LAB_CONVEYOR_TOP, piece),
582
+ direction: rotateVector(TRACK_LAB_CONVEYOR_DIRECTION, piece.transform.yawSteps),
583
+ width: TRACK_LAB_CONVEYOR_WIDTH,
584
+ shelfDepth: TRACK_LAB_CONVEYOR_SHELF_DEPTH,
585
+ shelfCount: TRACK_LAB_CONVEYOR_SHELF_COUNT,
586
+ speed: TRACK_LAB_CONVEYOR_SPEED,
587
+ friction: 1.4,
588
+ shelfTiltRadians: TRACK_LAB_CONVEYOR_SHELF_TILT_RADIANS,
589
+ releaseHeight: transformLocalPoint(TRACK_LAB_CONVEYOR_RELEASE, piece)[1],
590
+ phaseIndex: 2,
591
+ },
592
+ ]
593
+ }
594
+
595
+ function createRuntimeVortices(piece: CompiledTrackPiece, compiled: CompiledTrack): VortexPieceDefinition[] {
596
+ if (piece.runtimeKind !== 'vortex') return []
597
+ const input = piece.connectors.find(connector => connector.kind.endsWith('input'))
598
+ const waterFlowInputConnected = input
599
+ ? compiled.connections.some(connection => {
600
+ const otherEndpoint =
601
+ connection.to.pieceId === piece.id && connection.to.connectorId === input.id
602
+ ? connection.from
603
+ : connection.from.pieceId === piece.id && connection.from.connectorId === input.id
604
+ ? connection.to
605
+ : undefined
606
+ if (!otherEndpoint) return false
607
+ const otherPiece = compiled.pieces.find(candidate => candidate.id === otherEndpoint.pieceId)
608
+ return (
609
+ fluidSurfaceStyleFromId(otherPiece?.appearance?.waterOverlayId) !== undefined &&
610
+ fluidSurfaceStyleFromId(otherPiece?.appearance?.waterOverlayId) ===
611
+ fluidSurfaceStyleFromId(piece.appearance?.vortexWaterId)
612
+ )
613
+ })
614
+ : false
615
+ return [
616
+ {
617
+ ...createAtlasVortexDefinition(
618
+ piece.id,
619
+ transformLocalPoint(ATLAS_VORTEX_CENTER, piece),
620
+ transformLocalPoint(ATLAS_VORTEX_ENTRY, piece),
621
+ transformLocalPoint(ATLAS_VORTEX_DRAIN_CHANNEL, piece),
622
+ rotateVector(
623
+ [
624
+ ATLAS_VORTEX_OUTPUT[0] - ATLAS_VORTEX_DRAIN_CHANNEL[0],
625
+ ATLAS_VORTEX_OUTPUT[1] - ATLAS_VORTEX_DRAIN_CHANNEL[1],
626
+ ATLAS_VORTEX_OUTPUT[2] - ATLAS_VORTEX_DRAIN_CHANNEL[2],
627
+ ],
628
+ piece.transform.yawSteps,
629
+ ),
630
+ ),
631
+ ...(piece.appearance ? { appearance: piece.appearance } : {}),
632
+ waterFlowInputConnected,
633
+ },
634
+ ]
635
+ }
636
+
637
+ function createRuntimeSplitters(piece: CompiledTrackPiece): SplitterPieceDefinition[] {
638
+ if (piece.runtimeKind !== 'splitter') return []
639
+ const drop = laneGradeDrop({ ...piece.parameters, grade: piece.parameters.grade ?? 'slope', lengthUnits: 1 })
640
+ return [
641
+ {
642
+ id: piece.id,
643
+ label: piece.templateId,
644
+ type: 'splitter',
645
+ ...(piece.appearance ? { appearance: piece.appearance } : {}),
646
+ center: transformLocalPoint(splitterPivotLocalPosition(drop), piece),
647
+ capture: transformLocalPoint([0, 0.1 - drop * 0.32, 0.9], piece),
648
+ direction: rotateVector([0, -drop, -5], piece.transform.yawSteps),
649
+ surfaceAligned: true,
650
+ captureRadius: 3.15,
651
+ barLength: TRACK_LAB_SPLITTER_ARM_LENGTH,
652
+ barHeight: TRACK_LAB_SPLITTER_ARM_HEIGHT,
653
+ barDepth: TRACK_LAB_SPLITTER_ARM_DEPTH,
654
+ swingRadians: TRACK_LAB_SPLITTER_SWING_RADIANS,
655
+ cycleSeconds: TRACK_LAB_SPLITTER_CYCLE_SECONDS,
656
+ phaseIndex: 0,
657
+ },
658
+ ]
659
+ }
660
+
661
+ function createRuntimeRebounds(piece: CompiledTrackPiece): ReboundPieceDefinition[] {
662
+ if (piece.runtimeKind !== 'rebounds') return []
663
+ const layout = createReboundLayout(lengthUnitsFromPiece(piece), piece.parameters, reboundPatternFromPiece(piece))
664
+ return [
665
+ {
666
+ id: `${piece.id}-rebounds`,
667
+ label: piece.templateId,
668
+ type: 'rebounds',
669
+ direction: rotateVector(layout.direction, piece.transform.yawSteps),
670
+ mounts: layout.mounts.map(mount => ({
671
+ anchor: transformLocalPoint(mount.anchor, piece),
672
+ angle: mount.angle,
673
+ side: mount.side,
674
+ })),
675
+ reboundLength: REBOUND_LENGTH,
676
+ reboundWidth: REBOUND_WIDTH,
677
+ reboundHeight: REBOUND_HEIGHT,
678
+ friction: 0.01,
679
+ restitution: REBOUND_RESTITUTION,
680
+ strikeDistance: REBOUND_STRIKE_DISTANCE,
681
+ strikeSeconds: REBOUND_STRIKE_SECONDS,
682
+ holdSeconds: REBOUND_HOLD_SECONDS,
683
+ returnSeconds: REBOUND_RETURN_SECONDS,
684
+ cooldownSeconds: REBOUND_COOLDOWN_SECONDS,
685
+ ...(piece.appearance ? { appearance: piece.appearance } : {}),
686
+ },
687
+ ]
688
+ }
689
+
690
+ function createRuntimeRisingWallForests(piece: CompiledTrackPiece): RisingWallForestPieceDefinition[] {
691
+ if (piece.runtimeKind !== 'rising-wall-forest') return []
692
+ const layout = createRisingWallLayout(lengthUnitsFromPiece(piece), piece.parameters)
693
+ return [
694
+ {
695
+ id: piece.id,
696
+ label: piece.templateId,
697
+ type: 'rising-wall-forest',
698
+ direction: rotateVector(layout.direction, piece.transform.yawSteps),
699
+ anchors: layout.anchors.map(anchor => ({
700
+ ...anchor,
701
+ position: transformLocalPoint(anchor.position, piece),
702
+ })),
703
+ rows: layout.rows,
704
+ columns: layout.columns,
705
+ density: risingWallDensityFromPiece(piece),
706
+ wallWidth: RISING_WALL_WIDTH,
707
+ wallHeight: RISING_WALL_HEIGHT,
708
+ wallDepth: RISING_WALL_DEPTH,
709
+ cycleSeconds: RISING_WALL_CYCLE_SECONDS,
710
+ },
711
+ ]
712
+ }
713
+
714
+ function createRuntimeFlipperBanks(piece: CompiledTrackPiece): FlipperBankPieceDefinition[] {
715
+ if (piece.runtimeKind !== 'flipper-bank') return []
716
+ const layout = createFlipperBankLayout(lengthUnitsFromPiece(piece), gradeFromPiece(piece))
717
+ const direction = rotateVector(layout.direction, piece.transform.yawSteps)
718
+ return [
719
+ {
720
+ id: piece.id,
721
+ label: piece.templateId,
722
+ type: 'flipper-bank',
723
+ ...(piece.appearance ? { appearance: piece.appearance } : {}),
724
+ direction,
725
+ mounts: layout.mounts.map(mount => ({
726
+ anchor: transformLocalPoint(mount.anchor, piece),
727
+ direction,
728
+ side: mount.side,
729
+ })),
730
+ paddleLength: CANONICAL_FLIPPER_DIMENSIONS.length,
731
+ paddleWidth: CANONICAL_FLIPPER_DIMENSIONS.width,
732
+ paddleHeight: CANONICAL_FLIPPER_DIMENSIONS.height,
733
+ // Grade changes the deck plane, never the mechanism's forward direction
734
+ // or strike envelope. A former flat-only 1 radian sweep could launch
735
+ // marbles over the wall while the same bank was safe when sloped.
736
+ swingRadians: CANONICAL_FLIPPER_SWEEP_RADIANS,
737
+ // Stay within the canonical passive tip-speed bound without leaving a
738
+ // multi-marble bank dormant for several seconds between strokes.
739
+ strikeSeconds: FLIPPER_MINIMUM_ONE_WAY_SWEEP_SECONDS,
740
+ holdSeconds: 0.05,
741
+ returnSeconds: 0.7,
742
+ minimumIntervalSeconds: 0.35,
743
+ maximumIntervalSeconds: 0.85,
744
+ },
745
+ ]
746
+ }
747
+
748
+ function createRuntimePegFields(piece: CompiledTrackPiece): PegFieldPieceDefinition[] {
749
+ if (piece.runtimeKind !== 'peg-field') return []
750
+ const layout = createConeFieldLayout(lengthUnitsFromPiece(piece), conePatternFromPiece(piece), gradeFromPiece(piece))
751
+ return [
752
+ {
753
+ id: `${piece.id}-pegs`,
754
+ label: piece.templateId,
755
+ type: 'peg-field',
756
+ direction: rotateVector(layout.direction, piece.transform.yawSteps),
757
+ pegs: layout.pegBases.map(base =>
758
+ addVector(piece.transform.position, rotateVector(base, piece.transform.yawSteps)),
759
+ ),
760
+ pegRadius: CONE_FIELD_PEG_RADIUS,
761
+ pegHeight: CONE_FIELD_PEG_HEIGHT,
762
+ colliderShape: 'cone',
763
+ friction: 0.02,
764
+ restitution: 0.08,
765
+ ...(piece.appearance ? { appearance: piece.appearance } : {}),
766
+ },
767
+ ]
768
+ }
769
+
770
+ function createRuntimeRails(
771
+ piece: CompiledTrackPiece,
772
+ connectedConnectorKeys: ReadonlySet<string>,
773
+ connectorFrames: ReadonlyMap<string, RailConnectorFrame> = new Map(),
774
+ ): RailPieceDefinition[] {
775
+ const connectedConnectorIds = new Set(
776
+ piece.connectors
777
+ .filter(connector => connectedConnectorKeys.has(`${piece.id}:${connector.id}`))
778
+ .map(connector => connector.id),
779
+ )
780
+ const worldConnectors = piece.connectors.map(connector => ({
781
+ ...connector,
782
+ localPosition: connector.worldPosition,
783
+ localDirection: connector.worldDirection,
784
+ }))
785
+ return createUnframedRuntimeRails(piece).map(rail => {
786
+ const framed = applyRailConnectorFrames(rail, worldConnectors, connectedConnectorIds, connectorFrames)
787
+ const junctionBranches = framed.junctionBranches?.map(branch => {
788
+ const connectorFrame = connectorFrames.get(branch.connectorId)
789
+ return {
790
+ ...branch,
791
+ points: connectorFrame?.position ? [connectorFrame.position, ...branch.points.slice(1)] : branch.points,
792
+ startTangent: connectorFrame?.tangent ?? branch.startTangent,
793
+ }
794
+ })
795
+ return {
796
+ ...framed,
797
+ materialConnectors: piece.connectors.map(connector => ({
798
+ id: connector.id,
799
+ position: connector.worldPosition,
800
+ })),
801
+ ...(junctionBranches ? { junctionBranches } : {}),
802
+ ...(piece.appearance || framed.appearance ? { appearance: { ...piece.appearance, ...framed.appearance } } : {}),
803
+ }
804
+ })
805
+ }
806
+
807
+ function createUnframedRuntimeRails(piece: CompiledTrackPiece): RailPieceDefinition[] {
808
+ if (piece.runtimeKind === 'finish') return []
809
+ const input = piece.connectors.find(connector => connector.kind.endsWith('input'))
810
+ const output = piece.connectors.find(connector => connector.kind.endsWith('output'))
811
+ if (piece.runtimeKind === 'single-file-squeeze') {
812
+ return [transformLocalRail(createSingleFileSqueezeRail(piece.id, piece.templateId, piece.parameters), piece)]
813
+ }
814
+ if (piece.runtimeKind === 'rebounds') {
815
+ return [
816
+ transformLocalRail(
817
+ createReboundDeck(piece.id, piece.templateId, lengthUnitsFromPiece(piece), piece.parameters),
818
+ piece,
819
+ ),
820
+ ]
821
+ }
822
+ if (piece.runtimeKind === 'rising-wall-forest') {
823
+ const layout = createRisingWallLayout(lengthUnitsFromPiece(piece), piece.parameters)
824
+ return [transformLocalRail(createRisingWallDeck(`${piece.id}-deck`, piece.templateId, layout), piece)]
825
+ }
826
+ if (piece.runtimeKind === 'portal-entry-preview' && input) {
827
+ return [
828
+ {
829
+ id: `${piece.id}-deck`,
830
+ label: 'Portal entry channel',
831
+ type: 'rail',
832
+ points: [input.worldPosition, transformLocalPoint([0, 0, 0], piece)],
833
+ width: 4,
834
+ troughDepth: defaultTrackTroughDepth(4),
835
+ troughProfile: 'full',
836
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
837
+ floorFriction: 0.04,
838
+ },
839
+ ]
840
+ }
841
+ if (piece.runtimeKind === 'portal-exit-preview' && output) {
842
+ return [
843
+ {
844
+ id: `${piece.id}-deck`,
845
+ label: 'Portal exit channel',
846
+ type: 'rail',
847
+ points: [transformLocalPoint([0, 0, 0], piece), output.worldPosition],
848
+ width: 4,
849
+ troughDepth: defaultTrackTroughDepth(4),
850
+ troughProfile: 'full',
851
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
852
+ floorFriction: 0.04,
853
+ },
854
+ ]
855
+ }
856
+ if (piece.runtimeKind === 'conveyor' && input && output) {
857
+ return [
858
+ {
859
+ id: `${piece.id}-approach`,
860
+ label: 'Conveyor approach channel',
861
+ type: 'rail',
862
+ points: [input.worldPosition, transformLocalPoint(TRACK_LAB_CONVEYOR_LOADING, piece)],
863
+ startWidth: 4,
864
+ width: TRACK_LAB_CONVEYOR_WIDTH,
865
+ startTroughDepth: defaultTrackTroughDepth(4),
866
+ troughDepth: defaultTrackTroughDepth(TRACK_LAB_CONVEYOR_WIDTH),
867
+ troughProfile: 'full',
868
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
869
+ continuousFloorMaterialOnly: true,
870
+ floorFriction: 0.08,
871
+ },
872
+ {
873
+ id: `${piece.id}-release`,
874
+ label: 'Conveyor release channel',
875
+ type: 'rail',
876
+ points: [transformLocalPoint(TRACK_LAB_CONVEYOR_RELEASE, piece), output.worldPosition],
877
+ startWidth: TRACK_LAB_CONVEYOR_WIDTH,
878
+ width: 4,
879
+ startTroughDepth: defaultTrackTroughDepth(TRACK_LAB_CONVEYOR_WIDTH),
880
+ troughDepth: defaultTrackTroughDepth(4),
881
+ troughProfile: 'full',
882
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
883
+ floorFriction: 0.08,
884
+ },
885
+ ]
886
+ }
887
+ if (piece.runtimeKind === 'vortex' && input && output) {
888
+ return [
889
+ {
890
+ id: `${piece.id}-entry`,
891
+ label: 'Atlas vortex tangential entry',
892
+ type: 'rail',
893
+ points: [input.worldPosition, ...ATLAS_VORTEX_ENTRY_APPROACH.map(point => transformLocalPoint(point, piece))],
894
+ width: 4,
895
+ troughDepth: defaultTrackTroughDepth(4),
896
+ troughProfile: 'full',
897
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
898
+ floorFriction: 0.08,
899
+ supportsAppearanceCanopy: false,
900
+ },
901
+ {
902
+ id: `${piece.id}-drain`,
903
+ label: 'Continuous Atlas vortex catch and single-file drain',
904
+ type: 'rail',
905
+ points: ATLAS_VORTEX_DRAIN_PATH.map((point, index, points) =>
906
+ index === points.length - 1 ? output.worldPosition : transformLocalPoint(point, piece),
907
+ ),
908
+ width: ATLAS_VORTEX_DRAIN_OUTPUT_WIDTH,
909
+ pointWidths: atlasVortexDrainProfile(),
910
+ widthProfile: 'smoothstep',
911
+ troughDepth: defaultTrackTroughDepth(ATLAS_VORTEX_DRAIN_OUTPUT_WIDTH),
912
+ pointTroughDepths: atlasVortexDrainProfile().map(defaultTrackTroughDepth),
913
+ troughProfile: 'full',
914
+ troughFlatFraction: 0,
915
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
916
+ continuousFloorMaterialOnly: true,
917
+ supportsAppearanceCanopy: false,
918
+ // This is an exposed Vortex-owned catch terminal, not a rail seam.
919
+ // Cap the floor and side-wall volumes without adding a cross-wall
920
+ // behind the funnel bore.
921
+ capStart: true,
922
+ floorFriction: 0.02,
923
+ wallFriction: 0,
924
+ waterFlowStartConnected: Boolean(piece.appearance?.vortexWaterId),
925
+ ...(piece.appearance?.vortexWaterId
926
+ ? { appearance: { ...piece.appearance, waterOverlayId: piece.appearance.vortexWaterId } }
927
+ : {}),
928
+ },
929
+ ]
930
+ }
931
+ if (piece.runtimeKind === 'splitter' && input) {
932
+ const outputs = piece.connectors.filter(connector => connector.kind.endsWith('output'))
933
+ if (outputs.length === 0) return []
934
+ return [
935
+ transformLocalRail(
936
+ createRoutingSplitterDeck(`${piece.id}-deck`, 'Splitter routing deck', -outputs[0]!.localPosition[1]),
937
+ piece,
938
+ ),
939
+ ]
940
+ }
941
+ if (piece.runtimeKind === 'merge' && output) {
942
+ return [
943
+ transformLocalRail(createRoutingMergeDeck(piece.id, 'Route merge catch deck', -output.localPosition[1]), piece),
944
+ ]
945
+ }
946
+ if (piece.runtimeKind === 'intersection' && output) {
947
+ // Intersection topology belongs to the template, not to transient
948
+ // connection state. Every authored input must remain present so attaching
949
+ // or detaching a neighbour cannot regenerate the piece's floor and walls.
950
+ const inputs = piece.connectors.filter(connector => connector.kind.endsWith('input'))
951
+ if (inputs.length === 0) return []
952
+ return createIntersectionRailDefinitions(
953
+ piece.id,
954
+ piece.templateId,
955
+ inputs.map(connector => ({
956
+ id: connector.id,
957
+ localPosition: connector.worldPosition,
958
+ localDirection: connector.worldDirection,
959
+ })),
960
+ {
961
+ id: output.id,
962
+ localPosition: output.worldPosition,
963
+ localDirection: output.worldDirection,
964
+ },
965
+ )
966
+ }
967
+ if (piece.runtimeKind === 'flipper-bank' && input && output) {
968
+ return [
969
+ transformLocalRail(
970
+ createFlipperBankDeck(
971
+ `${piece.id}-deck`,
972
+ 'Continuous enclosed flipper deck',
973
+ lengthUnitsFromPiece(piece),
974
+ gradeFromPiece(piece),
975
+ ),
976
+ piece,
977
+ ),
978
+ ]
979
+ }
980
+ if (piece.runtimeKind === 'peg-field' && input && output) {
981
+ const layout = createConeFieldLayout(
982
+ lengthUnitsFromPiece(piece),
983
+ conePatternFromPiece(piece),
984
+ gradeFromPiece(piece),
985
+ )
986
+ const deckProfile = createConeFieldDeckProfile(layout, defaultTrackTroughDepth(4))
987
+ return [
988
+ {
989
+ id: `${piece.id}-deck`,
990
+ label: 'Continuous enclosed cone forest deck',
991
+ type: 'rail',
992
+ points: deckProfile.points.map(point => transformLocalPoint(point, piece)),
993
+ width: 4,
994
+ pointWidths: [...deckProfile.pointWidths],
995
+ troughDepth: defaultTrackTroughDepth(4),
996
+ pointTroughDepths: [...deckProfile.pointTroughDepths],
997
+ troughProfile: 'full',
998
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
999
+ // The passive cone field depends on the obstacles for deflection; the
1000
+ // deck and widening shoulders must not arrest a low-energy handoff.
1001
+ // These physical values are piece semantics and never vary by theme or
1002
+ // appearance material.
1003
+ floorFriction: 0.006,
1004
+ wallFriction: 0,
1005
+ },
1006
+ ]
1007
+ }
1008
+ const publicLane = createPublicLaneRailDefinition({
1009
+ id: piece.id,
1010
+ templateId: piece.templateId,
1011
+ runtimeKind: piece.runtimeKind,
1012
+ parameters: piece.parameters,
1013
+ })
1014
+ if (publicLane) {
1015
+ return [
1016
+ {
1017
+ ...publicLane,
1018
+ points: publicLane.points.map(point => transformLocalPoint(point, piece)),
1019
+ },
1020
+ ]
1021
+ }
1022
+ const points: TrackVector[] = []
1023
+
1024
+ if (piece.runtimeKind === 'start') {
1025
+ const direction = rotateVector([0, 0, 1], piece.transform.yawSteps)
1026
+ const entry = addVector(piece.transform.position, scaleVector(direction, 4))
1027
+ entry[1] += 1.2
1028
+ points.push(entry)
1029
+ if (output) points.push([...output.worldPosition])
1030
+ } else if (input && output) {
1031
+ points.push([...input.worldPosition])
1032
+ points.push([...output.worldPosition])
1033
+ }
1034
+
1035
+ if (points.length < 2) return []
1036
+ const width = piece.runtimeKind === 'start' ? 4 : widthFromPiece(piece)
1037
+ return [
1038
+ {
1039
+ id: piece.id,
1040
+ label: piece.templateId,
1041
+ type: 'rail',
1042
+ ...(piece.runtimeKind === 'start' ? { raceRole: 'start' as const } : {}),
1043
+ points,
1044
+ width,
1045
+ ...(piece.runtimeKind === 'start' ? { startWidth: 6 } : {}),
1046
+ ...(piece.runtimeKind === 'start' ? { startTroughDepth: defaultTrackTroughDepth(6) } : {}),
1047
+ troughDepth: defaultTrackTroughDepth(width),
1048
+ wallHeight: TRACK_LAB_WALL_HEIGHT,
1049
+ floorFriction: 0.08,
1050
+ },
1051
+ ]
1052
+ }
1053
+
1054
+ function transformLocalPoint(point: TrackVector, piece: CompiledTrackPiece): TrackVector {
1055
+ return addVector(piece.transform.position, rotateVector(point, piece.transform.yawSteps))
1056
+ }
1057
+
1058
+ function transformLocalRail(rail: RailPieceDefinition, piece: CompiledTrackPiece): RailPieceDefinition {
1059
+ return {
1060
+ ...rail,
1061
+ points: rail.points.map(point => transformLocalPoint(point, piece)),
1062
+ startTangent: rail.startTangent ? rotateVector(rail.startTangent, piece.transform.yawSteps) : undefined,
1063
+ endTangent: rail.endTangent ? rotateVector(rail.endTangent, piece.transform.yawSteps) : undefined,
1064
+ }
1065
+ }
1066
+
1067
+ function createRuntimeGuide(
1068
+ compiled: CompiledTrack,
1069
+ startEntry: TrackVector,
1070
+ finishCenter: TrackVector,
1071
+ channelEnd: TrackVector,
1072
+ ): TrackGuidePoint[] {
1073
+ const source = [
1074
+ { pieceId: compiled.route.startPieceId, position: [...startEntry] as TrackVector },
1075
+ ...compiled.projectionGuide.map(point => ({
1076
+ pieceId: point.pieceId,
1077
+ position: [...point.position] as TrackVector,
1078
+ })),
1079
+ { pieceId: compiled.route.finishPieceId, position: [...finishCenter] as TrackVector },
1080
+ { pieceId: compiled.route.finishPieceId, position: [...channelEnd] as TrackVector },
1081
+ ]
1082
+ const guide: TrackGuidePoint[] = []
1083
+ let progress = 0
1084
+ for (const point of source) {
1085
+ const previous = guide.at(-1)
1086
+ if (previous) {
1087
+ const distance = distanceBetween(previous.position, point.position)
1088
+ if (distance < 0.001) continue
1089
+ progress += distance
1090
+ }
1091
+ guide.push({ ...point, progress: Number(progress.toFixed(6)) })
1092
+ }
1093
+ return guide
1094
+ }
1095
+
1096
+ function widthFromPiece(piece: CompiledTrackPiece): number {
1097
+ const width = piece.parameters.width
1098
+ if (typeof width === 'string') return trackWidthParameterUnits(width)
1099
+ if (piece.runtimeKind === 'start') return 6
1100
+ if (piece.runtimeKind === 'peg-field') return CONE_FIELD_WIDTH
1101
+ return 4
1102
+ }
1103
+
1104
+ function lengthUnitsFromPiece(piece: CompiledTrackPiece): number {
1105
+ const value = piece.parameters.lengthUnits
1106
+ return typeof value === 'number' && Number.isFinite(value) ? value : 1
1107
+ }
1108
+
1109
+ function gradeFromPiece(piece: CompiledTrackPiece): 'flat' | 'slope' | 'steep' {
1110
+ return piece.parameters.grade === 'flat' || piece.parameters.grade === 'steep' ? piece.parameters.grade : 'slope'
1111
+ }
1112
+
1113
+ function conePatternFromPiece(piece: CompiledTrackPiece): 'classic' | 'dense' {
1114
+ return piece.parameters.pattern === 'classic' ? 'classic' : 'dense'
1115
+ }
1116
+
1117
+ function reboundPatternFromPiece(piece: CompiledTrackPiece): ReboundPattern {
1118
+ return piece.parameters.pattern === 'zigzag' ? 'zigzag' : 'alternating'
1119
+ }
1120
+
1121
+ function risingWallDensityFromPiece(piece: CompiledTrackPiece): RisingWallDensity {
1122
+ return piece.parameters.density === 'sparse' ? 'sparse' : 'dense'
1123
+ }
1124
+
1125
+ export function supportsCanonicalRailPorts(piece: Pick<CompiledTrackPiece, 'materialSlots'>): boolean {
1126
+ // A surfaced catalog piece owns a traversable rail shell at every ordinary
1127
+ // connector. Derive participation from that catalog contract instead of a
1128
+ // runtime-kind allowlist so starts, finishes, obstacles, portals, elevators,
1129
+ // vortices, and future surfaced pieces all negotiate the same terminal
1130
+ // cross-section as their neighbour.
1131
+ return piece.materialSlots.includes('lane.surface')
1132
+ }
1133
+
1134
+ function rotateVector(vector: TrackVector, yawSteps: 0 | 1 | 2 | 3): TrackVector {
1135
+ const [x, y, z] = vector
1136
+ if (yawSteps === 1) return [-z, y, x]
1137
+ if (yawSteps === 2) return [-x, y, -z]
1138
+ if (yawSteps === 3) return [z, y, -x]
1139
+ return [x, y, z]
1140
+ }
1141
+
1142
+ function addVector(left: TrackVector, right: TrackVector): [number, number, number] {
1143
+ return [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
1144
+ }
1145
+
1146
+ function scaleVector(vector: TrackVector, scale: number): [number, number, number] {
1147
+ return [vector[0] * scale, vector[1] * scale, vector[2] * scale]
1148
+ }
1149
+
1150
+ function distanceBetween(left: TrackVector, right: TrackVector): number {
1151
+ return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2])
1152
+ }