@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 @@
1
+ export { trackSurfaceBasis, type TrackSurfaceBasis } from '@marble.fun/physics-engine'
@@ -0,0 +1,326 @@
1
+ import * as THREE from 'three'
2
+
3
+ export type TrackWallBoundaryRole = 'outer' | 'junction' | 'divider'
4
+ export type TrackBoundaryOwnership = 'exposed' | 'connected' | 'closed-loop'
5
+
6
+ export const TRACK_SURFACE_BOUNDARY_INTERIOR = 0
7
+ export const TRACK_SURFACE_BOUNDARY_EXPOSED = 1
8
+ export const TRACK_SURFACE_BOUNDARY_CONNECTED = 2
9
+
10
+ export interface CanonicalWallFrame {
11
+ base: THREE.Vector3
12
+ tangent: THREE.Vector3
13
+ up: THREE.Vector3
14
+ outward: THREE.Vector3
15
+ distance: number
16
+ }
17
+
18
+ export interface CanonicalWallRun {
19
+ frames: readonly CanonicalWallFrame[]
20
+ role: TrackWallBoundaryRole
21
+ capStart: boolean
22
+ capEnd: boolean
23
+ startOwnership: TrackBoundaryOwnership
24
+ endOwnership: TrackBoundaryOwnership
25
+ closed: boolean
26
+ length: number
27
+ }
28
+
29
+ export interface RawWallFrame {
30
+ base: THREE.Vector3
31
+ tangent: THREE.Vector3
32
+ up: THREE.Vector3
33
+ outward: THREE.Vector3
34
+ }
35
+
36
+ export type CanonicalWallTerminal = 'start' | 'end'
37
+
38
+ /**
39
+ * Finds convergent terminals that are open only because two runs inside the
40
+ * same visual assembly meet there. Ordinary end-to-start track connectors
41
+ * remain neighbor-owned and open; two starts or two ends at one point need a
42
+ * single local visual closure so modular facades cannot expose a slit.
43
+ */
44
+ export function canonicalConvergentConnectedTerminalClosures(
45
+ runs: readonly CanonicalWallRun[],
46
+ ): ReadonlySet<string> {
47
+ const groups = new Map<string, Array<{ runIndex: number; terminal: CanonicalWallTerminal }>>()
48
+ runs.forEach((run, runIndex) => {
49
+ if (run.closed || run.frames.length < 2) return
50
+ for (const [terminal, ownership] of [
51
+ ['start', run.startOwnership],
52
+ ['end', run.endOwnership],
53
+ ] as const) {
54
+ if (ownership !== 'connected') continue
55
+ const frame = terminal === 'start' ? run.frames[0]! : run.frames.at(-1)!
56
+ const key = frame.base.toArray().map(value => value.toFixed(5)).join(':')
57
+ const entries = groups.get(key) ?? []
58
+ entries.push({ runIndex, terminal })
59
+ groups.set(key, entries)
60
+ }
61
+ })
62
+ const owners = new Set<string>()
63
+ for (const entries of groups.values()) {
64
+ if (entries.length < 2 || !entries.every(entry => entry.terminal === entries[0]!.terminal)) continue
65
+ const owner = entries[0]!
66
+ owners.add(`${owner.runIndex}:${owner.terminal}`)
67
+ }
68
+ return owners
69
+ }
70
+
71
+ const EPSILON = 0.000_001
72
+
73
+ function finiteVector(vector: THREE.Vector3): boolean {
74
+ return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z)
75
+ }
76
+
77
+ function segmentsIntersect(
78
+ a: THREE.Vector3,
79
+ b: THREE.Vector3,
80
+ c: THREE.Vector3,
81
+ d: THREE.Vector3,
82
+ ): boolean {
83
+ const first = b.clone().sub(a)
84
+ const second = d.clone().sub(c)
85
+ const between = a.clone().sub(c)
86
+ const firstLength = first.dot(first)
87
+ const crossLength = first.dot(second)
88
+ const secondLength = second.dot(second)
89
+ const firstOffset = first.dot(between)
90
+ const secondOffset = second.dot(between)
91
+ const determinant = firstLength * secondLength - crossLength * crossLength
92
+ let firstNumerator: number
93
+ let firstDenominator = determinant
94
+ let secondNumerator: number
95
+ let secondDenominator: number
96
+
97
+ if (determinant <= EPSILON) {
98
+ firstNumerator = 0
99
+ firstDenominator = 1
100
+ secondNumerator = secondOffset
101
+ secondDenominator = secondLength
102
+ } else {
103
+ secondDenominator = determinant
104
+ firstNumerator = crossLength * secondOffset - secondLength * firstOffset
105
+ secondNumerator = firstLength * secondOffset - crossLength * firstOffset
106
+ if (firstNumerator < 0) {
107
+ firstNumerator = 0
108
+ secondNumerator = secondOffset
109
+ secondDenominator = secondLength
110
+ } else if (firstNumerator > firstDenominator) {
111
+ firstNumerator = firstDenominator
112
+ secondNumerator = secondOffset + crossLength
113
+ secondDenominator = secondLength
114
+ }
115
+ }
116
+
117
+ if (secondNumerator < 0) {
118
+ secondNumerator = 0
119
+ if (-firstOffset < 0) firstNumerator = 0
120
+ else if (-firstOffset > firstLength) firstNumerator = firstDenominator
121
+ else {
122
+ firstNumerator = -firstOffset
123
+ firstDenominator = firstLength
124
+ }
125
+ } else if (secondNumerator > secondDenominator) {
126
+ secondNumerator = secondDenominator
127
+ const terminalOffset = -firstOffset + crossLength
128
+ if (terminalOffset < 0) firstNumerator = 0
129
+ else if (terminalOffset > firstLength) firstNumerator = firstDenominator
130
+ else {
131
+ firstNumerator = terminalOffset
132
+ firstDenominator = firstLength
133
+ }
134
+ }
135
+
136
+ const firstProgress = Math.abs(firstNumerator) <= EPSILON ? 0 : firstNumerator / firstDenominator
137
+ const secondProgress = Math.abs(secondNumerator) <= EPSILON ? 0 : secondNumerator / secondDenominator
138
+ return between.addScaledVector(first, firstProgress).addScaledVector(second, -secondProgress).lengthSq() <= EPSILON * EPSILON
139
+ }
140
+
141
+ function assertSimplePath(points: readonly THREE.Vector3[], closed: boolean): void {
142
+ const segmentCount = points.length - 1
143
+ for (let left = 0; left < segmentCount; left += 1) {
144
+ for (let right = left + 1; right < segmentCount; right += 1) {
145
+ if (right === left + 1) continue
146
+ if (closed && left === 0 && right === segmentCount - 1) continue
147
+ if (segmentsIntersect(points[left]!, points[left + 1]!, points[right]!, points[right + 1]!)) {
148
+ throw new Error(
149
+ `Wall boundary self-intersects between segments ${left} and ${right}: ${JSON.stringify([
150
+ points[left]!.toArray(),
151
+ points[left + 1]!.toArray(),
152
+ points[right]!.toArray(),
153
+ points[right + 1]!.toArray(),
154
+ ])}`,
155
+ )
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Converts one source boundary into the only frame contract themed walls may
163
+ * consume. Path positions own the tangent; authored axes are orientation hints
164
+ * only. Invalid topology fails here instead of exploding inside a theme.
165
+ */
166
+ export function createCanonicalWallRun(
167
+ source: readonly RawWallFrame[],
168
+ options: {
169
+ role: TrackWallBoundaryRole
170
+ capStart?: boolean
171
+ capEnd?: boolean
172
+ /** Width-changing lanes must derive orientation from their moving boundary. */
173
+ tangentSource?: 'authored' | 'boundary'
174
+ },
175
+ ): CanonicalWallRun | undefined {
176
+ const points: RawWallFrame[] = []
177
+ for (const frame of source) {
178
+ if (![frame.base, frame.tangent, frame.up, frame.outward].every(finiteVector)) {
179
+ throw new Error('Wall boundary contains a non-finite frame')
180
+ }
181
+ const previous = points.at(-1)
182
+ if (previous && previous.base.distanceToSquared(frame.base) <= EPSILON * EPSILON) continue
183
+ points.push({
184
+ base: frame.base.clone(),
185
+ tangent: frame.tangent.clone(),
186
+ up: frame.up.clone(),
187
+ outward: frame.outward.clone(),
188
+ })
189
+ }
190
+ if (points.length < 2) return undefined
191
+
192
+ const closed = points[0]!.base.distanceToSquared(points.at(-1)!.base) <= 0.001 * 0.001
193
+ if (closed && points.length < 4) return undefined
194
+ assertSimplePath(points.map(frame => frame.base), closed)
195
+ const uniqueCount = closed ? points.length - 1 : points.length
196
+ const frames: CanonicalWallFrame[] = []
197
+ let distance = 0
198
+
199
+ for (let index = 0; index < uniqueCount; index += 1) {
200
+ const sourceFrame = points[index]!
201
+ const previous = points[closed ? (index - 1 + uniqueCount) % uniqueCount : Math.max(0, index - 1)]!
202
+ const next = points[closed ? (index + 1) % uniqueCount : Math.min(uniqueCount - 1, index + 1)]!
203
+ // Walls follow the boundary's elevation, but their construction frame is
204
+ // always vertical. Including grade in the frame tangent forces `up` to
205
+ // lean backwards on slopes and makes every themed wall/roof inherit that
206
+ // piece-specific skew.
207
+ const worldUp = new THREE.Vector3(0, 1, 0)
208
+ const pathTangent = next.base.clone().sub(previous.base)
209
+ pathTangent.y = 0
210
+ if (pathTangent.lengthSq() <= EPSILON) {
211
+ pathTangent.copy(sourceFrame.tangent)
212
+ pathTangent.y = 0
213
+ }
214
+ if (pathTangent.lengthSq() <= EPSILON) return undefined
215
+ pathTangent.normalize()
216
+ if (pathTangent.dot(sourceFrame.tangent) < 0) pathTangent.negate()
217
+ const authoredTangent = sourceFrame.tangent.clone()
218
+ authoredTangent.y = 0
219
+ if (authoredTangent.lengthSq() > EPSILON) authoredTangent.normalize()
220
+ // Authored derivatives preserve smooth curve/grade continuity. The path
221
+ // chord takes ownership only when they materially disagree (normally a
222
+ // width transition or malformed producer), using one global threshold.
223
+ const openTerminal = !closed && (index === 0 || index === uniqueCount - 1)
224
+ const tangent =
225
+ openTerminal && authoredTangent.lengthSq() > EPSILON
226
+ ? authoredTangent
227
+ : options.tangentSource === 'boundary'
228
+ ? pathTangent
229
+ : uniqueCount > 2 &&
230
+ authoredTangent.lengthSq() > EPSILON &&
231
+ pathTangent.dot(authoredTangent) >= Math.cos(THREE.MathUtils.degToRad(20))
232
+ ? authoredTangent
233
+ : pathTangent
234
+
235
+ const up = worldUp.clone()
236
+ const outward = new THREE.Vector3().crossVectors(tangent, up)
237
+ if (outward.lengthSq() <= EPSILON) return undefined
238
+ outward.normalize()
239
+ // A mirrored lane reverses the path-local tangent, not world up. Flipping
240
+ // up to satisfy the outward hint turns roofs upside-down; flipping tangent
241
+ // preserves the physical boundary order while keeping the frame proper.
242
+ if (outward.dot(sourceFrame.outward) < 0) {
243
+ tangent.negate()
244
+ outward.crossVectors(tangent, up).normalize()
245
+ }
246
+ if (frames.length > 0) distance += sourceFrame.base.distanceTo(frames.at(-1)!.base)
247
+ frames.push({ base: sourceFrame.base.clone(), tangent, up, outward, distance })
248
+ }
249
+
250
+ if (closed) {
251
+ distance += frames.at(-1)!.base.distanceTo(frames[0]!.base)
252
+ frames.push({
253
+ base: frames[0]!.base.clone(),
254
+ tangent: frames[0]!.tangent.clone(),
255
+ up: frames[0]!.up.clone(),
256
+ outward: frames[0]!.outward.clone(),
257
+ distance,
258
+ })
259
+ }
260
+ const capStart = !closed && options.capStart !== false
261
+ const capEnd = !closed && options.capEnd !== false
262
+ return {
263
+ frames,
264
+ role: options.role,
265
+ capStart,
266
+ capEnd,
267
+ startOwnership: closed ? 'closed-loop' : capStart ? 'exposed' : 'connected',
268
+ endOwnership: closed ? 'closed-loop' : capEnd ? 'exposed' : 'connected',
269
+ closed,
270
+ length: distance,
271
+ }
272
+ }
273
+
274
+ export function canonicalWallFrameAt(run: CanonicalWallRun, targetDistance: number): CanonicalWallFrame {
275
+ const target = THREE.MathUtils.clamp(targetDistance, 0, run.length)
276
+ let low = 1
277
+ let high = run.frames.length - 1
278
+ while (low < high) {
279
+ const middle = (low + high) >>> 1
280
+ if (run.frames[middle]!.distance < target) low = middle + 1
281
+ else high = middle
282
+ }
283
+ const segment = Math.max(0, low - 1)
284
+ const start = run.frames[segment]!
285
+ const end = run.frames[segment + 1]!
286
+ const span = end.distance - start.distance
287
+ const progress = span <= EPSILON ? 0 : (target - start.distance) / span
288
+ const tangent = start.tangent.clone().lerp(end.tangent, progress).normalize()
289
+ const up = start.up.clone().lerp(end.up, progress)
290
+ up.addScaledVector(tangent, -up.dot(tangent)).normalize()
291
+ const outward = new THREE.Vector3().crossVectors(tangent, up).normalize()
292
+ if (outward.dot(start.outward.clone().lerp(end.outward, progress)) < 0) outward.negate()
293
+ up.crossVectors(outward, tangent).normalize()
294
+ return {
295
+ base: start.base.clone().lerp(end.base, progress),
296
+ tangent,
297
+ up,
298
+ outward,
299
+ distance: target,
300
+ }
301
+ }
302
+
303
+ export function resampleCanonicalWallRun(run: CanonicalWallRun, spacing: number): CanonicalWallRun {
304
+ const safeSpacing = Math.max(0.01, spacing)
305
+ const count = Math.max(2, Math.ceil(run.length / safeSpacing) + 1)
306
+ const cornerCosine = Math.cos(THREE.MathUtils.degToRad(12))
307
+ // Source curves may contain dozens of tessellation frames. Retaining every
308
+ // one defeats metric resampling and compresses decorative seams on the
309
+ // shorter side of a bend. Preserve only genuine authored corners.
310
+ const cornerDistances = run.frames.flatMap((frame, index) => {
311
+ if (index === 0 || index >= run.frames.length - 1) return []
312
+ const incoming = frame.base.clone().sub(run.frames[index - 1]!.base)
313
+ const outgoing = run.frames[index + 1]!.base.clone().sub(frame.base)
314
+ if (incoming.lengthSq() <= EPSILON || outgoing.lengthSq() <= EPSILON) return []
315
+ return incoming.normalize().dot(outgoing.normalize()) < cornerCosine ? [frame.distance] : []
316
+ })
317
+ const targets = [
318
+ 0,
319
+ ...cornerDistances,
320
+ run.length,
321
+ ...Array.from({ length: count }, (_, index) => (index / (count - 1)) * run.length),
322
+ ]
323
+ .sort((left, right) => left - right)
324
+ .filter((distance, index, values) => index === 0 || distance - values[index - 1]! > EPSILON)
325
+ return { ...run, frames: targets.map(distance => canonicalWallFrameAt(run, distance)) }
326
+ }