@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,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
|
+
}
|