@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,125 @@
1
+ import * as THREE from 'three'
2
+
3
+ interface ConnectorBinding {
4
+ mesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial>
5
+ materialVisible: boolean
6
+ }
7
+
8
+ /** Editor markers keep their source nodes for picking and connector focus. */
9
+ export function batchTrackConnectorMarkers(root: THREE.Group, sourceRoots: readonly THREE.Object3D[]) {
10
+ const groups = new Map<string, ConnectorBinding[]>()
11
+ for (const sourceRoot of sourceRoots) {
12
+ if (!sourceRoot.visible) continue
13
+ sourceRoot.traverse(object => {
14
+ if (
15
+ !(object instanceof THREE.Mesh) ||
16
+ !(object.geometry instanceof THREE.SphereGeometry) ||
17
+ !(object.material instanceof THREE.MeshBasicMaterial) ||
18
+ !object.userData.isTrackConnectorMarker ||
19
+ object.material.map ||
20
+ object.material.alphaMap ||
21
+ object.material.envMap ||
22
+ object.castShadow ||
23
+ object.receiveShadow ||
24
+ object.material.onBeforeCompile !== THREE.Material.prototype.onBeforeCompile
25
+ )
26
+ return
27
+ const material = object.material
28
+ const key = JSON.stringify([
29
+ object.geometry.parameters,
30
+ material.color.toArray(),
31
+ material.opacity,
32
+ material.transparent,
33
+ material.depthTest,
34
+ material.depthWrite,
35
+ material.depthFunc,
36
+ material.side,
37
+ material.blending,
38
+ material.toneMapped,
39
+ material.visible,
40
+ material.wireframe,
41
+ material.userData.themeBaseOpacity,
42
+ material.userData.themeBaseTransparent,
43
+ material.userData.themeBaseDepthWrite,
44
+ object.renderOrder,
45
+ object.layers.mask,
46
+ ])
47
+ const group = groups.get(key) ?? []
48
+ group.push({ mesh: object, materialVisible: material.visible })
49
+ groups.set(key, group)
50
+ })
51
+ }
52
+ const batches: Array<{ mesh: THREE.InstancedMesh; bindings: ConnectorBinding[] }> = []
53
+ const inverse = new THREE.Matrix4()
54
+ const matrix = new THREE.Matrix4()
55
+ const materialVisibility = new Map<THREE.MeshBasicMaterial, boolean>()
56
+ const updateTransforms = () => {
57
+ if (batches.length === 0) return
58
+ root.updateWorldMatrix(true, true)
59
+ inverse.copy(root.matrixWorld).invert()
60
+ for (const batch of batches) {
61
+ let count = 0
62
+ batch.bindings.forEach(({ mesh }) => {
63
+ if (!mesh.visible) return
64
+ matrix.copy(inverse).multiply(mesh.matrixWorld)
65
+ batch.mesh.setMatrixAt(count++, matrix)
66
+ })
67
+ batch.mesh.count = count
68
+ batch.mesh.instanceMatrix.needsUpdate = true
69
+ batch.mesh.computeBoundingBox()
70
+ batch.mesh.computeBoundingSphere()
71
+ }
72
+ }
73
+ for (const bindings of groups.values()) {
74
+ if (bindings.length < 2) continue
75
+ const first = bindings[0]!.mesh
76
+ const mesh = new THREE.InstancedMesh(first.geometry.clone(), first.material.clone(), bindings.length)
77
+ ;(mesh.material as THREE.Material).visible = bindings[0]!.materialVisible
78
+ mesh.name = `track-connector-batch-${batches.length}`
79
+ mesh.visible = bindings.some(binding => binding.mesh.visible)
80
+ mesh.renderOrder = first.renderOrder
81
+ mesh.layers.mask = first.layers.mask
82
+ mesh.userData.isTrackConnectorMarker = true
83
+ mesh.userData.connectorMarkerOwner = true
84
+ mesh.userData.excludeFromEditorBounds = true
85
+ mesh.userData.decorative = true
86
+ mesh.userData.visualOnly = true
87
+ // Material visibility leaves the source Object3D available to the editor,
88
+ // even when setEditorChromeVisible later toggles all marker nodes.
89
+ for (const binding of bindings) {
90
+ materialVisibility.set(binding.mesh.material, binding.materialVisible)
91
+ binding.mesh.material.visible = false
92
+ }
93
+ root.add(mesh)
94
+ batches.push({ mesh, bindings })
95
+ }
96
+ if (batches.length > 0) updateTransforms()
97
+ return {
98
+ batchCount: batches.length,
99
+ sourceCount: batches.reduce((sum, batch) => sum + batch.bindings.length, 0),
100
+ updateTransforms,
101
+ detachSources(roots: readonly THREE.Object3D[]): void {
102
+ const removed = new Set<THREE.Object3D>()
103
+ roots.forEach(source => source.traverse(object => removed.add(object)))
104
+ for (const batch of batches) batch.bindings = batch.bindings.filter(binding => !removed.has(binding.mesh))
105
+ const retainedMaterials = new Set(batches.flatMap(batch => batch.bindings.map(binding => binding.mesh.material)))
106
+ for (const [material, visible] of materialVisibility) {
107
+ if (retainedMaterials.has(material)) continue
108
+ material.visible = visible
109
+ materialVisibility.delete(material)
110
+ }
111
+ updateTransforms()
112
+ },
113
+ dispose(): void {
114
+ for (const [material, visible] of materialVisibility) material.visible = visible
115
+ materialVisibility.clear()
116
+ for (const { mesh } of batches) {
117
+ mesh.removeFromParent()
118
+ mesh.dispose()
119
+ mesh.geometry.dispose()
120
+ ;(mesh.material as THREE.Material).dispose()
121
+ }
122
+ batches.length = 0
123
+ },
124
+ }
125
+ }
@@ -0,0 +1,442 @@
1
+ import type { TrackDocument } from '@marble.fun/physics-engine'
2
+ import * as THREE from 'three'
3
+
4
+ type MaterialVisual = { root: THREE.Object3D | readonly THREE.Object3D[] }
5
+
6
+ function visualRoots(visual: MaterialVisual): readonly THREE.Object3D[] {
7
+ return visual.root instanceof THREE.Object3D ? [visual.root] : visual.root
8
+ }
9
+
10
+ function trackArchitectureMetricLengths(root: THREE.Object3D): number[] {
11
+ const lengths: number[] = []
12
+ root.traverse(object => {
13
+ const runLengths = object.userData.runMetricLengths
14
+ if (!Array.isArray(runLengths)) return
15
+ runLengths.forEach((length, index) => {
16
+ if (typeof length === 'number' && Number.isFinite(length)) {
17
+ lengths[index] = Math.max(lengths[index] ?? 0, length)
18
+ }
19
+ })
20
+ })
21
+ return lengths
22
+ }
23
+
24
+ function trackMaterialPreferredInputConnector(root: THREE.Object3D): string | undefined {
25
+ let connectorId: string | undefined
26
+ root.traverse(object => {
27
+ if (connectorId || !(object instanceof THREE.Mesh)) return
28
+ const candidate = object.geometry.userData.themeMaterialConnectionConnectorId
29
+ if (typeof candidate === 'string') connectorId = candidate
30
+ })
31
+ return connectorId
32
+ }
33
+
34
+ interface TrackMaterialConnectorFrame {
35
+ transverse: number
36
+ longitudinal: number
37
+ }
38
+
39
+ function trackMaterialConnectorFrames(root: THREE.Object3D): Map<string, TrackMaterialConnectorFrame> {
40
+ const frames = new Map<string, TrackMaterialConnectorFrame>()
41
+ root.traverse(object => {
42
+ if (!(object instanceof THREE.Mesh)) return
43
+ const candidates = object.geometry.userData.themeMaterialConnectorFrames
44
+ if (typeof candidates !== 'object' || candidates === null) return
45
+ for (const [connectorId, candidate] of Object.entries(candidates)) {
46
+ const frame = candidate as Partial<TrackMaterialConnectorFrame> | null
47
+ if (
48
+ typeof frame === 'object' &&
49
+ frame !== null &&
50
+ typeof frame.transverse === 'number' &&
51
+ Number.isFinite(frame.transverse) &&
52
+ typeof frame.longitudinal === 'number' &&
53
+ Number.isFinite(frame.longitudinal)
54
+ ) {
55
+ frames.set(connectorId, frame as TrackMaterialConnectorFrame)
56
+ }
57
+ }
58
+ })
59
+ return frames
60
+ }
61
+
62
+ export function trackMaterialMetricLength(root: THREE.Object3D): number {
63
+ let length = 0
64
+ let primaryRunLength: number | undefined
65
+ root.traverse(object => {
66
+ if (!(object instanceof THREE.Mesh)) return
67
+ if (object.userData.materialSlot !== 'lane.surface') return
68
+ const candidatePrimaryLength = object.geometry.userData.themeMaterialPrimaryRunLength
69
+ if (typeof candidatePrimaryLength === 'number' && Number.isFinite(candidatePrimaryLength)) {
70
+ primaryRunLength = Math.max(primaryRunLength ?? 0, candidatePrimaryLength)
71
+ }
72
+ const attribute = object.geometry.getAttribute('themeNetUv')
73
+ if (!attribute || attribute.itemSize < 4) return
74
+ for (let index = 0; index < attribute.count; index += 1) {
75
+ if (attribute.getZ(index) > 0.5) length = Math.max(length, attribute.getW(index))
76
+ }
77
+ })
78
+ return primaryRunLength ?? length
79
+ }
80
+
81
+ /**
82
+ * Writes one absolute metric offset. Individual materials apply their own
83
+ * texture/grid scale in the shader; source topology must never quantize phase
84
+ * to a theme-specific repeat period.
85
+ */
86
+ export function setTrackMaterialConnectionPhase(
87
+ root: THREE.Object3D,
88
+ phase: number,
89
+ connectorPhases: ReadonlyMap<string, number> = new Map(),
90
+ transversePhase = 0,
91
+ connectorTransversePhases: ReadonlyMap<string, number> = new Map(),
92
+ ): void {
93
+ root.traverse(object => {
94
+ if (!(object instanceof THREE.Mesh)) return
95
+ const waterPhaseSetter = object.userData.setWaterConnectionPhase
96
+ if (typeof waterPhaseSetter === 'function') (waterPhaseSetter as (phase: number) => void)(phase)
97
+ const geometry = object.geometry
98
+ const position = geometry.getAttribute('position')
99
+ if (!position || !geometry.getAttribute('themeNetUv')) return
100
+ const localAuthoredUv = geometry.userData.themeMaterialPhaseMode === 'local'
101
+ const connectorId = geometry.userData.themeMaterialConnectionConnectorId
102
+ const basePhase = localAuthoredUv
103
+ ? 0
104
+ : typeof connectorId === 'string'
105
+ ? (connectorPhases.get(connectorId) ?? phase)
106
+ : phase
107
+ const vertexPhases = new Float32Array(position.count).fill(basePhase)
108
+ const baseTransversePhase = localAuthoredUv
109
+ ? 0
110
+ : typeof connectorId === 'string'
111
+ ? (connectorTransversePhases.get(connectorId) ?? transversePhase)
112
+ : transversePhase
113
+ const vertexTransversePhases = new Float32Array(position.count).fill(baseTransversePhase)
114
+ const connectorRanges = geometry.userData.themeMaterialConnectionVertexRanges
115
+ if (!localAuthoredUv && Array.isArray(connectorRanges)) {
116
+ for (const range of connectorRanges) {
117
+ if (
118
+ typeof range !== 'object' ||
119
+ range === null ||
120
+ typeof range.connectorId !== 'string' ||
121
+ typeof range.start !== 'number' ||
122
+ typeof range.end !== 'number'
123
+ ) {
124
+ continue
125
+ }
126
+ const rangePhase = connectorPhases.get(range.connectorId) ?? basePhase
127
+ const rangeTransversePhase = connectorTransversePhases.get(range.connectorId) ?? baseTransversePhase
128
+ vertexPhases.fill(rangePhase, Math.max(0, range.start), Math.min(position.count, range.end))
129
+ vertexTransversePhases.fill(rangeTransversePhase, Math.max(0, range.start), Math.min(position.count, range.end))
130
+ }
131
+ }
132
+ geometry.setAttribute('themeMaterialPhase', new THREE.BufferAttribute(vertexPhases, 1))
133
+ // The diagnostic name describes the same values as the shader channel.
134
+ // Keep one buffer; phase edits replace both attributes together above.
135
+ geometry.setAttribute('themeConnectionPhase', geometry.getAttribute('themeMaterialPhase'))
136
+ geometry.setAttribute('themeMaterialTransversePhase', new THREE.BufferAttribute(vertexTransversePhases, 1))
137
+ const materialAxis = geometry.userData.themeMaterialLongitudinalAxis === 'x' ? 0 : 1
138
+ geometry.setAttribute(
139
+ 'themeMaterialLongitudinalAxis',
140
+ new THREE.BufferAttribute(new Float32Array(position.count).fill(materialAxis), 1),
141
+ )
142
+ })
143
+ }
144
+
145
+ export function connectedTrackMaterialPhases(
146
+ pieceIds: readonly string[],
147
+ connections: readonly {
148
+ from: { pieceId: string; connectorId?: string }
149
+ to: { pieceId: string; connectorId?: string }
150
+ }[],
151
+ metricLengths: ReadonlyMap<string, number>,
152
+ rootPhases: ReadonlyMap<string, number> = new Map(),
153
+ preferredInputConnectorIds: ReadonlyMap<string, string> = new Map(),
154
+ connectorCoordinates: ReadonlyMap<string, ReadonlyMap<string, number>> = new Map(),
155
+ ): Map<string, number> {
156
+ const phases = new Map<string, number>()
157
+ const outgoing = new Map<
158
+ string,
159
+ Array<{ targetId: string; sourceConnectorId?: string; targetConnectorId?: string }>
160
+ >()
161
+ const incomingCount = new Map(pieceIds.map(pieceId => [pieceId, 0]))
162
+ const remainingIncoming = new Map(pieceIds.map(pieceId => [pieceId, 0]))
163
+ const incomingCandidates = new Map<
164
+ string,
165
+ Array<{ connectorId?: string; sourceId: string; phase: number }>
166
+ >()
167
+
168
+ for (const connection of connections) {
169
+ if (!incomingCount.has(connection.from.pieceId) || !incomingCount.has(connection.to.pieceId)) continue
170
+ const targets = outgoing.get(connection.from.pieceId) ?? []
171
+ targets.push({
172
+ targetId: connection.to.pieceId,
173
+ sourceConnectorId: connection.from.connectorId,
174
+ targetConnectorId: connection.to.connectorId,
175
+ })
176
+ outgoing.set(connection.from.pieceId, targets)
177
+ incomingCount.set(connection.to.pieceId, (incomingCount.get(connection.to.pieceId) ?? 0) + 1)
178
+ remainingIncoming.set(connection.to.pieceId, (remainingIncoming.get(connection.to.pieceId) ?? 0) + 1)
179
+ }
180
+ for (const targets of outgoing.values()) {
181
+ targets.sort((left, right) =>
182
+ `${left.targetId}:${left.targetConnectorId ?? ''}`.localeCompare(
183
+ `${right.targetId}:${right.targetConnectorId ?? ''}`,
184
+ ),
185
+ )
186
+ }
187
+ // Track Lab serialization already owns a canonical source-piece phase.
188
+ // Runtime may split that source into auxiliary rail definitions whose
189
+ // inferred graph is intentionally lossy; never overwrite an explicit phase
190
+ // merely because the rendered graph has a different root or identifier.
191
+ for (const [pieceId, phase] of rootPhases) {
192
+ if (incomingCount.has(pieceId) && Number.isFinite(phase)) phases.set(pieceId, phase)
193
+ }
194
+
195
+ const queue = pieceIds.filter(pieceId => (incomingCount.get(pieceId) ?? 0) === 0).sort()
196
+ const enqueueComponent = (pieceId: string) => {
197
+ if (!phases.has(pieceId)) phases.set(pieceId, rootPhases.get(pieceId) ?? 0)
198
+ queue.push(pieceId)
199
+ }
200
+ if (queue.length === 0 && pieceIds.length > 0) enqueueComponent([...pieceIds].sort()[0]!)
201
+
202
+ const visited = new Set<string>()
203
+ while (visited.size < pieceIds.length) {
204
+ while (queue.length > 0) {
205
+ const pieceId = queue.shift()!
206
+ if (visited.has(pieceId)) continue
207
+ visited.add(pieceId)
208
+ if (!phases.has(pieceId)) phases.set(pieceId, 0)
209
+ for (const edge of outgoing.get(pieceId) ?? []) {
210
+ const sourceCoordinate = edge.sourceConnectorId
211
+ ? connectorCoordinates.get(pieceId)?.get(edge.sourceConnectorId)
212
+ : undefined
213
+ const targetCoordinate = edge.targetConnectorId
214
+ ? connectorCoordinates.get(edge.targetId)?.get(edge.targetConnectorId)
215
+ : undefined
216
+ const edgePhase =
217
+ (phases.get(pieceId) ?? 0) +
218
+ (sourceCoordinate ?? metricLengths.get(pieceId) ?? 0) -
219
+ (targetCoordinate ?? 0)
220
+ const candidates = incomingCandidates.get(edge.targetId) ?? []
221
+ candidates.push({
222
+ connectorId: edge.targetConnectorId,
223
+ sourceId: pieceId,
224
+ phase: edgePhase,
225
+ })
226
+ incomingCandidates.set(edge.targetId, candidates)
227
+ const remaining = Math.max(0, (remainingIncoming.get(edge.targetId) ?? 1) - 1)
228
+ remainingIncoming.set(edge.targetId, remaining)
229
+ if (remaining > 0) continue
230
+ if (!phases.has(edge.targetId)) {
231
+ const preferredConnectorId = preferredInputConnectorIds.get(edge.targetId)
232
+ const selected =
233
+ candidates.find(candidate => candidate.connectorId === preferredConnectorId) ??
234
+ [...candidates].sort((left, right) =>
235
+ `${left.sourceId}:${left.connectorId ?? ''}`.localeCompare(`${right.sourceId}:${right.connectorId ?? ''}`),
236
+ )[0]
237
+ phases.set(edge.targetId, selected?.phase ?? 0)
238
+ }
239
+ queue.push(edge.targetId)
240
+ }
241
+ }
242
+ const next = [...pieceIds].sort().find(pieceId => !visited.has(pieceId))
243
+ if (next) enqueueComponent(next)
244
+ }
245
+ return phases
246
+ }
247
+
248
+ export function applyTrackDocumentMaterialContinuity(
249
+ document: Pick<TrackDocument, 'pieces' | 'connections'>,
250
+ visuals: ReadonlyMap<string, MaterialVisual>,
251
+ ): Map<string, number> {
252
+ return applyTrackMaterialContinuity(
253
+ document.pieces.map(piece => piece.id),
254
+ document.connections,
255
+ visuals,
256
+ )
257
+ }
258
+
259
+ /**
260
+ * Applies one connected material schedule to editor or runtime visuals.
261
+ * Architectural runs keep independent path-length phases (for example, the
262
+ * inner and outer walls of a curve) without changing the authored track,
263
+ * connector, floor, or collider geometry.
264
+ */
265
+ export interface TrackMaterialContinuityPlan {
266
+ readonly phases: Map<string, number>
267
+ applyPiece(pieceId: string): void
268
+ }
269
+
270
+ export function createTrackMaterialContinuityPlan(
271
+ pieceIds: readonly string[],
272
+ connections: readonly {
273
+ from: { pieceId: string; connectorId?: string }
274
+ to: { pieceId: string; connectorId?: string }
275
+ }[],
276
+ visuals: ReadonlyMap<string, MaterialVisual>,
277
+ rootPhases: ReadonlyMap<string, number> = new Map(),
278
+ ): TrackMaterialContinuityPlan {
279
+ const lengths = new Map<string, number>()
280
+ const preferredInputConnectorIds = new Map<string, string>()
281
+ const connectorFrames = new Map<string, Map<string, TrackMaterialConnectorFrame>>()
282
+ for (const pieceId of pieceIds) {
283
+ const visual = visuals.get(pieceId)
284
+ if (visual) {
285
+ lengths.set(pieceId, Math.max(0, ...visualRoots(visual).map(root => trackMaterialMetricLength(root))))
286
+ for (const root of visualRoots(visual)) {
287
+ const connectorId = trackMaterialPreferredInputConnector(root)
288
+ if (connectorId) preferredInputConnectorIds.set(pieceId, connectorId)
289
+ const frames = connectorFrames.get(pieceId) ?? new Map<string, TrackMaterialConnectorFrame>()
290
+ for (const [frameConnectorId, frame] of trackMaterialConnectorFrames(root)) {
291
+ frames.set(frameConnectorId, frame)
292
+ }
293
+ if (frames.size > 0) connectorFrames.set(pieceId, frames)
294
+ }
295
+ }
296
+ }
297
+ const phases = connectedTrackMaterialPhases(
298
+ pieceIds,
299
+ connections,
300
+ lengths,
301
+ rootPhases,
302
+ preferredInputConnectorIds,
303
+ new Map(
304
+ [...connectorFrames].map(([pieceId, frames]) => [
305
+ pieceId,
306
+ new Map([...frames].map(([connectorId, frame]) => [connectorId, frame.longitudinal])),
307
+ ]),
308
+ ),
309
+ )
310
+ const incomingConnectorPhases = new Map<string, Map<string, number>>()
311
+ const zeroLengths = new Map(pieceIds.map(pieceId => [pieceId, 0]))
312
+ const transversePhases = connectedTrackMaterialPhases(
313
+ pieceIds,
314
+ connections,
315
+ zeroLengths,
316
+ new Map(),
317
+ preferredInputConnectorIds,
318
+ new Map(
319
+ [...connectorFrames].map(([pieceId, frames]) => [
320
+ pieceId,
321
+ new Map([...frames].map(([connectorId, frame]) => [connectorId, frame.transverse])),
322
+ ]),
323
+ ),
324
+ )
325
+ const incomingConnectorTransversePhases = new Map<string, Map<string, number>>()
326
+ for (const connection of connections) {
327
+ const connectorId = connection.to.connectorId
328
+ if (!connectorId) continue
329
+ const sourceFrame = connection.from.connectorId
330
+ ? connectorFrames.get(connection.from.pieceId)?.get(connection.from.connectorId)
331
+ : undefined
332
+ const targetFrame = connectorFrames.get(connection.to.pieceId)?.get(connectorId)
333
+ const phase =
334
+ (phases.get(connection.from.pieceId) ?? 0) +
335
+ (sourceFrame?.longitudinal ?? lengths.get(connection.from.pieceId) ?? 0) -
336
+ (targetFrame?.longitudinal ?? 0)
337
+ const connectorPhases = incomingConnectorPhases.get(connection.to.pieceId) ?? new Map<string, number>()
338
+ connectorPhases.set(connectorId, phase)
339
+ incomingConnectorPhases.set(connection.to.pieceId, connectorPhases)
340
+ const sourceTransverseCoordinate = connection.from.connectorId
341
+ ? (connectorFrames.get(connection.from.pieceId)?.get(connection.from.connectorId)?.transverse ?? 0)
342
+ : 0
343
+ const targetTransverseCoordinate = targetFrame?.transverse ?? 0
344
+ const transverseConnectorPhases =
345
+ incomingConnectorTransversePhases.get(connection.to.pieceId) ?? new Map<string, number>()
346
+ transverseConnectorPhases.set(
347
+ connectorId,
348
+ (transversePhases.get(connection.from.pieceId) ?? 0) +
349
+ sourceTransverseCoordinate -
350
+ targetTransverseCoordinate,
351
+ )
352
+ incomingConnectorTransversePhases.set(connection.to.pieceId, transverseConnectorPhases)
353
+ }
354
+ const architectureLengths = new Map<string, number[]>()
355
+ let architectureRunCount = 0
356
+ for (const pieceId of pieceIds) {
357
+ const visual = visuals.get(pieceId)
358
+ if (!visual) continue
359
+ const runLengths: number[] = []
360
+ for (const root of visualRoots(visual)) {
361
+ trackArchitectureMetricLengths(root).forEach((length, index) => {
362
+ runLengths[index] = Math.max(runLengths[index] ?? 0, length)
363
+ })
364
+ }
365
+ architectureLengths.set(pieceId, runLengths)
366
+ architectureRunCount = Math.max(architectureRunCount, runLengths.length)
367
+ }
368
+ const architecturePhases = Array.from({ length: architectureRunCount }, (_unused, runIndex) =>
369
+ connectedTrackMaterialPhases(
370
+ pieceIds,
371
+ connections,
372
+ new Map(
373
+ pieceIds.map(pieceId => [
374
+ pieceId,
375
+ architectureLengths.get(pieceId)?.[runIndex] ?? lengths.get(pieceId) ?? 0,
376
+ ]),
377
+ ),
378
+ rootPhases,
379
+ preferredInputConnectorIds,
380
+ ),
381
+ )
382
+ const applyPiece = (pieceId: string): void => {
383
+ const phase = phases.get(pieceId)
384
+ if (phase === undefined) return
385
+ const visual = visuals.get(pieceId)
386
+ if (!visual) return
387
+ const supportPhaseSetters: ((phase: number) => void)[] = []
388
+ const floorPhaseSetters: ((phase: number) => void)[] = []
389
+ const architecturePhaseSetters: ((phase: number | readonly number[]) => void)[] = []
390
+ for (const root of visualRoots(visual)) {
391
+ root.traverse(object => {
392
+ const setter = object.userData.setSupportConnectionPhase
393
+ if (typeof setter === 'function') supportPhaseSetters.push(setter as (phase: number) => void)
394
+ const floorSetter = object.userData.setFloorConnectionPhase
395
+ if (typeof floorSetter === 'function') {
396
+ const connectorId = object.userData.floorConnectionConnectorId
397
+ const connectorPhase =
398
+ typeof connectorId === 'string'
399
+ ? incomingConnectorPhases.get(pieceId)?.get(connectorId)
400
+ : undefined
401
+ floorPhaseSetters.push(() =>
402
+ (floorSetter as (phase: number) => void)(connectorPhase ?? phase),
403
+ )
404
+ }
405
+ const architectureSetter = object.userData.setMaterialConnectionPhase
406
+ if (typeof architectureSetter === 'function') {
407
+ architecturePhaseSetters.push(architectureSetter as (phase: number | readonly number[]) => void)
408
+ }
409
+ })
410
+ }
411
+ const runPhases = architecturePhases.map(runPhasesByPiece => runPhasesByPiece.get(pieceId) ?? phase)
412
+ architecturePhaseSetters.forEach(setter => setter(runPhases.length > 0 ? runPhases : phase))
413
+ floorPhaseSetters.forEach(setter => setter(phase))
414
+ supportPhaseSetters.forEach(setter => setter(phase))
415
+ const connectorPhases = incomingConnectorPhases.get(pieceId)
416
+ const connectorTransversePhases = incomingConnectorTransversePhases.get(pieceId)
417
+ visualRoots(visual).forEach(root =>
418
+ setTrackMaterialConnectionPhase(
419
+ root,
420
+ phase,
421
+ connectorPhases,
422
+ transversePhases.get(pieceId) ?? 0,
423
+ connectorTransversePhases,
424
+ ),
425
+ )
426
+ }
427
+ return { phases, applyPiece }
428
+ }
429
+
430
+ export function applyTrackMaterialContinuity(
431
+ pieceIds: readonly string[],
432
+ connections: readonly {
433
+ from: { pieceId: string; connectorId?: string }
434
+ to: { pieceId: string; connectorId?: string }
435
+ }[],
436
+ visuals: ReadonlyMap<string, MaterialVisual>,
437
+ rootPhases: ReadonlyMap<string, number> = new Map(),
438
+ ): Map<string, number> {
439
+ const plan = createTrackMaterialContinuityPlan(pieceIds, connections, visuals, rootPhases)
440
+ pieceIds.forEach(plan.applyPiece)
441
+ return plan.phases
442
+ }