@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.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- 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
|
+
}
|