@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,572 @@
1
+ import * as THREE from 'three'
2
+
3
+ export interface WildWestVortexHousingOptions {
4
+ pieceId: string
5
+ center: THREE.Vector3
6
+ outerRadius: number
7
+ floorOuterRadius?: number
8
+ innerRadius: number
9
+ segmentCount: number
10
+ surfaceHeightAtRadius(radius: number): number
11
+ drainTopY: number
12
+ shellMaterial: THREE.Material
13
+ floorPerimeterTreatments?: readonly {
14
+ material: THREE.Material
15
+ materialId: string
16
+ themeId: string | readonly string[]
17
+ }[]
18
+ shellDrop?: number
19
+ inletShell?: {
20
+ vertices: readonly number[]
21
+ indices: readonly number[]
22
+ fasciaIndices?: readonly number[]
23
+ seam: readonly {
24
+ vertexIndex: number
25
+ angle: number
26
+ }[]
27
+ }
28
+ rimOpening?: {
29
+ entryAngle: number
30
+ orbitDirection: -1 | 1
31
+ sweep: number
32
+ }
33
+ outletTunnel?: {
34
+ curve: THREE.Curve<THREE.Vector3>
35
+ radius: number
36
+ innerRadius: number
37
+ }
38
+ innerFunnel?: {
39
+ rings: readonly {
40
+ radius: number
41
+ height: number
42
+ centerX: number
43
+ centerZ: number
44
+ }[]
45
+ wallThickness: number
46
+ }
47
+ }
48
+
49
+ function markDecorative(object: THREE.Object3D): void {
50
+ object.userData.decorative = true
51
+ object.userData.visualOnly = true
52
+ object.castShadow = true
53
+ object.receiveShadow = true
54
+ }
55
+
56
+ /**
57
+ * Adds the removable Wild West skin around the existing vortex. The housing
58
+ * deliberately accepts no Rapier world or collider bookkeeping: it dresses
59
+ * the bowl without becoming another physical surface.
60
+ */
61
+ export function addWildWestVortexHousing(root: THREE.Object3D, options: WildWestVortexHousingOptions): THREE.Group {
62
+ const group = new THREE.Group()
63
+ group.name = `${options.pieceId}-wild-west-vortex-housing`
64
+ group.userData.decorative = true
65
+ group.userData.visualOnly = true
66
+
67
+ const segments = Math.max(24, Math.round(options.segmentCount))
68
+ const radialBands = 4
69
+ const outerRadius = Math.max(options.innerRadius + 0.8, options.outerRadius)
70
+ const floorOuterRadius = THREE.MathUtils.clamp(options.floorOuterRadius ?? outerRadius, options.innerRadius, outerRadius)
71
+ const outlet = options.outletTunnel
72
+ const outletRadius = Math.max(0.18, outlet?.radius ?? options.innerRadius)
73
+ const funnelRings = options.innerFunnel?.rings
74
+ const funnelMouth = funnelRings?.at(-1)
75
+ const funnelWallThickness = Math.max(0.02, options.innerFunnel?.wallThickness ?? 0.2)
76
+ const collarRadius = funnelMouth
77
+ ? funnelMouth.radius + funnelWallThickness
78
+ : outlet
79
+ ? outletRadius
80
+ : Math.min(outerRadius - 0.55, options.innerRadius + 0.34)
81
+ const shellDrop = options.shellDrop ?? 0.3
82
+ const shellHeightAtRadius = (radius: number): number => options.surfaceHeightAtRadius(radius) - shellDrop
83
+ const outerHeight = options.surfaceHeightAtRadius(outerRadius)
84
+ const outerShellHeight = shellHeightAtRadius(outerRadius)
85
+ const shellInnerY = shellHeightAtRadius(collarRadius)
86
+ const outletInnerRadius = Math.min(
87
+ outletRadius,
88
+ Math.max(0.12, outlet?.innerRadius ?? outletRadius - 0.2),
89
+ )
90
+ const outletStart = outlet?.curve.getPointAt(0) ?? options.center.clone().setY(options.drainTopY)
91
+ const outletIsLinear = outlet
92
+ ? [0.25, 0.5, 0.75].every(progress =>
93
+ outlet.curve
94
+ .getPointAt(progress)
95
+ .distanceToSquared(outlet.curve.getPointAt(0).lerp(outlet.curve.getPointAt(1), progress)) <= 0.000_000_01,
96
+ )
97
+ : false
98
+ // Atlas outlets are straight vertical tubes. Subdividing the same line into
99
+ // 32 exterior bands and another 32 bore bands creates thousands of
100
+ // collinear triangles without changing silhouette, normals, or linearly
101
+ // interpolated UVs. Preserve dense sampling automatically for any future
102
+ // genuinely curved outlet.
103
+ const tubeBands = outletIsLinear ? 1 : 32
104
+ const neckBands = 3
105
+ const neckControlY = THREE.MathUtils.lerp(shellInnerY, outletStart.y, 0.68)
106
+ const profilePoint = (progress: number): { radius: number; height: number } => {
107
+ const inverse = 1 - progress
108
+ return {
109
+ radius:
110
+ inverse ** 3 * collarRadius +
111
+ 3 * inverse * inverse * progress * (collarRadius - (collarRadius - outletRadius) * 0.52) +
112
+ 3 * inverse * progress * progress * outletRadius +
113
+ progress ** 3 * outletRadius,
114
+ height:
115
+ inverse ** 3 * shellInnerY +
116
+ 3 * inverse * inverse * progress * (shellInnerY - 0.16) +
117
+ 3 * inverse * progress * progress * neckControlY +
118
+ progress ** 3 * outletStart.y,
119
+ }
120
+ }
121
+
122
+ interface HousingRing {
123
+ center: THREE.Vector3
124
+ radius: number
125
+ normal: THREE.Vector3
126
+ binormal: THREE.Vector3
127
+ }
128
+ const rings: HousingRing[] = []
129
+ const appendHorizontalRing = (radius: number, height: number): number => {
130
+ const ringIndex = rings.length
131
+ rings.push({
132
+ center: new THREE.Vector3(options.center.x, height, options.center.z),
133
+ radius,
134
+ normal: new THREE.Vector3(1, 0, 0),
135
+ binormal: new THREE.Vector3(0, 0, 1),
136
+ })
137
+ return ringIndex
138
+ }
139
+ const appendProfileRing = (
140
+ profile: { radius: number; height: number; centerX: number; centerZ: number },
141
+ radiusOffset = 0,
142
+ heightOffset = 0,
143
+ ): void => {
144
+ rings.push({
145
+ center: new THREE.Vector3(profile.centerX, profile.height + heightOffset, profile.centerZ),
146
+ radius: profile.radius + radiusOffset,
147
+ normal: new THREE.Vector3(1, 0, 0),
148
+ binormal: new THREE.Vector3(0, 0, 1),
149
+ })
150
+ }
151
+
152
+ // Trace the complete exposed cross-section as one surface of revolution:
153
+ // bowl underside -> funnel exterior -> pipe exterior -> open terminal lip
154
+ // -> pipe interior -> funnel interior. There is no second render mesh and
155
+ // no coincident surface for the depth buffer to reveal.
156
+ // Meet the guard at its actual base plane. Extending the fascia upward
157
+ // behind block-built wall styles exposes a purple toothed band through the
158
+ // course gaps in Material Slot Debug and at grazing angles.
159
+ appendHorizontalRing(outerRadius, outerHeight - 0.03)
160
+ const outerShellRingIndex = appendHorizontalRing(outerRadius, outerShellHeight)
161
+ const floorOuterRingIndex =
162
+ floorOuterRadius < outerRadius - 0.000_001
163
+ ? appendHorizontalRing(floorOuterRadius, shellHeightAtRadius(floorOuterRadius))
164
+ : outerShellRingIndex
165
+ for (let band = 1; band <= radialBands; band += 1) {
166
+ const progress = band / radialBands
167
+ const radius = THREE.MathUtils.lerp(
168
+ floorOuterRadius,
169
+ collarRadius,
170
+ THREE.MathUtils.smoothstep(progress, 0, 1),
171
+ )
172
+ appendHorizontalRing(radius, shellHeightAtRadius(radius))
173
+ }
174
+ if (outlet && funnelRings && funnelRings.length >= 2) {
175
+ const mouth = funnelRings.at(-1)!
176
+ appendProfileRing(mouth, funnelWallThickness, -funnelWallThickness)
177
+ for (let index = funnelRings.length - 2; index >= 0; index -= 1) {
178
+ const profile = funnelRings[index]!
179
+ const progress = index / (funnelRings.length - 1)
180
+ appendProfileRing(profile, funnelWallThickness, -funnelWallThickness * progress)
181
+ }
182
+ // The outer throat and first pipe ring are identical. Start at band one
183
+ // so the entire pipe exterior has exactly one topological owner.
184
+ for (let band = 1; band <= tubeBands; band += 1) {
185
+ const progress = band / tubeBands
186
+ rings.push({
187
+ center: outlet.curve.getPointAt(progress),
188
+ radius: outletRadius,
189
+ normal: new THREE.Vector3(1, 0, 0),
190
+ binormal: new THREE.Vector3(0, 0, 1),
191
+ })
192
+ }
193
+ // One annular terminal face closes the wall thickness without capping the
194
+ // bore. Then walk the same axis back upward at the inner radius.
195
+ rings.push({
196
+ center: outlet.curve.getPointAt(1),
197
+ radius: outletInnerRadius,
198
+ normal: new THREE.Vector3(1, 0, 0),
199
+ binormal: new THREE.Vector3(0, 0, 1),
200
+ })
201
+ for (let band = tubeBands - 1; band >= 0; band -= 1) {
202
+ const progress = band / tubeBands
203
+ rings.push({
204
+ center: outlet.curve.getPointAt(progress),
205
+ radius: outletInnerRadius,
206
+ normal: new THREE.Vector3(1, 0, 0),
207
+ binormal: new THREE.Vector3(0, 0, 1),
208
+ })
209
+ }
210
+ for (let index = 1; index < funnelRings.length; index += 1) {
211
+ const profile = funnelRings[index]!
212
+ appendProfileRing(profile)
213
+ }
214
+ } else if (outlet) {
215
+ const neckStart = rings.at(-1)!
216
+ for (let band = 1; band <= neckBands; band += 1) {
217
+ const progress = band / neckBands
218
+ rings.push({
219
+ center: neckStart.center.clone().lerp(outletStart, progress),
220
+ radius: THREE.MathUtils.lerp(neckStart.radius, outletRadius, progress),
221
+ normal: new THREE.Vector3(1, 0, 0),
222
+ binormal: new THREE.Vector3(0, 0, 1),
223
+ })
224
+ }
225
+ } else {
226
+ const profile = profilePoint(1)
227
+ appendHorizontalRing(profile.radius, profile.height)
228
+ }
229
+
230
+ const shellVertices: number[] = []
231
+ const shellUvs: number[] = []
232
+ const shellIndices: number[] = []
233
+ const outerFasciaIndices: number[] = []
234
+ const trimmedFasciaPanels = [0, 0]
235
+ const turn = Math.PI * 2
236
+ const normalizeAngle = (angle: number): number => ((angle % turn) + turn) % turn
237
+ const openingBoundaryAngles = options.rimOpening
238
+ ? [
239
+ normalizeAngle(options.rimOpening.entryAngle),
240
+ normalizeAngle(
241
+ options.rimOpening.entryAngle +
242
+ options.rimOpening.orbitDirection * (turn - options.rimOpening.sweep),
243
+ ),
244
+ ]
245
+ : []
246
+ // A skipped regular panel only approximates the inlet cut and can leave up
247
+ // to half a segment of purple housing outside the exact yellow wall plane.
248
+ // Insert both analytic opening boundaries into every ring so the fascia,
249
+ // shoulder, bowl, funnel, and tube retain one compatible indexed topology.
250
+ const angularSamples = [
251
+ ...Array.from({ length: segments }, (_, segment) => (segment / segments) * turn),
252
+ ...openingBoundaryAngles,
253
+ ...(options.inletShell?.seam.map(vertex => normalizeAngle(vertex.angle)) ?? []),
254
+ ]
255
+ .sort((left, right) => left - right)
256
+ .filter((angle, index, angles) => index === 0 || Math.abs(angle - angles[index - 1]!) > 0.000_001)
257
+ const ringAngles = [...angularSamples, angularSamples[0]! + turn]
258
+ const rowLength = ringAngles.length
259
+ const angularPanelCount = rowLength - 1
260
+ let longitudinal = 0
261
+ rings.forEach((ring, ringIndex) => {
262
+ if (ringIndex > 0) {
263
+ const previous = rings[ringIndex - 1]!
264
+ longitudinal += previous.center.distanceTo(ring.center) + Math.abs(previous.radius - ring.radius)
265
+ }
266
+ for (const angle of ringAngles) {
267
+ const angleProgress = angle / turn
268
+ const radial = ring.normal
269
+ .clone()
270
+ .multiplyScalar(Math.cos(angle))
271
+ .addScaledVector(ring.binormal, Math.sin(angle))
272
+ const position = ring.center.clone().addScaledVector(radial, ring.radius)
273
+ shellVertices.push(position.x, position.y, position.z)
274
+ shellUvs.push(angleProgress * Math.PI * 2 * ring.radius, longitudinal)
275
+ }
276
+ })
277
+ for (let ring = 0; ring < rings.length - 1; ring += 1) {
278
+ for (let segment = 0; segment < angularPanelCount; segment += 1) {
279
+ // The integrated inlet owns this part of the outer fascia. Leaving the
280
+ // complete underside ring here exposes it as a dark circular seam below
281
+ // the inlet apron, even though the two top surfaces share vertices.
282
+ // The first two bands form the complete outer fascia: the vertical lip
283
+ // at the guard's outer face and the horizontal shoulder back to the
284
+ // traversable floor radius. Both must stop at the inlet opening. Cutting
285
+ // only the first band leaves the shoulder running through the opening as
286
+ // a long purple strip with a protruding corner beside the straight wall.
287
+ // Trim only the outer fascia bands before the shared floor-radius ring.
288
+ // When the housing is intentionally inset, the outer shell and floor
289
+ // ring are the same ring; trimming the next band would cut the actual
290
+ // bowl underside away from the inlet seam.
291
+ if (ring < floorOuterRingIndex && options.rimOpening) {
292
+ const midpointAngle = (ringAngles[segment]! + ringAngles[segment + 1]!) / 2
293
+ let directed = Math.atan2(
294
+ Math.sin(midpointAngle - options.rimOpening.entryAngle),
295
+ Math.cos(midpointAngle - options.rimOpening.entryAngle),
296
+ )
297
+ if (options.rimOpening.orbitDirection > 0 && directed < 0) directed += Math.PI * 2
298
+ if (options.rimOpening.orbitDirection < 0 && directed > 0) directed -= Math.PI * 2
299
+ const wallArcSweep = Math.PI * 2 - options.rimOpening.sweep
300
+ if (Math.abs(directed) > wallArcSweep) {
301
+ trimmedFasciaPanels[ring]! += 1
302
+ continue
303
+ }
304
+ }
305
+ const current = ring * rowLength + segment
306
+ const next = current + rowLength
307
+ shellIndices.push(current, current + 1, next, current + 1, next + 1, next)
308
+ if (ring === 0) {
309
+ outerFasciaIndices.push(current, current + 1, next, current + 1, next + 1, next)
310
+ }
311
+ }
312
+ }
313
+
314
+ // Cutting the two outer fascia bands creates an exposed triangular
315
+ // cross-section at each side of the inlet. Close those exact analytic cut
316
+ // planes so the visible purple edge remains one thin floor profile beneath
317
+ // the yellow wall. Without these faces the deeper inlet side closure is
318
+ // visible through the open end and reads as a swollen purple wedge.
319
+ let inletFasciaTerminalCount = 0
320
+ if (options.rimOpening && floorOuterRingIndex !== outerShellRingIndex) {
321
+ const openingMidpointAngle = normalizeAngle(
322
+ options.rimOpening.entryAngle - options.rimOpening.orbitDirection * options.rimOpening.sweep / 2,
323
+ )
324
+ for (const boundaryAngle of openingBoundaryAngles) {
325
+ const angleIndex = ringAngles
326
+ .slice(0, angularPanelCount)
327
+ .findIndex(
328
+ angle =>
329
+ Math.abs(Math.atan2(Math.sin(angle - boundaryAngle), Math.cos(angle - boundaryAngle))) <
330
+ 0.000_001,
331
+ )
332
+ if (angleIndex < 0) throw new Error(`Missing Vortex fascia terminal angle ${boundaryAngle}`)
333
+ const outerTop = angleIndex
334
+ const outerBottom = outerShellRingIndex * rowLength + angleIndex
335
+ const floorBottom = floorOuterRingIndex * rowLength + angleIndex
336
+ const towardOpening = Math.sign(
337
+ Math.atan2(
338
+ Math.sin(openingMidpointAngle - boundaryAngle),
339
+ Math.cos(openingMidpointAngle - boundaryAngle),
340
+ ),
341
+ ) || 1
342
+ const expectedNormal = new THREE.Vector3(
343
+ -Math.sin(boundaryAngle) * towardOpening,
344
+ 0,
345
+ Math.cos(boundaryAngle) * towardOpening,
346
+ )
347
+ const pointAt = (index: number): THREE.Vector3 =>
348
+ new THREE.Vector3(
349
+ shellVertices[index * 3]!,
350
+ shellVertices[index * 3 + 1]!,
351
+ shellVertices[index * 3 + 2]!,
352
+ )
353
+ const normal = pointAt(outerBottom)
354
+ .sub(pointAt(outerTop))
355
+ .cross(pointAt(floorBottom).sub(pointAt(outerTop)))
356
+ if (normal.dot(expectedNormal) >= 0) {
357
+ shellIndices.push(outerTop, outerBottom, floorBottom)
358
+ } else {
359
+ shellIndices.push(outerTop, floorBottom, outerBottom)
360
+ }
361
+ inletFasciaTerminalCount += 1
362
+ }
363
+ }
364
+
365
+ const inletSeamVertexIndices: number[] = []
366
+ let inletFasciaOverlayIndexCount = 0
367
+ let inletShellIndexOffset = 0
368
+ // The integrated inlet has the same visual ownership as the bowl
369
+ // underside. Its complete curved boundary must reuse the bowl's indexed
370
+ // apron ring. Merely appending coincident positions creates a disconnected
371
+ // mesh island whose independently-computed normals expose the join.
372
+ if (options.inletShell) {
373
+ inletShellIndexOffset = shellIndices.length
374
+ const remappedVertices = new Map<number, number>()
375
+ for (const seamVertex of options.inletShell.seam) {
376
+ const normalizedSeamAngle = normalizeAngle(seamVertex.angle)
377
+ const angleIndex = ringAngles
378
+ .slice(0, angularPanelCount)
379
+ .findIndex(
380
+ angle =>
381
+ Math.abs(
382
+ Math.atan2(
383
+ Math.sin(angle - normalizedSeamAngle),
384
+ Math.cos(angle - normalizedSeamAngle),
385
+ ),
386
+ ) < 0.000_001,
387
+ )
388
+ if (angleIndex < 0) {
389
+ throw new Error(`Missing Vortex housing seam angle ${seamVertex.angle}`)
390
+ }
391
+ const housingVertexIndex = floorOuterRingIndex * rowLength + angleIndex
392
+ const sourceOffset = seamVertex.vertexIndex * 3
393
+ const housingOffset = housingVertexIndex * 3
394
+ const seamDistance = Math.hypot(
395
+ options.inletShell.vertices[sourceOffset]! - shellVertices[housingOffset]!,
396
+ options.inletShell.vertices[sourceOffset + 1]! - shellVertices[housingOffset + 1]!,
397
+ options.inletShell.vertices[sourceOffset + 2]! - shellVertices[housingOffset + 2]!,
398
+ )
399
+ if (seamDistance > 0.000_001) {
400
+ throw new Error(`Vortex inlet shell misses its housing seam by ${seamDistance}`)
401
+ }
402
+ remappedVertices.set(seamVertex.vertexIndex, housingVertexIndex)
403
+ inletSeamVertexIndices.push(housingVertexIndex)
404
+ }
405
+
406
+ for (const sourceIndex of options.inletShell.indices) {
407
+ let housingVertexIndex = remappedVertices.get(sourceIndex)
408
+ if (housingVertexIndex === undefined) {
409
+ const sourceOffset = sourceIndex * 3
410
+ housingVertexIndex = shellVertices.length / 3
411
+ shellVertices.push(
412
+ options.inletShell.vertices[sourceOffset]!,
413
+ options.inletShell.vertices[sourceOffset + 1]!,
414
+ options.inletShell.vertices[sourceOffset + 2]!,
415
+ )
416
+ shellUvs.push(
417
+ options.inletShell.vertices[sourceOffset]!,
418
+ options.inletShell.vertices[sourceOffset + 2]!,
419
+ )
420
+ remappedVertices.set(sourceIndex, housingVertexIndex)
421
+ }
422
+ shellIndices.push(housingVertexIndex)
423
+ }
424
+ for (const sourceIndex of options.inletShell.fasciaIndices ?? []) {
425
+ const housingVertexIndex = remappedVertices.get(sourceIndex)
426
+ if (housingVertexIndex === undefined) {
427
+ throw new Error(`Missing remapped Vortex inlet fascia vertex ${sourceIndex}`)
428
+ }
429
+ outerFasciaIndices.push(housingVertexIndex)
430
+ inletFasciaOverlayIndexCount += 1
431
+ }
432
+ }
433
+
434
+ // Trimming the circular fascia deliberately leaves some generated ring
435
+ // vertices unreferenced. Compact the completed topology so the production
436
+ // geometry contains only vertices owned by a visible triangle, while
437
+ // preserving the inlet-to-bowl seam indices and angular wrap pairs.
438
+ const referencedVertexIndices = [...new Set(shellIndices)].sort((left, right) => left - right)
439
+ const compactVertexIndex = new Map<number, number>()
440
+ const compactShellVertices: number[] = []
441
+ const compactShellUvs: number[] = []
442
+ referencedVertexIndices.forEach((sourceIndex, targetIndex) => {
443
+ compactVertexIndex.set(sourceIndex, targetIndex)
444
+ const sourceOffset = sourceIndex * 3
445
+ compactShellVertices.push(
446
+ shellVertices[sourceOffset]!,
447
+ shellVertices[sourceOffset + 1]!,
448
+ shellVertices[sourceOffset + 2]!,
449
+ )
450
+ const sourceUvOffset = sourceIndex * 2
451
+ compactShellUvs.push(shellUvs[sourceUvOffset]!, shellUvs[sourceUvOffset + 1]!)
452
+ })
453
+ const compactShellIndices = shellIndices.map(index => compactVertexIndex.get(index)!)
454
+ const compactInletSeamVertexIndices = inletSeamVertexIndices.map(index => compactVertexIndex.get(index)!)
455
+ const compactAngularWrapPairs = rings
456
+ .map((_, ringIndex) => [
457
+ compactVertexIndex.get(ringIndex * rowLength),
458
+ compactVertexIndex.get(ringIndex * rowLength + angularPanelCount),
459
+ ] as const)
460
+ .filter((pair): pair is readonly [number, number] => pair[0] !== undefined && pair[1] !== undefined)
461
+
462
+ const shellGeometry = new THREE.BufferGeometry()
463
+ shellGeometry.setAttribute('position', new THREE.Float32BufferAttribute(compactShellVertices, 3))
464
+ shellGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(compactShellUvs, 2))
465
+ shellGeometry.setIndex(compactShellIndices)
466
+ shellGeometry.computeVertexNormals()
467
+ const normals = shellGeometry.getAttribute('normal')
468
+ for (const [first, last] of compactAngularWrapPairs) {
469
+ const averaged = new THREE.Vector3(
470
+ normals.getX(first) + normals.getX(last),
471
+ normals.getY(first) + normals.getY(last),
472
+ normals.getZ(first) + normals.getZ(last),
473
+ ).normalize()
474
+ normals.setXYZ(first, averaged.x, averaged.y, averaged.z)
475
+ normals.setXYZ(last, averaged.x, averaged.y, averaged.z)
476
+ }
477
+ normals.needsUpdate = true
478
+ const shell = new THREE.Mesh(shellGeometry, options.shellMaterial)
479
+ shell.name = `${options.pieceId}-wild-west-vortex-continuous-housing-shell`
480
+ shell.userData.panelCount = angularPanelCount * (rings.length - 1)
481
+ shell.userData.surfaceTopology = 'single-continuous-indexed-mesh'
482
+ shell.userData.continuousOutputShell = true
483
+ shell.userData.outletTerminalProfile = 'single-surface-funnel-and-open-pipe'
484
+ shell.userData.outletPipeBands = outlet ? tubeBands : 0
485
+ shell.userData.outletPipeLinear = outletIsLinear
486
+ shell.userData.embeddedTubeEntry = false
487
+ shell.userData.throatSealRadius = outlet ? outletRadius : 0
488
+ shell.userData.singleTubeExit = true
489
+ shell.userData.singleRenderMesh = Boolean(outlet && funnelRings)
490
+ shell.userData.duplicateOutletRingCount = 0
491
+ shell.userData.integratedInletShell = Boolean(options.inletShell)
492
+ shell.userData.inletSeamVertexIndices = compactInletSeamVertexIndices
493
+ shell.userData.inletSeamSampleCount = compactInletSeamVertexIndices.length
494
+ shell.userData.inletShellIndexOffset = inletShellIndexOffset
495
+ shell.userData.inletFasciaTerminalCount = inletFasciaTerminalCount
496
+ shell.userData.trimmedInletFasciaPanels = trimmedFasciaPanels
497
+ shell.userData.rimOpeningBoundaryAngles = openingBoundaryAngles
498
+ shell.userData.circularOuterRadius = outerRadius
499
+ shell.userData.floorOuterRadius = floorOuterRadius
500
+ markDecorative(shell)
501
+ group.add(shell)
502
+
503
+ // The traversable bowl and this housing deliberately remain separate
504
+ // geometry owners. The shared floor treatment therefore cannot discover
505
+ // the housing's exposed vertical lip from the floor mesh. Reuse only the
506
+ // first housing band here so the visual shoulder continues down the outer
507
+ // floor depth without reaching the bowl underside, funnel, drain bore, or
508
+ // connected inlet opening.
509
+ for (const [treatmentIndex, treatment] of (options.floorPerimeterTreatments ?? []).entries()) {
510
+ if (outerFasciaIndices.length === 0) continue
511
+ const sourceVertexIndices = [...new Set(outerFasciaIndices)].sort((left, right) => left - right)
512
+ const fasciaVertexIndex = new Map<number, number>()
513
+ const fasciaVertices: number[] = []
514
+ const fasciaUvs: number[] = []
515
+ const zeroDistances: number[] = []
516
+ const faceRoles: number[] = []
517
+ sourceVertexIndices.forEach((sourceIndex, targetIndex) => {
518
+ fasciaVertexIndex.set(sourceIndex, targetIndex)
519
+ const positionOffset = sourceIndex * 3
520
+ const x = shellVertices[positionOffset]!
521
+ const y = shellVertices[positionOffset + 1]!
522
+ const z = shellVertices[positionOffset + 2]!
523
+ fasciaVertices.push(x, y, z)
524
+ const uvOffset = sourceIndex * 2
525
+ fasciaUvs.push(shellUvs[uvOffset]!, shellUvs[uvOffset + 1]!)
526
+ zeroDistances.push(0)
527
+ faceRoles.push(2)
528
+ })
529
+
530
+ const fasciaGeometry = new THREE.BufferGeometry()
531
+ fasciaGeometry.setAttribute('position', new THREE.Float32BufferAttribute(fasciaVertices, 3))
532
+ fasciaGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(fasciaUvs, 2))
533
+ fasciaGeometry.setAttribute('themePerimeterDistance', new THREE.Float32BufferAttribute(zeroDistances, 1))
534
+ fasciaGeometry.setAttribute('themePerimeterSideDistance', new THREE.Float32BufferAttribute(zeroDistances, 1))
535
+ fasciaGeometry.setAttribute('themePerimeterTerminalDistance', new THREE.Float32BufferAttribute(zeroDistances, 1))
536
+ fasciaGeometry.setAttribute('themePerimeterFaceRole', new THREE.Float32BufferAttribute(faceRoles, 1))
537
+ fasciaGeometry.setIndex(outerFasciaIndices.map(index => fasciaVertexIndex.get(index)!))
538
+ // Recompute from the selected fascia faces rather than borrowing the
539
+ // continuous housing normals. This keeps the circular lip smooth while
540
+ // giving both straight inlet sides their own correct outward offset.
541
+ fasciaGeometry.computeVertexNormals()
542
+ fasciaGeometry.computeBoundingBox()
543
+ fasciaGeometry.computeBoundingSphere()
544
+ fasciaGeometry.userData.perimeterSource = 'vortex-housing-outer-depth'
545
+ fasciaGeometry.userData.perimeterCoversTop = false
546
+ fasciaGeometry.userData.perimeterCoversExposedSides = true
547
+ fasciaGeometry.userData.perimeterCoversUnderside = false
548
+ fasciaGeometry.userData.perimeterCoversInnerDrain = false
549
+ fasciaGeometry.userData.inletFasciaTriangleCount = inletFasciaOverlayIndexCount / 3
550
+
551
+ const fascia = new THREE.Mesh(fasciaGeometry, treatment.material)
552
+ fascia.name = `${options.pieceId}-floor-perimeter-depth-overlay${treatmentIndex === 0 ? '' : `-${treatmentIndex + 1}`}`
553
+ fascia.userData.themeOnly = treatment.themeId
554
+ fascia.userData.decorative = true
555
+ fascia.userData.visualOnly = true
556
+ fascia.userData.appearanceMaterialId = treatment.materialId
557
+ fascia.userData.perimeterEdgeProfile = 'metric-square-perimeter-feather'
558
+ fascia.userData.vortexHousingRole = 'outer-floor-fascia-shoulder'
559
+ fascia.receiveShadow = true
560
+ group.add(fascia)
561
+ }
562
+
563
+ const housingBounds = new THREE.Box3().setFromObject(group)
564
+ group.userData.housingBounds = {
565
+ min: housingBounds.min.toArray(),
566
+ max: housingBounds.max.toArray(),
567
+ }
568
+ group.userData.drainTopY = options.drainTopY
569
+ group.userData.bodyMeshCount = 1
570
+ root.add(group)
571
+ return group
572
+ }
@@ -0,0 +1 @@
1
+ export * from '@marble.fun/physics-engine/atlas-vortex-layout'
@@ -0,0 +1 @@
1
+ export * from '@marble.fun/physics-engine/cone-field-layout'
@@ -0,0 +1 @@
1
+ export * from '@marble.fun/physics-engine/conveyor-layout'
@@ -0,0 +1,64 @@
1
+ import type { TrackDocument } from '@marble.fun/physics-engine'
2
+ import * as THREE from 'three'
3
+
4
+ import type { ThemeMaterialRegistry } from '../../../three/theme-material-registry'
5
+ import { applyTrackDocumentMaterialContinuity } from '../../../three/track-material-continuity'
6
+ import { batchStaticTrackShadowCasters } from '../../play/race/track-shadow-batching'
7
+ import { visualConnectionState } from './track-lab-connection-state'
8
+ import { createTrackPieceVisual, type TrackPieceVisual } from './track-piece-visual'
9
+
10
+ /**
11
+ * The materialised course shared by editing and racing. Editor helpers,
12
+ * marbles, Rapier resources, and race-only effects deliberately live outside
13
+ * this assembly.
14
+ */
15
+ export interface CanonicalTrackAssembly {
16
+ readonly document: TrackDocument
17
+ readonly root: THREE.Group
18
+ readonly pieceVisuals: ReadonlyMap<string, TrackPieceVisual>
19
+ }
20
+
21
+ export interface OwnedCanonicalTrackAssembly extends CanonicalTrackAssembly {
22
+ dispose(): void
23
+ }
24
+
25
+ export function createCanonicalTrackAssembly(
26
+ document: TrackDocument,
27
+ materials: ThemeMaterialRegistry,
28
+ ): OwnedCanonicalTrackAssembly {
29
+ const root = new THREE.Group()
30
+ root.name = 'canonical-track-pieces'
31
+ const pieceVisuals = new Map<string, TrackPieceVisual>()
32
+ const {
33
+ connectedConnectorIdsByPiece,
34
+ continuousFluidConnectorIdsByPiece,
35
+ continuousSupportConnectorIdsByPiece,
36
+ connectorFramesByPiece,
37
+ } = visualConnectionState(document)
38
+
39
+ for (const piece of document.pieces) {
40
+ const visual = createTrackPieceVisual(piece, materials, {
41
+ connectedConnectorIds: connectedConnectorIdsByPiece.get(piece.id) ?? new Set(),
42
+ continuousFluidConnectorIds: continuousFluidConnectorIdsByPiece.get(piece.id) ?? new Set(),
43
+ continuousSupportConnectorIds: continuousSupportConnectorIdsByPiece.get(piece.id) ?? new Set(),
44
+ connectorFrames: connectorFramesByPiece.get(piece.id),
45
+ overviewLod: true,
46
+ deferSupportUntilMaterialContinuity: true,
47
+ })
48
+ root.add(visual.root)
49
+ pieceVisuals.set(piece.id, visual)
50
+ }
51
+ applyTrackDocumentMaterialContinuity(document, pieceVisuals)
52
+ for (const visual of pieceVisuals.values()) batchStaticTrackShadowCasters(visual.root)
53
+
54
+ return {
55
+ document,
56
+ root,
57
+ pieceVisuals,
58
+ dispose(): void {
59
+ for (const visual of pieceVisuals.values()) visual.dispose()
60
+ pieceVisuals.clear()
61
+ root.removeFromParent()
62
+ },
63
+ }
64
+ }