@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,167 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { TRACK_RAIL_WALL_THICKNESS } from './track-definition'
4
+ import {
5
+ canonicalConvergentConnectedTerminalClosures,
6
+ type CanonicalWallRun,
7
+ } from './track-surface-topology'
8
+
9
+ export interface CanonicalWallTerminalSkinOptions {
10
+ material: THREE.Material
11
+ wallThickness?: number
12
+ facadeOverlap?: number
13
+ projection?: number
14
+ inset?: number
15
+ /** Vertical offset above the canonical wall base for layered terminal finishes. */
16
+ verticalOffset?: number
17
+ }
18
+
19
+ /**
20
+ * Covers exposed canonical wall terminals with the material selected by the
21
+ * appearance applier. Connected and closed-loop boundaries produce no skin.
22
+ * The short wrapped return hides backing and facade end faces without changing
23
+ * the canonical wall or its Rapier collider.
24
+ */
25
+ export function addCanonicalWallTerminalSkins(
26
+ root: THREE.Group,
27
+ id: string,
28
+ runs: readonly CanonicalWallRun[],
29
+ wallHeight: number,
30
+ options: CanonicalWallTerminalSkinOptions,
31
+ ): THREE.Mesh | undefined {
32
+ if (wallHeight <= 0.001) return undefined
33
+ const wallThickness = options.wallThickness ?? TRACK_RAIL_WALL_THICKNESS
34
+ const facadeOverlap = options.facadeOverlap ?? 0.014
35
+ const projection = options.projection ?? 0.062
36
+ const inset = options.inset ?? 0.006
37
+ const verticalOffset = options.verticalOffset ?? 0
38
+ const positions: number[] = []
39
+ const uvs: number[] = []
40
+ const metricUvs: number[] = []
41
+ const indices: number[] = []
42
+ let terminalCount = 0
43
+ let sharedConnectedClosureCount = 0
44
+ const sharedConnectedClosures = canonicalConvergentConnectedTerminalClosures(runs)
45
+
46
+ const appendVertex = (point: THREE.Vector3, u: number, v: number, extent: number): number => {
47
+ const index = positions.length / 3
48
+ positions.push(point.x, point.y, point.z)
49
+ uvs.push(u, v)
50
+ metricUvs.push(u, v, 1, extent)
51
+ return index
52
+ }
53
+ const appendQuad = (
54
+ a: THREE.Vector3,
55
+ b: THREE.Vector3,
56
+ c: THREE.Vector3,
57
+ d: THREE.Vector3,
58
+ expectedNormal: THREE.Vector3,
59
+ width: number,
60
+ height: number,
61
+ ): void => {
62
+ const extent = Math.max(0.001, width, height)
63
+ const offset = positions.length / 3
64
+ appendVertex(a, 0, 0, extent)
65
+ appendVertex(b, width, 0, extent)
66
+ appendVertex(c, 0, height, extent)
67
+ appendVertex(d, width, height, extent)
68
+ const normal = b.clone().sub(a).cross(c.clone().sub(a))
69
+ if (normal.dot(expectedNormal) >= 0) {
70
+ indices.push(offset, offset + 1, offset + 2, offset + 2, offset + 1, offset + 3)
71
+ } else {
72
+ indices.push(offset, offset + 2, offset + 1, offset + 2, offset + 3, offset + 1)
73
+ }
74
+ }
75
+
76
+ for (const [runIndex, run] of runs.entries()) {
77
+ if (run.frames.length < 2 || run.closed) continue
78
+ for (const [terminal, ownership] of [
79
+ ['start', run.startOwnership],
80
+ ['end', run.endOwnership],
81
+ ] as const) {
82
+ const ownsSharedConnectedClosure = sharedConnectedClosures.has(`${runIndex}:${terminal}`)
83
+ if (ownership !== 'exposed' && !ownsSharedConnectedClosure) continue
84
+ const frame = terminal === 'start' ? run.frames[0]! : run.frames.at(-1)!
85
+ // Terminal projection follows the actual run endpoint chord. A
86
+ // canonical frame may reverse its tangent to preserve a physical
87
+ // outward side on mirrored or side-owned paths; using that orientation
88
+ // here can project the finish back into the traversable surface.
89
+ const adjacent = terminal === 'start' ? run.frames[1]! : run.frames.at(-2)!
90
+ const terminalNormal = frame.base.clone().sub(adjacent.base).setY(0)
91
+ if (terminalNormal.lengthSq() <= 0.000_001) {
92
+ terminalNormal.copy(frame.tangent).multiplyScalar(terminal === 'start' ? -1 : 1).setY(0)
93
+ }
94
+ terminalNormal.normalize()
95
+ const up = frame.up.clone().normalize()
96
+ const across = frame.outward.clone().normalize()
97
+ const halfWidth = wallThickness / 2 + facadeOverlap
98
+ const bottomCenter = frame.base.clone().addScaledVector(up, verticalOffset - 0.03)
99
+ // A convergent internal seam needs only a thin two-sided plug at the
100
+ // shared plane. The longer wrapped return remains exclusive to a truly
101
+ // exposed terminal so nothing protrudes through the neighboring run.
102
+ const terminalProjection = ownsSharedConnectedClosure ? inset : projection
103
+ const frontCenter = bottomCenter.clone().addScaledVector(terminalNormal, terminalProjection)
104
+ const backCenter = bottomCenter.clone().addScaledVector(terminalNormal, -inset)
105
+ const frontNegative = frontCenter.clone().addScaledVector(across, -halfWidth)
106
+ const frontPositive = frontCenter.clone().addScaledVector(across, halfWidth)
107
+ const backNegative = backCenter.clone().addScaledVector(across, -halfWidth)
108
+ const backPositive = backCenter.clone().addScaledVector(across, halfWidth)
109
+ const frontNegativeTop = frontNegative.clone().addScaledVector(up, wallHeight)
110
+ const frontPositiveTop = frontPositive.clone().addScaledVector(up, wallHeight)
111
+ const backNegativeTop = backNegative.clone().addScaledVector(up, wallHeight)
112
+ const backPositiveTop = backPositive.clone().addScaledVector(up, wallHeight)
113
+ const width = halfWidth * 2
114
+ const depth = terminalProjection + inset
115
+
116
+ appendQuad(frontNegative, frontPositive, frontNegativeTop, frontPositiveTop, terminalNormal, width, wallHeight)
117
+ appendQuad(
118
+ backPositive,
119
+ backNegative,
120
+ backPositiveTop,
121
+ backNegativeTop,
122
+ terminalNormal.clone().negate(),
123
+ width,
124
+ wallHeight,
125
+ )
126
+ appendQuad(
127
+ backNegative,
128
+ frontNegative,
129
+ backNegativeTop,
130
+ frontNegativeTop,
131
+ across.clone().negate(),
132
+ depth,
133
+ wallHeight,
134
+ )
135
+ appendQuad(frontPositive, backPositive, frontPositiveTop, backPositiveTop, across, depth, wallHeight)
136
+ appendQuad(frontNegativeTop, frontPositiveTop, backNegativeTop, backPositiveTop, up, width, depth)
137
+ appendQuad(backNegative, backPositive, frontNegative, frontPositive, up.clone().negate(), width, depth)
138
+ terminalCount += 1
139
+ if (ownsSharedConnectedClosure) sharedConnectedClosureCount += 1
140
+ }
141
+ }
142
+
143
+ if (terminalCount === 0) return undefined
144
+ const geometry = new THREE.BufferGeometry()
145
+ geometry.name = 'canonical-wall-terminal-skin-geometry'
146
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
147
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
148
+ geometry.setAttribute('themeNetUv', new THREE.Float32BufferAttribute(metricUvs, 4))
149
+ geometry.setIndex(indices)
150
+ geometry.computeVertexNormals()
151
+ geometry.computeBoundingBox()
152
+ geometry.computeBoundingSphere()
153
+ geometry.userData.terminalCount = terminalCount
154
+ geometry.userData.sharedConnectedClosureCount = sharedConnectedClosureCount
155
+ geometry.userData.boundaryOwnershipSource = 'canonical-wall-run'
156
+
157
+ const mesh = new THREE.Mesh(geometry, options.material)
158
+ mesh.name = `${id}-wall-terminal-skins`
159
+ mesh.castShadow = true
160
+ mesh.receiveShadow = true
161
+ mesh.userData.decorative = true
162
+ mesh.userData.visualOnly = true
163
+ mesh.userData.boundaryOwnershipSource = 'canonical-wall-run'
164
+ mesh.userData.sharedConnectedClosureCount = sharedConnectedClosureCount
165
+ root.add(mesh)
166
+ return mesh
167
+ }
@@ -0,0 +1,271 @@
1
+ import * as THREE from 'three'
2
+
3
+ import type { TrackDefinition, TrackGuidePath } from './track-definition'
4
+
5
+ export interface TrackSample {
6
+ guideId: string
7
+ pieceId: string
8
+ startDistance: number
9
+ endDistance: number
10
+ physicalLength: number
11
+ start: THREE.Vector3
12
+ end: THREE.Vector3
13
+ center: THREE.Vector3
14
+ tangent: THREE.Vector3
15
+ right: THREE.Vector3
16
+ up: THREE.Vector3
17
+ quaternion: THREE.Quaternion
18
+ }
19
+
20
+ export interface TrackPose {
21
+ sampleIndex: number
22
+ distance: number
23
+ position: THREE.Vector3
24
+ tangent: THREE.Vector3
25
+ right: THREE.Vector3
26
+ up: THREE.Vector3
27
+ quaternion: THREE.Quaternion
28
+ }
29
+
30
+ export interface TrackProjection {
31
+ sampleIndex: number
32
+ distance: number
33
+ lateralDistance: number
34
+ verticalDistance: number
35
+ }
36
+
37
+ export interface TrackDistanceSample {
38
+ endDistance: number
39
+ }
40
+
41
+ /** Finds the first guide sample ending at or after the requested distance. */
42
+ export function findTrackSampleIndexAtDistance(
43
+ samples: readonly TrackDistanceSample[],
44
+ distance: number,
45
+ hintIndex = 0,
46
+ ): number {
47
+ if (samples.length === 0) return -1
48
+ const hint = Math.min(samples.length - 1, Math.max(0, hintIndex))
49
+ if (
50
+ distance <= samples[hint]!.endDistance &&
51
+ (hint === 0 || distance > samples[hint - 1]!.endDistance)
52
+ ) {
53
+ return hint
54
+ }
55
+
56
+ let low = 0
57
+ let high = samples.length - 1
58
+ while (low < high) {
59
+ const middle = (low + high) >>> 1
60
+ if (distance <= samples[middle]!.endDistance) high = middle
61
+ else low = middle + 1
62
+ }
63
+ return low
64
+ }
65
+
66
+ function vectorFromTuple(tuple: readonly [number, number, number]): THREE.Vector3 {
67
+ return new THREE.Vector3(tuple[0], tuple[1], tuple[2])
68
+ }
69
+
70
+ function createSampleQuaternion(tangent: THREE.Vector3, right: THREE.Vector3, up: THREE.Vector3): THREE.Quaternion {
71
+ const basis = new THREE.Matrix4().makeBasis(right, up, tangent)
72
+ return new THREE.Quaternion().setFromRotationMatrix(basis)
73
+ }
74
+
75
+ function buildSamples(path: TrackGuidePath): TrackSample[] {
76
+ return path.points.slice(0, -1).map((point, index) => {
77
+ const next = path.points[index + 1]!
78
+ const start = vectorFromTuple(point.position)
79
+ const end = vectorFromTuple(next.position)
80
+ const tangent = end.clone().sub(start).normalize()
81
+ const right = new THREE.Vector3(tangent.z, 0, -tangent.x).normalize()
82
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
83
+
84
+ return {
85
+ guideId: path.id,
86
+ pieceId: next.pieceId || point.pieceId,
87
+ startDistance: point.progress,
88
+ endDistance: next.progress,
89
+ physicalLength: start.distanceTo(end),
90
+ start,
91
+ end,
92
+ center: start.clone().add(end).multiplyScalar(0.5),
93
+ tangent,
94
+ right,
95
+ up,
96
+ quaternion: createSampleQuaternion(tangent, right, up),
97
+ }
98
+ })
99
+ }
100
+
101
+ function samplesAreContinuous(left: TrackSample | undefined, right: TrackSample | undefined): boolean {
102
+ if (!left || !right) return false
103
+ return (
104
+ left.guideId === right.guideId &&
105
+ Math.abs(left.endDistance - right.startDistance) <= 0.01 &&
106
+ left.end.distanceToSquared(right.start) <= 0.04
107
+ )
108
+ }
109
+
110
+ function writeBlendedUnitVector(
111
+ left: THREE.Vector3,
112
+ right: THREE.Vector3,
113
+ interpolation: number,
114
+ output: THREE.Vector3,
115
+ ): void {
116
+ output.set(
117
+ THREE.MathUtils.lerp(left.x, right.x, interpolation),
118
+ THREE.MathUtils.lerp(left.y, right.y, interpolation),
119
+ THREE.MathUtils.lerp(left.z, right.z, interpolation),
120
+ )
121
+ if (output.lengthSq() < 0.000_001) output.copy(left)
122
+ output.normalize()
123
+ }
124
+
125
+ export class TrackCourse {
126
+ readonly samples: TrackSample[]
127
+ readonly projectionSamples: TrackSample[]
128
+ readonly totalLength: number
129
+
130
+ constructor(readonly definition: TrackDefinition) {
131
+ this.samples = buildSamples(definition.cameraPath)
132
+ this.projectionSamples = definition.projectionPaths.flatMap(buildSamples)
133
+ this.totalLength = definition.cameraPath.points.at(-1)?.progress ?? 0
134
+
135
+ if (this.samples.length === 0 || this.projectionSamples.length === 0 || this.totalLength <= 0) {
136
+ throw new Error(`Track ${definition.id}@${definition.version} has an invalid progress guide`)
137
+ }
138
+ }
139
+
140
+ createPose(): TrackPose {
141
+ return {
142
+ sampleIndex: 0,
143
+ distance: 0,
144
+ position: new THREE.Vector3(),
145
+ tangent: new THREE.Vector3(),
146
+ right: new THREE.Vector3(),
147
+ up: new THREE.Vector3(),
148
+ quaternion: new THREE.Quaternion(),
149
+ }
150
+ }
151
+
152
+ writePoseAtDistance(distance: number, pose: TrackPose): TrackPose {
153
+ const clampedDistance = THREE.MathUtils.clamp(distance, 0, this.totalLength)
154
+ const sampleIndex = findTrackSampleIndexAtDistance(this.samples, clampedDistance, pose.sampleIndex)
155
+ const sample = this.samples[sampleIndex]!
156
+ const progressLength = Math.max(0.0001, sample.endDistance - sample.startDistance)
157
+ const interpolation = THREE.MathUtils.clamp((clampedDistance - sample.startDistance) / progressLength, 0, 1)
158
+
159
+ pose.sampleIndex = sampleIndex
160
+ pose.distance = clampedDistance
161
+ pose.position.copy(sample.start).lerp(sample.end, interpolation)
162
+ pose.tangent.copy(sample.tangent)
163
+ pose.right.copy(sample.right)
164
+ pose.up.copy(sample.up)
165
+ pose.quaternion.copy(sample.quaternion)
166
+
167
+ return pose
168
+ }
169
+
170
+ /**
171
+ * Writes a continuous frame along the marble's selected projection branch.
172
+ * Projection samples are straight segments, so copying a sample tangent
173
+ * makes the camera rotate in visible steps at every boundary. Blending the
174
+ * shared boundary frames makes consecutive curves one continuous turn while
175
+ * retaining the exact branch selected by physics projection.
176
+ */
177
+ writeProjectionFrameAtDistance(
178
+ projectionSampleIndex: number,
179
+ distance: number,
180
+ tangent: THREE.Vector3,
181
+ right: THREE.Vector3,
182
+ up: THREE.Vector3,
183
+ ): void {
184
+ const sampleIndex = THREE.MathUtils.clamp(projectionSampleIndex, 0, this.projectionSamples.length - 1)
185
+ const sample = this.projectionSamples[sampleIndex]!
186
+ const previous = this.projectionSamples[sampleIndex - 1]
187
+ const next = this.projectionSamples[sampleIndex + 1]
188
+ const continuousPrevious = samplesAreContinuous(previous, sample) ? previous : undefined
189
+ const continuousNext = samplesAreContinuous(sample, next) ? next : undefined
190
+ const progressLength = Math.max(0.0001, sample.endDistance - sample.startDistance)
191
+ const interpolation = THREE.MathUtils.clamp((distance - sample.startDistance) / progressLength, 0, 1)
192
+
193
+ const startTangent = continuousPrevious?.tangent ?? sample.tangent
194
+ const endTangent = continuousNext?.tangent ?? sample.tangent
195
+ tangent.set(
196
+ sample.tangent.x + startTangent.x,
197
+ sample.tangent.y + startTangent.y,
198
+ sample.tangent.z + startTangent.z,
199
+ )
200
+ right.set(
201
+ sample.tangent.x + endTangent.x,
202
+ sample.tangent.y + endTangent.y,
203
+ sample.tangent.z + endTangent.z,
204
+ )
205
+ writeBlendedUnitVector(tangent, right, interpolation, tangent)
206
+
207
+ const startUp = continuousPrevious?.up ?? sample.up
208
+ const endUp = continuousNext?.up ?? sample.up
209
+ up.set(sample.up.x + startUp.x, sample.up.y + startUp.y, sample.up.z + startUp.z).normalize()
210
+ right.set(sample.up.x + endUp.x, sample.up.y + endUp.y, sample.up.z + endUp.z).normalize()
211
+ writeBlendedUnitVector(up, right, interpolation, up)
212
+
213
+ right.crossVectors(up, tangent)
214
+ if (right.lengthSq() < 0.000_001) right.copy(sample.right)
215
+ else right.normalize()
216
+ up.crossVectors(tangent, right).normalize()
217
+ }
218
+
219
+ projectPoint(
220
+ point: { x: number; y: number; z: number },
221
+ _hintIndex: number,
222
+ projection: TrackProjection,
223
+ allowForwardJump = false,
224
+ ): TrackProjection {
225
+ let bestSquaredDistance = Number.POSITIVE_INFINITY
226
+ const previousProgress = projection.distance
227
+
228
+ for (let index = 0; index < this.projectionSamples.length; index += 1) {
229
+ const sample = this.projectionSamples[index]!
230
+ // Nearby spatially-overlapping devices may represent very different stages.
231
+ if (
232
+ sample.endDistance < previousProgress - 8 ||
233
+ (!allowForwardJump && sample.startDistance > previousProgress + 22)
234
+ ) {
235
+ continue
236
+ }
237
+ const offsetX = point.x - sample.start.x
238
+ const offsetY = point.y - sample.start.y
239
+ const offsetZ = point.z - sample.start.z
240
+ const physicalAlong = THREE.MathUtils.clamp(
241
+ offsetX * sample.tangent.x + offsetY * sample.tangent.y + offsetZ * sample.tangent.z,
242
+ 0,
243
+ sample.physicalLength,
244
+ )
245
+ const closestX = sample.start.x + sample.tangent.x * physicalAlong
246
+ const closestY = sample.start.y + sample.tangent.y * physicalAlong
247
+ const closestZ = sample.start.z + sample.tangent.z * physicalAlong
248
+ const deltaX = point.x - closestX
249
+ const deltaY = point.y - closestY
250
+ const deltaZ = point.z - closestZ
251
+ const squaredDistance = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ
252
+
253
+ if (squaredDistance >= bestSquaredDistance) {
254
+ continue
255
+ }
256
+
257
+ const fraction = sample.physicalLength > 0 ? physicalAlong / sample.physicalLength : 0
258
+ bestSquaredDistance = squaredDistance
259
+ projection.sampleIndex = index
260
+ projection.distance = THREE.MathUtils.lerp(sample.startDistance, sample.endDistance, fraction)
261
+ projection.lateralDistance = deltaX * sample.right.x + deltaY * sample.right.y + deltaZ * sample.right.z
262
+ projection.verticalDistance = deltaX * sample.up.x + deltaY * sample.up.y + deltaZ * sample.up.z
263
+ }
264
+
265
+ return projection
266
+ }
267
+
268
+ getPieceId(projectionSampleIndex: number): string {
269
+ return this.projectionSamples[projectionSampleIndex]?.pieceId ?? 'unknown'
270
+ }
271
+ }