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