@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,572 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
export interface WildWestVortexHousingOptions {
|
|
4
|
+
pieceId: string
|
|
5
|
+
center: THREE.Vector3
|
|
6
|
+
outerRadius: number
|
|
7
|
+
floorOuterRadius?: number
|
|
8
|
+
innerRadius: number
|
|
9
|
+
segmentCount: number
|
|
10
|
+
surfaceHeightAtRadius(radius: number): number
|
|
11
|
+
drainTopY: number
|
|
12
|
+
shellMaterial: THREE.Material
|
|
13
|
+
floorPerimeterTreatments?: readonly {
|
|
14
|
+
material: THREE.Material
|
|
15
|
+
materialId: string
|
|
16
|
+
themeId: string | readonly string[]
|
|
17
|
+
}[]
|
|
18
|
+
shellDrop?: number
|
|
19
|
+
inletShell?: {
|
|
20
|
+
vertices: readonly number[]
|
|
21
|
+
indices: readonly number[]
|
|
22
|
+
fasciaIndices?: readonly number[]
|
|
23
|
+
seam: readonly {
|
|
24
|
+
vertexIndex: number
|
|
25
|
+
angle: number
|
|
26
|
+
}[]
|
|
27
|
+
}
|
|
28
|
+
rimOpening?: {
|
|
29
|
+
entryAngle: number
|
|
30
|
+
orbitDirection: -1 | 1
|
|
31
|
+
sweep: number
|
|
32
|
+
}
|
|
33
|
+
outletTunnel?: {
|
|
34
|
+
curve: THREE.Curve<THREE.Vector3>
|
|
35
|
+
radius: number
|
|
36
|
+
innerRadius: number
|
|
37
|
+
}
|
|
38
|
+
innerFunnel?: {
|
|
39
|
+
rings: readonly {
|
|
40
|
+
radius: number
|
|
41
|
+
height: number
|
|
42
|
+
centerX: number
|
|
43
|
+
centerZ: number
|
|
44
|
+
}[]
|
|
45
|
+
wallThickness: number
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function markDecorative(object: THREE.Object3D): void {
|
|
50
|
+
object.userData.decorative = true
|
|
51
|
+
object.userData.visualOnly = true
|
|
52
|
+
object.castShadow = true
|
|
53
|
+
object.receiveShadow = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Adds the removable Wild West skin around the existing vortex. The housing
|
|
58
|
+
* deliberately accepts no Rapier world or collider bookkeeping: it dresses
|
|
59
|
+
* the bowl without becoming another physical surface.
|
|
60
|
+
*/
|
|
61
|
+
export function addWildWestVortexHousing(root: THREE.Object3D, options: WildWestVortexHousingOptions): THREE.Group {
|
|
62
|
+
const group = new THREE.Group()
|
|
63
|
+
group.name = `${options.pieceId}-wild-west-vortex-housing`
|
|
64
|
+
group.userData.decorative = true
|
|
65
|
+
group.userData.visualOnly = true
|
|
66
|
+
|
|
67
|
+
const segments = Math.max(24, Math.round(options.segmentCount))
|
|
68
|
+
const radialBands = 4
|
|
69
|
+
const outerRadius = Math.max(options.innerRadius + 0.8, options.outerRadius)
|
|
70
|
+
const floorOuterRadius = THREE.MathUtils.clamp(options.floorOuterRadius ?? outerRadius, options.innerRadius, outerRadius)
|
|
71
|
+
const outlet = options.outletTunnel
|
|
72
|
+
const outletRadius = Math.max(0.18, outlet?.radius ?? options.innerRadius)
|
|
73
|
+
const funnelRings = options.innerFunnel?.rings
|
|
74
|
+
const funnelMouth = funnelRings?.at(-1)
|
|
75
|
+
const funnelWallThickness = Math.max(0.02, options.innerFunnel?.wallThickness ?? 0.2)
|
|
76
|
+
const collarRadius = funnelMouth
|
|
77
|
+
? funnelMouth.radius + funnelWallThickness
|
|
78
|
+
: outlet
|
|
79
|
+
? outletRadius
|
|
80
|
+
: Math.min(outerRadius - 0.55, options.innerRadius + 0.34)
|
|
81
|
+
const shellDrop = options.shellDrop ?? 0.3
|
|
82
|
+
const shellHeightAtRadius = (radius: number): number => options.surfaceHeightAtRadius(radius) - shellDrop
|
|
83
|
+
const outerHeight = options.surfaceHeightAtRadius(outerRadius)
|
|
84
|
+
const outerShellHeight = shellHeightAtRadius(outerRadius)
|
|
85
|
+
const shellInnerY = shellHeightAtRadius(collarRadius)
|
|
86
|
+
const outletInnerRadius = Math.min(
|
|
87
|
+
outletRadius,
|
|
88
|
+
Math.max(0.12, outlet?.innerRadius ?? outletRadius - 0.2),
|
|
89
|
+
)
|
|
90
|
+
const outletStart = outlet?.curve.getPointAt(0) ?? options.center.clone().setY(options.drainTopY)
|
|
91
|
+
const outletIsLinear = outlet
|
|
92
|
+
? [0.25, 0.5, 0.75].every(progress =>
|
|
93
|
+
outlet.curve
|
|
94
|
+
.getPointAt(progress)
|
|
95
|
+
.distanceToSquared(outlet.curve.getPointAt(0).lerp(outlet.curve.getPointAt(1), progress)) <= 0.000_000_01,
|
|
96
|
+
)
|
|
97
|
+
: false
|
|
98
|
+
// Atlas outlets are straight vertical tubes. Subdividing the same line into
|
|
99
|
+
// 32 exterior bands and another 32 bore bands creates thousands of
|
|
100
|
+
// collinear triangles without changing silhouette, normals, or linearly
|
|
101
|
+
// interpolated UVs. Preserve dense sampling automatically for any future
|
|
102
|
+
// genuinely curved outlet.
|
|
103
|
+
const tubeBands = outletIsLinear ? 1 : 32
|
|
104
|
+
const neckBands = 3
|
|
105
|
+
const neckControlY = THREE.MathUtils.lerp(shellInnerY, outletStart.y, 0.68)
|
|
106
|
+
const profilePoint = (progress: number): { radius: number; height: number } => {
|
|
107
|
+
const inverse = 1 - progress
|
|
108
|
+
return {
|
|
109
|
+
radius:
|
|
110
|
+
inverse ** 3 * collarRadius +
|
|
111
|
+
3 * inverse * inverse * progress * (collarRadius - (collarRadius - outletRadius) * 0.52) +
|
|
112
|
+
3 * inverse * progress * progress * outletRadius +
|
|
113
|
+
progress ** 3 * outletRadius,
|
|
114
|
+
height:
|
|
115
|
+
inverse ** 3 * shellInnerY +
|
|
116
|
+
3 * inverse * inverse * progress * (shellInnerY - 0.16) +
|
|
117
|
+
3 * inverse * progress * progress * neckControlY +
|
|
118
|
+
progress ** 3 * outletStart.y,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface HousingRing {
|
|
123
|
+
center: THREE.Vector3
|
|
124
|
+
radius: number
|
|
125
|
+
normal: THREE.Vector3
|
|
126
|
+
binormal: THREE.Vector3
|
|
127
|
+
}
|
|
128
|
+
const rings: HousingRing[] = []
|
|
129
|
+
const appendHorizontalRing = (radius: number, height: number): number => {
|
|
130
|
+
const ringIndex = rings.length
|
|
131
|
+
rings.push({
|
|
132
|
+
center: new THREE.Vector3(options.center.x, height, options.center.z),
|
|
133
|
+
radius,
|
|
134
|
+
normal: new THREE.Vector3(1, 0, 0),
|
|
135
|
+
binormal: new THREE.Vector3(0, 0, 1),
|
|
136
|
+
})
|
|
137
|
+
return ringIndex
|
|
138
|
+
}
|
|
139
|
+
const appendProfileRing = (
|
|
140
|
+
profile: { radius: number; height: number; centerX: number; centerZ: number },
|
|
141
|
+
radiusOffset = 0,
|
|
142
|
+
heightOffset = 0,
|
|
143
|
+
): void => {
|
|
144
|
+
rings.push({
|
|
145
|
+
center: new THREE.Vector3(profile.centerX, profile.height + heightOffset, profile.centerZ),
|
|
146
|
+
radius: profile.radius + radiusOffset,
|
|
147
|
+
normal: new THREE.Vector3(1, 0, 0),
|
|
148
|
+
binormal: new THREE.Vector3(0, 0, 1),
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Trace the complete exposed cross-section as one surface of revolution:
|
|
153
|
+
// bowl underside -> funnel exterior -> pipe exterior -> open terminal lip
|
|
154
|
+
// -> pipe interior -> funnel interior. There is no second render mesh and
|
|
155
|
+
// no coincident surface for the depth buffer to reveal.
|
|
156
|
+
// Meet the guard at its actual base plane. Extending the fascia upward
|
|
157
|
+
// behind block-built wall styles exposes a purple toothed band through the
|
|
158
|
+
// course gaps in Material Slot Debug and at grazing angles.
|
|
159
|
+
appendHorizontalRing(outerRadius, outerHeight - 0.03)
|
|
160
|
+
const outerShellRingIndex = appendHorizontalRing(outerRadius, outerShellHeight)
|
|
161
|
+
const floorOuterRingIndex =
|
|
162
|
+
floorOuterRadius < outerRadius - 0.000_001
|
|
163
|
+
? appendHorizontalRing(floorOuterRadius, shellHeightAtRadius(floorOuterRadius))
|
|
164
|
+
: outerShellRingIndex
|
|
165
|
+
for (let band = 1; band <= radialBands; band += 1) {
|
|
166
|
+
const progress = band / radialBands
|
|
167
|
+
const radius = THREE.MathUtils.lerp(
|
|
168
|
+
floorOuterRadius,
|
|
169
|
+
collarRadius,
|
|
170
|
+
THREE.MathUtils.smoothstep(progress, 0, 1),
|
|
171
|
+
)
|
|
172
|
+
appendHorizontalRing(radius, shellHeightAtRadius(radius))
|
|
173
|
+
}
|
|
174
|
+
if (outlet && funnelRings && funnelRings.length >= 2) {
|
|
175
|
+
const mouth = funnelRings.at(-1)!
|
|
176
|
+
appendProfileRing(mouth, funnelWallThickness, -funnelWallThickness)
|
|
177
|
+
for (let index = funnelRings.length - 2; index >= 0; index -= 1) {
|
|
178
|
+
const profile = funnelRings[index]!
|
|
179
|
+
const progress = index / (funnelRings.length - 1)
|
|
180
|
+
appendProfileRing(profile, funnelWallThickness, -funnelWallThickness * progress)
|
|
181
|
+
}
|
|
182
|
+
// The outer throat and first pipe ring are identical. Start at band one
|
|
183
|
+
// so the entire pipe exterior has exactly one topological owner.
|
|
184
|
+
for (let band = 1; band <= tubeBands; band += 1) {
|
|
185
|
+
const progress = band / tubeBands
|
|
186
|
+
rings.push({
|
|
187
|
+
center: outlet.curve.getPointAt(progress),
|
|
188
|
+
radius: outletRadius,
|
|
189
|
+
normal: new THREE.Vector3(1, 0, 0),
|
|
190
|
+
binormal: new THREE.Vector3(0, 0, 1),
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
// One annular terminal face closes the wall thickness without capping the
|
|
194
|
+
// bore. Then walk the same axis back upward at the inner radius.
|
|
195
|
+
rings.push({
|
|
196
|
+
center: outlet.curve.getPointAt(1),
|
|
197
|
+
radius: outletInnerRadius,
|
|
198
|
+
normal: new THREE.Vector3(1, 0, 0),
|
|
199
|
+
binormal: new THREE.Vector3(0, 0, 1),
|
|
200
|
+
})
|
|
201
|
+
for (let band = tubeBands - 1; band >= 0; band -= 1) {
|
|
202
|
+
const progress = band / tubeBands
|
|
203
|
+
rings.push({
|
|
204
|
+
center: outlet.curve.getPointAt(progress),
|
|
205
|
+
radius: outletInnerRadius,
|
|
206
|
+
normal: new THREE.Vector3(1, 0, 0),
|
|
207
|
+
binormal: new THREE.Vector3(0, 0, 1),
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
for (let index = 1; index < funnelRings.length; index += 1) {
|
|
211
|
+
const profile = funnelRings[index]!
|
|
212
|
+
appendProfileRing(profile)
|
|
213
|
+
}
|
|
214
|
+
} else if (outlet) {
|
|
215
|
+
const neckStart = rings.at(-1)!
|
|
216
|
+
for (let band = 1; band <= neckBands; band += 1) {
|
|
217
|
+
const progress = band / neckBands
|
|
218
|
+
rings.push({
|
|
219
|
+
center: neckStart.center.clone().lerp(outletStart, progress),
|
|
220
|
+
radius: THREE.MathUtils.lerp(neckStart.radius, outletRadius, progress),
|
|
221
|
+
normal: new THREE.Vector3(1, 0, 0),
|
|
222
|
+
binormal: new THREE.Vector3(0, 0, 1),
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
const profile = profilePoint(1)
|
|
227
|
+
appendHorizontalRing(profile.radius, profile.height)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const shellVertices: number[] = []
|
|
231
|
+
const shellUvs: number[] = []
|
|
232
|
+
const shellIndices: number[] = []
|
|
233
|
+
const outerFasciaIndices: number[] = []
|
|
234
|
+
const trimmedFasciaPanels = [0, 0]
|
|
235
|
+
const turn = Math.PI * 2
|
|
236
|
+
const normalizeAngle = (angle: number): number => ((angle % turn) + turn) % turn
|
|
237
|
+
const openingBoundaryAngles = options.rimOpening
|
|
238
|
+
? [
|
|
239
|
+
normalizeAngle(options.rimOpening.entryAngle),
|
|
240
|
+
normalizeAngle(
|
|
241
|
+
options.rimOpening.entryAngle +
|
|
242
|
+
options.rimOpening.orbitDirection * (turn - options.rimOpening.sweep),
|
|
243
|
+
),
|
|
244
|
+
]
|
|
245
|
+
: []
|
|
246
|
+
// A skipped regular panel only approximates the inlet cut and can leave up
|
|
247
|
+
// to half a segment of purple housing outside the exact yellow wall plane.
|
|
248
|
+
// Insert both analytic opening boundaries into every ring so the fascia,
|
|
249
|
+
// shoulder, bowl, funnel, and tube retain one compatible indexed topology.
|
|
250
|
+
const angularSamples = [
|
|
251
|
+
...Array.from({ length: segments }, (_, segment) => (segment / segments) * turn),
|
|
252
|
+
...openingBoundaryAngles,
|
|
253
|
+
...(options.inletShell?.seam.map(vertex => normalizeAngle(vertex.angle)) ?? []),
|
|
254
|
+
]
|
|
255
|
+
.sort((left, right) => left - right)
|
|
256
|
+
.filter((angle, index, angles) => index === 0 || Math.abs(angle - angles[index - 1]!) > 0.000_001)
|
|
257
|
+
const ringAngles = [...angularSamples, angularSamples[0]! + turn]
|
|
258
|
+
const rowLength = ringAngles.length
|
|
259
|
+
const angularPanelCount = rowLength - 1
|
|
260
|
+
let longitudinal = 0
|
|
261
|
+
rings.forEach((ring, ringIndex) => {
|
|
262
|
+
if (ringIndex > 0) {
|
|
263
|
+
const previous = rings[ringIndex - 1]!
|
|
264
|
+
longitudinal += previous.center.distanceTo(ring.center) + Math.abs(previous.radius - ring.radius)
|
|
265
|
+
}
|
|
266
|
+
for (const angle of ringAngles) {
|
|
267
|
+
const angleProgress = angle / turn
|
|
268
|
+
const radial = ring.normal
|
|
269
|
+
.clone()
|
|
270
|
+
.multiplyScalar(Math.cos(angle))
|
|
271
|
+
.addScaledVector(ring.binormal, Math.sin(angle))
|
|
272
|
+
const position = ring.center.clone().addScaledVector(radial, ring.radius)
|
|
273
|
+
shellVertices.push(position.x, position.y, position.z)
|
|
274
|
+
shellUvs.push(angleProgress * Math.PI * 2 * ring.radius, longitudinal)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
for (let ring = 0; ring < rings.length - 1; ring += 1) {
|
|
278
|
+
for (let segment = 0; segment < angularPanelCount; segment += 1) {
|
|
279
|
+
// The integrated inlet owns this part of the outer fascia. Leaving the
|
|
280
|
+
// complete underside ring here exposes it as a dark circular seam below
|
|
281
|
+
// the inlet apron, even though the two top surfaces share vertices.
|
|
282
|
+
// The first two bands form the complete outer fascia: the vertical lip
|
|
283
|
+
// at the guard's outer face and the horizontal shoulder back to the
|
|
284
|
+
// traversable floor radius. Both must stop at the inlet opening. Cutting
|
|
285
|
+
// only the first band leaves the shoulder running through the opening as
|
|
286
|
+
// a long purple strip with a protruding corner beside the straight wall.
|
|
287
|
+
// Trim only the outer fascia bands before the shared floor-radius ring.
|
|
288
|
+
// When the housing is intentionally inset, the outer shell and floor
|
|
289
|
+
// ring are the same ring; trimming the next band would cut the actual
|
|
290
|
+
// bowl underside away from the inlet seam.
|
|
291
|
+
if (ring < floorOuterRingIndex && options.rimOpening) {
|
|
292
|
+
const midpointAngle = (ringAngles[segment]! + ringAngles[segment + 1]!) / 2
|
|
293
|
+
let directed = Math.atan2(
|
|
294
|
+
Math.sin(midpointAngle - options.rimOpening.entryAngle),
|
|
295
|
+
Math.cos(midpointAngle - options.rimOpening.entryAngle),
|
|
296
|
+
)
|
|
297
|
+
if (options.rimOpening.orbitDirection > 0 && directed < 0) directed += Math.PI * 2
|
|
298
|
+
if (options.rimOpening.orbitDirection < 0 && directed > 0) directed -= Math.PI * 2
|
|
299
|
+
const wallArcSweep = Math.PI * 2 - options.rimOpening.sweep
|
|
300
|
+
if (Math.abs(directed) > wallArcSweep) {
|
|
301
|
+
trimmedFasciaPanels[ring]! += 1
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const current = ring * rowLength + segment
|
|
306
|
+
const next = current + rowLength
|
|
307
|
+
shellIndices.push(current, current + 1, next, current + 1, next + 1, next)
|
|
308
|
+
if (ring === 0) {
|
|
309
|
+
outerFasciaIndices.push(current, current + 1, next, current + 1, next + 1, next)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Cutting the two outer fascia bands creates an exposed triangular
|
|
315
|
+
// cross-section at each side of the inlet. Close those exact analytic cut
|
|
316
|
+
// planes so the visible purple edge remains one thin floor profile beneath
|
|
317
|
+
// the yellow wall. Without these faces the deeper inlet side closure is
|
|
318
|
+
// visible through the open end and reads as a swollen purple wedge.
|
|
319
|
+
let inletFasciaTerminalCount = 0
|
|
320
|
+
if (options.rimOpening && floorOuterRingIndex !== outerShellRingIndex) {
|
|
321
|
+
const openingMidpointAngle = normalizeAngle(
|
|
322
|
+
options.rimOpening.entryAngle - options.rimOpening.orbitDirection * options.rimOpening.sweep / 2,
|
|
323
|
+
)
|
|
324
|
+
for (const boundaryAngle of openingBoundaryAngles) {
|
|
325
|
+
const angleIndex = ringAngles
|
|
326
|
+
.slice(0, angularPanelCount)
|
|
327
|
+
.findIndex(
|
|
328
|
+
angle =>
|
|
329
|
+
Math.abs(Math.atan2(Math.sin(angle - boundaryAngle), Math.cos(angle - boundaryAngle))) <
|
|
330
|
+
0.000_001,
|
|
331
|
+
)
|
|
332
|
+
if (angleIndex < 0) throw new Error(`Missing Vortex fascia terminal angle ${boundaryAngle}`)
|
|
333
|
+
const outerTop = angleIndex
|
|
334
|
+
const outerBottom = outerShellRingIndex * rowLength + angleIndex
|
|
335
|
+
const floorBottom = floorOuterRingIndex * rowLength + angleIndex
|
|
336
|
+
const towardOpening = Math.sign(
|
|
337
|
+
Math.atan2(
|
|
338
|
+
Math.sin(openingMidpointAngle - boundaryAngle),
|
|
339
|
+
Math.cos(openingMidpointAngle - boundaryAngle),
|
|
340
|
+
),
|
|
341
|
+
) || 1
|
|
342
|
+
const expectedNormal = new THREE.Vector3(
|
|
343
|
+
-Math.sin(boundaryAngle) * towardOpening,
|
|
344
|
+
0,
|
|
345
|
+
Math.cos(boundaryAngle) * towardOpening,
|
|
346
|
+
)
|
|
347
|
+
const pointAt = (index: number): THREE.Vector3 =>
|
|
348
|
+
new THREE.Vector3(
|
|
349
|
+
shellVertices[index * 3]!,
|
|
350
|
+
shellVertices[index * 3 + 1]!,
|
|
351
|
+
shellVertices[index * 3 + 2]!,
|
|
352
|
+
)
|
|
353
|
+
const normal = pointAt(outerBottom)
|
|
354
|
+
.sub(pointAt(outerTop))
|
|
355
|
+
.cross(pointAt(floorBottom).sub(pointAt(outerTop)))
|
|
356
|
+
if (normal.dot(expectedNormal) >= 0) {
|
|
357
|
+
shellIndices.push(outerTop, outerBottom, floorBottom)
|
|
358
|
+
} else {
|
|
359
|
+
shellIndices.push(outerTop, floorBottom, outerBottom)
|
|
360
|
+
}
|
|
361
|
+
inletFasciaTerminalCount += 1
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const inletSeamVertexIndices: number[] = []
|
|
366
|
+
let inletFasciaOverlayIndexCount = 0
|
|
367
|
+
let inletShellIndexOffset = 0
|
|
368
|
+
// The integrated inlet has the same visual ownership as the bowl
|
|
369
|
+
// underside. Its complete curved boundary must reuse the bowl's indexed
|
|
370
|
+
// apron ring. Merely appending coincident positions creates a disconnected
|
|
371
|
+
// mesh island whose independently-computed normals expose the join.
|
|
372
|
+
if (options.inletShell) {
|
|
373
|
+
inletShellIndexOffset = shellIndices.length
|
|
374
|
+
const remappedVertices = new Map<number, number>()
|
|
375
|
+
for (const seamVertex of options.inletShell.seam) {
|
|
376
|
+
const normalizedSeamAngle = normalizeAngle(seamVertex.angle)
|
|
377
|
+
const angleIndex = ringAngles
|
|
378
|
+
.slice(0, angularPanelCount)
|
|
379
|
+
.findIndex(
|
|
380
|
+
angle =>
|
|
381
|
+
Math.abs(
|
|
382
|
+
Math.atan2(
|
|
383
|
+
Math.sin(angle - normalizedSeamAngle),
|
|
384
|
+
Math.cos(angle - normalizedSeamAngle),
|
|
385
|
+
),
|
|
386
|
+
) < 0.000_001,
|
|
387
|
+
)
|
|
388
|
+
if (angleIndex < 0) {
|
|
389
|
+
throw new Error(`Missing Vortex housing seam angle ${seamVertex.angle}`)
|
|
390
|
+
}
|
|
391
|
+
const housingVertexIndex = floorOuterRingIndex * rowLength + angleIndex
|
|
392
|
+
const sourceOffset = seamVertex.vertexIndex * 3
|
|
393
|
+
const housingOffset = housingVertexIndex * 3
|
|
394
|
+
const seamDistance = Math.hypot(
|
|
395
|
+
options.inletShell.vertices[sourceOffset]! - shellVertices[housingOffset]!,
|
|
396
|
+
options.inletShell.vertices[sourceOffset + 1]! - shellVertices[housingOffset + 1]!,
|
|
397
|
+
options.inletShell.vertices[sourceOffset + 2]! - shellVertices[housingOffset + 2]!,
|
|
398
|
+
)
|
|
399
|
+
if (seamDistance > 0.000_001) {
|
|
400
|
+
throw new Error(`Vortex inlet shell misses its housing seam by ${seamDistance}`)
|
|
401
|
+
}
|
|
402
|
+
remappedVertices.set(seamVertex.vertexIndex, housingVertexIndex)
|
|
403
|
+
inletSeamVertexIndices.push(housingVertexIndex)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const sourceIndex of options.inletShell.indices) {
|
|
407
|
+
let housingVertexIndex = remappedVertices.get(sourceIndex)
|
|
408
|
+
if (housingVertexIndex === undefined) {
|
|
409
|
+
const sourceOffset = sourceIndex * 3
|
|
410
|
+
housingVertexIndex = shellVertices.length / 3
|
|
411
|
+
shellVertices.push(
|
|
412
|
+
options.inletShell.vertices[sourceOffset]!,
|
|
413
|
+
options.inletShell.vertices[sourceOffset + 1]!,
|
|
414
|
+
options.inletShell.vertices[sourceOffset + 2]!,
|
|
415
|
+
)
|
|
416
|
+
shellUvs.push(
|
|
417
|
+
options.inletShell.vertices[sourceOffset]!,
|
|
418
|
+
options.inletShell.vertices[sourceOffset + 2]!,
|
|
419
|
+
)
|
|
420
|
+
remappedVertices.set(sourceIndex, housingVertexIndex)
|
|
421
|
+
}
|
|
422
|
+
shellIndices.push(housingVertexIndex)
|
|
423
|
+
}
|
|
424
|
+
for (const sourceIndex of options.inletShell.fasciaIndices ?? []) {
|
|
425
|
+
const housingVertexIndex = remappedVertices.get(sourceIndex)
|
|
426
|
+
if (housingVertexIndex === undefined) {
|
|
427
|
+
throw new Error(`Missing remapped Vortex inlet fascia vertex ${sourceIndex}`)
|
|
428
|
+
}
|
|
429
|
+
outerFasciaIndices.push(housingVertexIndex)
|
|
430
|
+
inletFasciaOverlayIndexCount += 1
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Trimming the circular fascia deliberately leaves some generated ring
|
|
435
|
+
// vertices unreferenced. Compact the completed topology so the production
|
|
436
|
+
// geometry contains only vertices owned by a visible triangle, while
|
|
437
|
+
// preserving the inlet-to-bowl seam indices and angular wrap pairs.
|
|
438
|
+
const referencedVertexIndices = [...new Set(shellIndices)].sort((left, right) => left - right)
|
|
439
|
+
const compactVertexIndex = new Map<number, number>()
|
|
440
|
+
const compactShellVertices: number[] = []
|
|
441
|
+
const compactShellUvs: number[] = []
|
|
442
|
+
referencedVertexIndices.forEach((sourceIndex, targetIndex) => {
|
|
443
|
+
compactVertexIndex.set(sourceIndex, targetIndex)
|
|
444
|
+
const sourceOffset = sourceIndex * 3
|
|
445
|
+
compactShellVertices.push(
|
|
446
|
+
shellVertices[sourceOffset]!,
|
|
447
|
+
shellVertices[sourceOffset + 1]!,
|
|
448
|
+
shellVertices[sourceOffset + 2]!,
|
|
449
|
+
)
|
|
450
|
+
const sourceUvOffset = sourceIndex * 2
|
|
451
|
+
compactShellUvs.push(shellUvs[sourceUvOffset]!, shellUvs[sourceUvOffset + 1]!)
|
|
452
|
+
})
|
|
453
|
+
const compactShellIndices = shellIndices.map(index => compactVertexIndex.get(index)!)
|
|
454
|
+
const compactInletSeamVertexIndices = inletSeamVertexIndices.map(index => compactVertexIndex.get(index)!)
|
|
455
|
+
const compactAngularWrapPairs = rings
|
|
456
|
+
.map((_, ringIndex) => [
|
|
457
|
+
compactVertexIndex.get(ringIndex * rowLength),
|
|
458
|
+
compactVertexIndex.get(ringIndex * rowLength + angularPanelCount),
|
|
459
|
+
] as const)
|
|
460
|
+
.filter((pair): pair is readonly [number, number] => pair[0] !== undefined && pair[1] !== undefined)
|
|
461
|
+
|
|
462
|
+
const shellGeometry = new THREE.BufferGeometry()
|
|
463
|
+
shellGeometry.setAttribute('position', new THREE.Float32BufferAttribute(compactShellVertices, 3))
|
|
464
|
+
shellGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(compactShellUvs, 2))
|
|
465
|
+
shellGeometry.setIndex(compactShellIndices)
|
|
466
|
+
shellGeometry.computeVertexNormals()
|
|
467
|
+
const normals = shellGeometry.getAttribute('normal')
|
|
468
|
+
for (const [first, last] of compactAngularWrapPairs) {
|
|
469
|
+
const averaged = new THREE.Vector3(
|
|
470
|
+
normals.getX(first) + normals.getX(last),
|
|
471
|
+
normals.getY(first) + normals.getY(last),
|
|
472
|
+
normals.getZ(first) + normals.getZ(last),
|
|
473
|
+
).normalize()
|
|
474
|
+
normals.setXYZ(first, averaged.x, averaged.y, averaged.z)
|
|
475
|
+
normals.setXYZ(last, averaged.x, averaged.y, averaged.z)
|
|
476
|
+
}
|
|
477
|
+
normals.needsUpdate = true
|
|
478
|
+
const shell = new THREE.Mesh(shellGeometry, options.shellMaterial)
|
|
479
|
+
shell.name = `${options.pieceId}-wild-west-vortex-continuous-housing-shell`
|
|
480
|
+
shell.userData.panelCount = angularPanelCount * (rings.length - 1)
|
|
481
|
+
shell.userData.surfaceTopology = 'single-continuous-indexed-mesh'
|
|
482
|
+
shell.userData.continuousOutputShell = true
|
|
483
|
+
shell.userData.outletTerminalProfile = 'single-surface-funnel-and-open-pipe'
|
|
484
|
+
shell.userData.outletPipeBands = outlet ? tubeBands : 0
|
|
485
|
+
shell.userData.outletPipeLinear = outletIsLinear
|
|
486
|
+
shell.userData.embeddedTubeEntry = false
|
|
487
|
+
shell.userData.throatSealRadius = outlet ? outletRadius : 0
|
|
488
|
+
shell.userData.singleTubeExit = true
|
|
489
|
+
shell.userData.singleRenderMesh = Boolean(outlet && funnelRings)
|
|
490
|
+
shell.userData.duplicateOutletRingCount = 0
|
|
491
|
+
shell.userData.integratedInletShell = Boolean(options.inletShell)
|
|
492
|
+
shell.userData.inletSeamVertexIndices = compactInletSeamVertexIndices
|
|
493
|
+
shell.userData.inletSeamSampleCount = compactInletSeamVertexIndices.length
|
|
494
|
+
shell.userData.inletShellIndexOffset = inletShellIndexOffset
|
|
495
|
+
shell.userData.inletFasciaTerminalCount = inletFasciaTerminalCount
|
|
496
|
+
shell.userData.trimmedInletFasciaPanels = trimmedFasciaPanels
|
|
497
|
+
shell.userData.rimOpeningBoundaryAngles = openingBoundaryAngles
|
|
498
|
+
shell.userData.circularOuterRadius = outerRadius
|
|
499
|
+
shell.userData.floorOuterRadius = floorOuterRadius
|
|
500
|
+
markDecorative(shell)
|
|
501
|
+
group.add(shell)
|
|
502
|
+
|
|
503
|
+
// The traversable bowl and this housing deliberately remain separate
|
|
504
|
+
// geometry owners. The shared floor treatment therefore cannot discover
|
|
505
|
+
// the housing's exposed vertical lip from the floor mesh. Reuse only the
|
|
506
|
+
// first housing band here so the visual shoulder continues down the outer
|
|
507
|
+
// floor depth without reaching the bowl underside, funnel, drain bore, or
|
|
508
|
+
// connected inlet opening.
|
|
509
|
+
for (const [treatmentIndex, treatment] of (options.floorPerimeterTreatments ?? []).entries()) {
|
|
510
|
+
if (outerFasciaIndices.length === 0) continue
|
|
511
|
+
const sourceVertexIndices = [...new Set(outerFasciaIndices)].sort((left, right) => left - right)
|
|
512
|
+
const fasciaVertexIndex = new Map<number, number>()
|
|
513
|
+
const fasciaVertices: number[] = []
|
|
514
|
+
const fasciaUvs: number[] = []
|
|
515
|
+
const zeroDistances: number[] = []
|
|
516
|
+
const faceRoles: number[] = []
|
|
517
|
+
sourceVertexIndices.forEach((sourceIndex, targetIndex) => {
|
|
518
|
+
fasciaVertexIndex.set(sourceIndex, targetIndex)
|
|
519
|
+
const positionOffset = sourceIndex * 3
|
|
520
|
+
const x = shellVertices[positionOffset]!
|
|
521
|
+
const y = shellVertices[positionOffset + 1]!
|
|
522
|
+
const z = shellVertices[positionOffset + 2]!
|
|
523
|
+
fasciaVertices.push(x, y, z)
|
|
524
|
+
const uvOffset = sourceIndex * 2
|
|
525
|
+
fasciaUvs.push(shellUvs[uvOffset]!, shellUvs[uvOffset + 1]!)
|
|
526
|
+
zeroDistances.push(0)
|
|
527
|
+
faceRoles.push(2)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
const fasciaGeometry = new THREE.BufferGeometry()
|
|
531
|
+
fasciaGeometry.setAttribute('position', new THREE.Float32BufferAttribute(fasciaVertices, 3))
|
|
532
|
+
fasciaGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(fasciaUvs, 2))
|
|
533
|
+
fasciaGeometry.setAttribute('themePerimeterDistance', new THREE.Float32BufferAttribute(zeroDistances, 1))
|
|
534
|
+
fasciaGeometry.setAttribute('themePerimeterSideDistance', new THREE.Float32BufferAttribute(zeroDistances, 1))
|
|
535
|
+
fasciaGeometry.setAttribute('themePerimeterTerminalDistance', new THREE.Float32BufferAttribute(zeroDistances, 1))
|
|
536
|
+
fasciaGeometry.setAttribute('themePerimeterFaceRole', new THREE.Float32BufferAttribute(faceRoles, 1))
|
|
537
|
+
fasciaGeometry.setIndex(outerFasciaIndices.map(index => fasciaVertexIndex.get(index)!))
|
|
538
|
+
// Recompute from the selected fascia faces rather than borrowing the
|
|
539
|
+
// continuous housing normals. This keeps the circular lip smooth while
|
|
540
|
+
// giving both straight inlet sides their own correct outward offset.
|
|
541
|
+
fasciaGeometry.computeVertexNormals()
|
|
542
|
+
fasciaGeometry.computeBoundingBox()
|
|
543
|
+
fasciaGeometry.computeBoundingSphere()
|
|
544
|
+
fasciaGeometry.userData.perimeterSource = 'vortex-housing-outer-depth'
|
|
545
|
+
fasciaGeometry.userData.perimeterCoversTop = false
|
|
546
|
+
fasciaGeometry.userData.perimeterCoversExposedSides = true
|
|
547
|
+
fasciaGeometry.userData.perimeterCoversUnderside = false
|
|
548
|
+
fasciaGeometry.userData.perimeterCoversInnerDrain = false
|
|
549
|
+
fasciaGeometry.userData.inletFasciaTriangleCount = inletFasciaOverlayIndexCount / 3
|
|
550
|
+
|
|
551
|
+
const fascia = new THREE.Mesh(fasciaGeometry, treatment.material)
|
|
552
|
+
fascia.name = `${options.pieceId}-floor-perimeter-depth-overlay${treatmentIndex === 0 ? '' : `-${treatmentIndex + 1}`}`
|
|
553
|
+
fascia.userData.themeOnly = treatment.themeId
|
|
554
|
+
fascia.userData.decorative = true
|
|
555
|
+
fascia.userData.visualOnly = true
|
|
556
|
+
fascia.userData.appearanceMaterialId = treatment.materialId
|
|
557
|
+
fascia.userData.perimeterEdgeProfile = 'metric-square-perimeter-feather'
|
|
558
|
+
fascia.userData.vortexHousingRole = 'outer-floor-fascia-shoulder'
|
|
559
|
+
fascia.receiveShadow = true
|
|
560
|
+
group.add(fascia)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const housingBounds = new THREE.Box3().setFromObject(group)
|
|
564
|
+
group.userData.housingBounds = {
|
|
565
|
+
min: housingBounds.min.toArray(),
|
|
566
|
+
max: housingBounds.max.toArray(),
|
|
567
|
+
}
|
|
568
|
+
group.userData.drainTopY = options.drainTopY
|
|
569
|
+
group.userData.bodyMeshCount = 1
|
|
570
|
+
root.add(group)
|
|
571
|
+
return group
|
|
572
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@marble.fun/physics-engine/atlas-vortex-layout'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@marble.fun/physics-engine/cone-field-layout'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@marble.fun/physics-engine/conveyor-layout'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { TrackDocument } from '@marble.fun/physics-engine'
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
|
|
4
|
+
import type { ThemeMaterialRegistry } from '../../../three/theme-material-registry'
|
|
5
|
+
import { applyTrackDocumentMaterialContinuity } from '../../../three/track-material-continuity'
|
|
6
|
+
import { batchStaticTrackShadowCasters } from '../../play/race/track-shadow-batching'
|
|
7
|
+
import { visualConnectionState } from './track-lab-connection-state'
|
|
8
|
+
import { createTrackPieceVisual, type TrackPieceVisual } from './track-piece-visual'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The materialised course shared by editing and racing. Editor helpers,
|
|
12
|
+
* marbles, Rapier resources, and race-only effects deliberately live outside
|
|
13
|
+
* this assembly.
|
|
14
|
+
*/
|
|
15
|
+
export interface CanonicalTrackAssembly {
|
|
16
|
+
readonly document: TrackDocument
|
|
17
|
+
readonly root: THREE.Group
|
|
18
|
+
readonly pieceVisuals: ReadonlyMap<string, TrackPieceVisual>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OwnedCanonicalTrackAssembly extends CanonicalTrackAssembly {
|
|
22
|
+
dispose(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createCanonicalTrackAssembly(
|
|
26
|
+
document: TrackDocument,
|
|
27
|
+
materials: ThemeMaterialRegistry,
|
|
28
|
+
): OwnedCanonicalTrackAssembly {
|
|
29
|
+
const root = new THREE.Group()
|
|
30
|
+
root.name = 'canonical-track-pieces'
|
|
31
|
+
const pieceVisuals = new Map<string, TrackPieceVisual>()
|
|
32
|
+
const {
|
|
33
|
+
connectedConnectorIdsByPiece,
|
|
34
|
+
continuousFluidConnectorIdsByPiece,
|
|
35
|
+
continuousSupportConnectorIdsByPiece,
|
|
36
|
+
connectorFramesByPiece,
|
|
37
|
+
} = visualConnectionState(document)
|
|
38
|
+
|
|
39
|
+
for (const piece of document.pieces) {
|
|
40
|
+
const visual = createTrackPieceVisual(piece, materials, {
|
|
41
|
+
connectedConnectorIds: connectedConnectorIdsByPiece.get(piece.id) ?? new Set(),
|
|
42
|
+
continuousFluidConnectorIds: continuousFluidConnectorIdsByPiece.get(piece.id) ?? new Set(),
|
|
43
|
+
continuousSupportConnectorIds: continuousSupportConnectorIdsByPiece.get(piece.id) ?? new Set(),
|
|
44
|
+
connectorFrames: connectorFramesByPiece.get(piece.id),
|
|
45
|
+
overviewLod: true,
|
|
46
|
+
deferSupportUntilMaterialContinuity: true,
|
|
47
|
+
})
|
|
48
|
+
root.add(visual.root)
|
|
49
|
+
pieceVisuals.set(piece.id, visual)
|
|
50
|
+
}
|
|
51
|
+
applyTrackDocumentMaterialContinuity(document, pieceVisuals)
|
|
52
|
+
for (const visual of pieceVisuals.values()) batchStaticTrackShadowCasters(visual.root)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
document,
|
|
56
|
+
root,
|
|
57
|
+
pieceVisuals,
|
|
58
|
+
dispose(): void {
|
|
59
|
+
for (const visual of pieceVisuals.values()) visual.dispose()
|
|
60
|
+
pieceVisuals.clear()
|
|
61
|
+
root.removeFromParent()
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|