@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,517 @@
1
+ import * as THREE from 'three'
2
+
3
+ import {
4
+ isSharedStoneTrackShellAppearance,
5
+ isSharedStoneTrackShellDefaultTheme,
6
+ SHARED_STONE_TRACK_SHELL_THEME_IDS,
7
+ } from '../../../three/stone-track-shell-appearance'
8
+ import { TRACK_RAIL_WALL_THICKNESS } from './track-definition'
9
+ import {
10
+ canonicalConvergentConnectedTerminalClosures,
11
+ canonicalWallFrameAt,
12
+ type CanonicalWallFrame,
13
+ type CanonicalWallRun,
14
+ } from './track-surface-topology'
15
+
16
+ export const STONE_TRACK_SHELL_TARGET_PITCH = 1.12
17
+ export const STONE_TRACK_SHELL_COURSE_COUNT = 5
18
+ export const STONE_TRACK_SHELL_MORTAR_GAP = 0.018
19
+ export const STONE_TRACK_SHELL_BLOCK_DEPTH = 0.38
20
+ export const STONE_TRACK_SHELL_CAP_HEIGHT = 0.21
21
+ export const STONE_TRACK_SHELL_CAP_DEPTH = 0.52
22
+ // Canonical connectors already share one exact docking plane. Extending both
23
+ // neighboring brick halves through it creates double-owned coplanar faces and
24
+ // visible interpenetration, especially under grazing light.
25
+ export const STONE_TRACK_SHELL_CONNECTOR_OVERLAP = 0
26
+ // Underlap the canonical floor far enough to cover the per-brick depth
27
+ // variation. Centering the masonry outside the
28
+ // old 20 cm guard left a real dark channel between these two visual shells.
29
+ export const STONE_TRACK_SHELL_FLOOR_UNDERLAP = 0.08
30
+ export const STONE_TRACK_SHELL_PREVIEW_OUTWARD_OFFSET =
31
+ STONE_TRACK_SHELL_BLOCK_DEPTH * 0.5 - STONE_TRACK_SHELL_FLOOR_UNDERLAP
32
+
33
+ // One rectangular ring per end; curved bricks retain a midpoint for their silhouette.
34
+ const MAX_SWEEP_SAMPLE_SPACING = 0.75
35
+
36
+ export interface StoneTrackShellMaterials {
37
+ wall: THREE.Material
38
+ floor: THREE.Material
39
+ }
40
+
41
+ export interface StoneTrackShellOptions {
42
+ themeId: string
43
+ connectionPhase?: number | readonly number[]
44
+ deferGeometry?: boolean
45
+ }
46
+
47
+ interface StoneSegment {
48
+ start: number
49
+ end: number
50
+ metricLength: number
51
+ closeStart: boolean
52
+ closeEnd: boolean
53
+ }
54
+
55
+ interface GeometryAccumulator {
56
+ positions: number[]
57
+ uvs: number[]
58
+ metricUvs: number[]
59
+ moduleCount: number
60
+ maxModuleLength: number
61
+ }
62
+
63
+ interface SweptBlockOptions {
64
+ start: number
65
+ end: number
66
+ metricLength: number
67
+ depth: number
68
+ height: number
69
+ verticalOffset: number
70
+ outwardOffset: number
71
+ uvPhase: readonly [number, number]
72
+ /** Distance already consumed by this brick before the current piece begins. */
73
+ uvLongitudinalStart: number
74
+ closeStart: boolean
75
+ closeEnd: boolean
76
+ }
77
+
78
+ type WallFrameSampler = (distance: number) => CanonicalWallFrame
79
+
80
+ function masonryUvPhase(runIndex: number, course: number, segmentIndex: number, cap: boolean): [number, number] {
81
+ let seed = Math.imul(course + 1, 0x45d9f3b)
82
+ seed ^= Math.imul(segmentIndex + 1, 0x119de1f3)
83
+ seed ^= Math.imul(runIndex + 2, 0x3449f)
84
+ if (cap) seed ^= 0x27d4eb2d
85
+ seed ^= seed >>> 16
86
+ seed = Math.imul(seed, 0x7feb352d)
87
+ seed ^= seed >>> 15
88
+ seed = Math.imul(seed, 0x846ca68b)
89
+ seed ^= seed >>> 16
90
+ const unsigned = seed >>> 0
91
+ // The preview divides geometry UVs by 1.6 and then applies a 0.9 texture
92
+ // repeat. Production's metric shader uses a 1.6/0.9 world size, so storing
93
+ // preview phases in 1.6-world-unit space reproduces the exact transform.
94
+ return [((unsigned & 0xffff) / 0x10000) * 1.6, ((unsigned >>> 16) / 0x10000) * 1.6]
95
+ }
96
+
97
+ function positiveModulo(value: number, divisor: number): number {
98
+ return ((value % divisor) + divisor) % divisor
99
+ }
100
+
101
+ function courseSegments(
102
+ run: CanonicalWallRun,
103
+ course: number,
104
+ connectionPhase: number,
105
+ closeStart = false,
106
+ closeEnd = false,
107
+ ): StoneSegment[] {
108
+ if (run.length <= 0.001) return []
109
+ const offset = course % 2 === 0 ? 0 : STONE_TRACK_SHELL_TARGET_PITCH * 0.5
110
+ const boundaries = [0]
111
+ const phaseRemainder = positiveModulo(connectionPhase - offset, STONE_TRACK_SHELL_TARGET_PITCH)
112
+ const firstBoundary =
113
+ phaseRemainder <= 0.000_001 ? STONE_TRACK_SHELL_TARGET_PITCH : STONE_TRACK_SHELL_TARGET_PITCH - phaseRemainder
114
+ for (let distance = firstBoundary; distance < run.length; distance += STONE_TRACK_SHELL_TARGET_PITCH) {
115
+ if (distance > 0.12) boundaries.push(distance)
116
+ }
117
+ boundaries.push(run.length)
118
+ return boundaries.slice(0, -1).flatMap((boundary, index) => {
119
+ const next = boundaries[index + 1]!
120
+ const start = boundary === 0 ? boundary : boundary + STONE_TRACK_SHELL_MORTAR_GAP * 0.5
121
+ const end = next === run.length ? next : next - STONE_TRACK_SHELL_MORTAR_GAP * 0.5
122
+ return end - start < 0.02
123
+ ? []
124
+ : [
125
+ {
126
+ start,
127
+ end,
128
+ metricLength: end - start,
129
+ closeStart: boundary !== 0 || run.capStart || closeStart,
130
+ closeEnd: next !== run.length || run.capEnd || closeEnd,
131
+ },
132
+ ]
133
+ })
134
+ }
135
+
136
+ function appendTriangle(
137
+ output: GeometryAccumulator,
138
+ a: THREE.Vector3,
139
+ b: THREE.Vector3,
140
+ c: THREE.Vector3,
141
+ uvA: readonly [number, number],
142
+ uvB: readonly [number, number],
143
+ uvC: readonly [number, number],
144
+ uvPhase: readonly [number, number],
145
+ metricLength: number,
146
+ reverseWinding: boolean,
147
+ ): void {
148
+ const points = reverseWinding ? [a, c, b] : [a, b, c]
149
+ const triangleUvs = reverseWinding ? [uvA, uvC, uvB] : [uvA, uvB, uvC]
150
+ for (const point of points) output.positions.push(point.x, point.y, point.z)
151
+ for (const uv of triangleUvs) {
152
+ const u = uv[0] + uvPhase[0]
153
+ const v = uv[1] + uvPhase[1]
154
+ output.uvs.push(u, v)
155
+ output.metricUvs.push(u, v, 1, metricLength)
156
+ }
157
+ }
158
+
159
+ function appendSweptBlock(
160
+ output: GeometryAccumulator,
161
+ sampleFrame: WallFrameSampler,
162
+ options: SweptBlockOptions,
163
+ ): void {
164
+ const physicalLength = Math.max(0.001, options.metricLength)
165
+ const startFrame = sampleFrame(options.start)
166
+ const endFrame = sampleFrame(options.end)
167
+ const linearSweep = [0.25, 0.5, 0.75].every(fraction => {
168
+ const frame = sampleFrame(THREE.MathUtils.lerp(options.start, options.end, fraction))
169
+ const expectedBase = startFrame.base.clone().lerp(endFrame.base, fraction)
170
+ return (
171
+ frame.base.distanceToSquared(expectedBase) <= 0.000_000_01 &&
172
+ frame.tangent.dot(startFrame.tangent) >= 0.999_999_9 &&
173
+ frame.up.dot(startFrame.up) >= 0.999_999_9 &&
174
+ frame.outward.dot(startFrame.outward) >= 0.999_999_9
175
+ )
176
+ })
177
+ const subdivisions = linearSweep ? 1 : Math.max(2, Math.ceil(physicalLength / MAX_SWEEP_SAMPLE_SPACING))
178
+ const fractions = new Set([0, 1])
179
+ for (let index = 1; index < subdivisions; index += 1) fractions.add(index / subdivisions)
180
+ const orderedFractions = [...fractions].sort((left, right) => left - right)
181
+ const directionProbe = sampleFrame(Math.min(options.end, options.start + 0.01))
182
+ // The preview's ring order is front-facing when its path tangent opposes
183
+ // the ring sweep. Canonical runs can use either traversal direction, so
184
+ // orient every triangle from the actual path progression instead of side.
185
+ const reverseWinding = directionProbe.base.clone().sub(startFrame.base).dot(startFrame.tangent) > 0
186
+ const rings = orderedFractions.map(fraction => {
187
+ const distance = THREE.MathUtils.lerp(options.start, options.end, fraction)
188
+ const frame = fraction === 0 ? startFrame : fraction === 1 ? endFrame : sampleFrame(distance)
189
+ const center = frame.base
190
+ .clone()
191
+ .addScaledVector(frame.outward, options.outwardOffset)
192
+ .addScaledVector(frame.up, options.verticalOffset)
193
+ const halfDepth = options.depth * 0.5
194
+ const halfHeight = options.height * 0.5
195
+ const crossSection: Array<readonly [number, number]> = [
196
+ [-halfDepth, -halfHeight],
197
+ [halfDepth, -halfHeight],
198
+ [halfDepth, halfHeight],
199
+ [-halfDepth, halfHeight],
200
+ ]
201
+ const points = crossSection.map(([depthOffset, heightOffset]) =>
202
+ center.clone().addScaledVector(frame.outward, depthOffset).addScaledVector(frame.up, heightOffset),
203
+ )
204
+ const perimeter = [0]
205
+ for (let edge = 1; edge < crossSection.length; edge += 1) {
206
+ const previous = crossSection[edge - 1]!
207
+ const current = crossSection[edge]!
208
+ perimeter.push(perimeter.at(-1)! + Math.hypot(current[0] - previous[0], current[1] - previous[1]))
209
+ }
210
+ const first = crossSection[0]!
211
+ const last = crossSection.at(-1)!
212
+ const perimeterTotal = perimeter.at(-1)! + Math.hypot(first[0] - last[0], first[1] - last[1])
213
+ return { fraction, center, crossSection, points, perimeter, perimeterTotal }
214
+ })
215
+
216
+ for (let ringIndex = 0; ringIndex < rings.length - 1; ringIndex += 1) {
217
+ const current = rings[ringIndex]!
218
+ const next = rings[ringIndex + 1]!
219
+ for (let edge = 0; edge < current.crossSection.length; edge += 1) {
220
+ const nextEdge = (edge + 1) % current.crossSection.length
221
+ const currentV = current.perimeter[edge]!
222
+ const currentNextV = nextEdge === 0 ? current.perimeterTotal : current.perimeter[nextEdge]!
223
+ const nextV = next.perimeter[edge]!
224
+ const nextNextV = nextEdge === 0 ? next.perimeterTotal : next.perimeter[nextEdge]!
225
+ // A brick can straddle two track pieces. Its second half must resume the
226
+ // first half's UVs; restarting U at zero makes the connector visible even
227
+ // when the swept geometry itself is perfectly flush.
228
+ const currentU = options.uvLongitudinalStart + current.fraction * physicalLength
229
+ const nextU = options.uvLongitudinalStart + next.fraction * physicalLength
230
+ appendTriangle(
231
+ output,
232
+ current.points[edge]!,
233
+ current.points[nextEdge]!,
234
+ next.points[edge]!,
235
+ [currentU, currentV],
236
+ [currentU, currentNextV],
237
+ [nextU, nextV],
238
+ options.uvPhase,
239
+ physicalLength,
240
+ reverseWinding,
241
+ )
242
+ appendTriangle(
243
+ output,
244
+ current.points[nextEdge]!,
245
+ next.points[nextEdge]!,
246
+ next.points[edge]!,
247
+ [currentU, currentNextV],
248
+ [nextU, nextNextV],
249
+ [nextU, nextV],
250
+ options.uvPhase,
251
+ physicalLength,
252
+ reverseWinding,
253
+ )
254
+ }
255
+ }
256
+
257
+ const startRing = rings[0]!
258
+ const endRing = rings.at(-1)!
259
+ // Two triangles close a rectangular end. A centre fan adds vertices and
260
+ // triangles without changing the surface or its planar UV interpolation.
261
+ for (const [a, b, c] of [[0, 1, 2], [0, 2, 3]] as const) {
262
+ if (options.closeStart) {
263
+ appendTriangle(output, startRing.points[a]!, startRing.points[c]!, startRing.points[b]!,
264
+ startRing.crossSection[a]!, startRing.crossSection[c]!, startRing.crossSection[b]!,
265
+ options.uvPhase, physicalLength, reverseWinding)
266
+ }
267
+ if (options.closeEnd) {
268
+ const uv = (index: number): [number, number] => [physicalLength + endRing.crossSection[index]![0], endRing.crossSection[index]![1]]
269
+ appendTriangle(output, endRing.points[a]!, endRing.points[b]!, endRing.points[c]!,
270
+ uv(a), uv(b), uv(c), options.uvPhase, physicalLength, reverseWinding)
271
+ }
272
+ }
273
+ output.moduleCount += 1
274
+ output.maxModuleLength = Math.max(output.maxModuleLength, physicalLength)
275
+ }
276
+
277
+ function appendRun(
278
+ output: GeometryAccumulator,
279
+ run: CanonicalWallRun,
280
+ sampleFrame: WallFrameSampler,
281
+ runIndex: number,
282
+ wallHeight: number,
283
+ connectionPhase: number,
284
+ closeStart: boolean,
285
+ closeEnd: boolean,
286
+ ): { wallBlocks: number; capBlocks: number } {
287
+ const usableWallHeight = Math.max(0.1, wallHeight - STONE_TRACK_SHELL_CAP_HEIGHT)
288
+ const courseHeight = usableWallHeight / STONE_TRACK_SHELL_COURSE_COUNT
289
+ let wallBlocks = 0
290
+ let capBlocks = 0
291
+ for (let course = 0; course < STONE_TRACK_SHELL_COURSE_COUNT; course += 1) {
292
+ const courseOffset = course % 2 === 0 ? 0 : STONE_TRACK_SHELL_TARGET_PITCH * 0.5
293
+ for (const segment of courseSegments(run, course, connectionPhase, closeStart, closeEnd)) {
294
+ const globalBrickIndex = Math.floor(
295
+ (connectionPhase + segment.start - courseOffset + 0.000_001) / STONE_TRACK_SHELL_TARGET_PITCH,
296
+ )
297
+ const depthVariation = (positiveModulo(globalBrickIndex * 17 + course * 7 + runIndex * 11, 3) - 1) * 0.012
298
+ appendSweptBlock(output, sampleFrame, {
299
+ ...segment,
300
+ depth: STONE_TRACK_SHELL_BLOCK_DEPTH,
301
+ height: courseHeight - STONE_TRACK_SHELL_MORTAR_GAP,
302
+ verticalOffset: courseHeight * (course + 0.5),
303
+ outwardOffset: STONE_TRACK_SHELL_PREVIEW_OUTWARD_OFFSET + depthVariation,
304
+ uvPhase: masonryUvPhase(runIndex, course, globalBrickIndex, false),
305
+ uvLongitudinalStart: positiveModulo(
306
+ connectionPhase + segment.start - courseOffset,
307
+ STONE_TRACK_SHELL_TARGET_PITCH,
308
+ ),
309
+ })
310
+ wallBlocks += 1
311
+ }
312
+ }
313
+ for (const segment of courseSegments(run, 0, connectionPhase, closeStart, closeEnd)) {
314
+ const globalBrickIndex = Math.floor((connectionPhase + segment.start + 0.000_001) / STONE_TRACK_SHELL_TARGET_PITCH)
315
+ appendSweptBlock(output, sampleFrame, {
316
+ ...segment,
317
+ depth: STONE_TRACK_SHELL_CAP_DEPTH,
318
+ height: STONE_TRACK_SHELL_CAP_HEIGHT - STONE_TRACK_SHELL_MORTAR_GAP * 0.5,
319
+ verticalOffset: usableWallHeight + STONE_TRACK_SHELL_CAP_HEIGHT * 0.5,
320
+ outwardOffset: STONE_TRACK_SHELL_PREVIEW_OUTWARD_OFFSET,
321
+ uvPhase: masonryUvPhase(runIndex, STONE_TRACK_SHELL_COURSE_COUNT, globalBrickIndex, true),
322
+ uvLongitudinalStart: positiveModulo(connectionPhase + segment.start, STONE_TRACK_SHELL_TARGET_PITCH),
323
+ })
324
+ capBlocks += 1
325
+ }
326
+ return { wallBlocks, capBlocks }
327
+ }
328
+
329
+ function createBatchedGeometry(
330
+ runs: readonly CanonicalWallRun[],
331
+ wallHeight: number,
332
+ connectionPhases: readonly number[],
333
+ ): {
334
+ geometry: THREE.BufferGeometry
335
+ wallBlocks: number
336
+ capBlocks: number
337
+ moduleCount: number
338
+ maxModuleLength: number
339
+ } {
340
+ const output: GeometryAccumulator = { positions: [], uvs: [], metricUvs: [], moduleCount: 0, maxModuleLength: 0 }
341
+ let wallBlocks = 0
342
+ let capBlocks = 0
343
+ const sharedConnectedClosures = canonicalConvergentConnectedTerminalClosures(runs)
344
+ runs.forEach((run, runIndex) => {
345
+ const frames = new Map<number, CanonicalWallFrame>()
346
+ const sampleFrame = (distance: number): CanonicalWallFrame => {
347
+ const target = THREE.MathUtils.clamp(distance, 0, run.length)
348
+ const cached = frames.get(target)
349
+ if (cached) return cached
350
+ const frame = canonicalWallFrameAt(run, target)
351
+ frames.set(target, frame)
352
+ return frame
353
+ }
354
+ const counts = appendRun(
355
+ output,
356
+ run,
357
+ sampleFrame,
358
+ runIndex,
359
+ wallHeight,
360
+ connectionPhases[runIndex] ?? connectionPhases[0] ?? 0,
361
+ sharedConnectedClosures.has(`${runIndex}:start`),
362
+ sharedConnectedClosures.has(`${runIndex}:end`),
363
+ )
364
+ wallBlocks += counts.wallBlocks
365
+ capBlocks += counts.capBlocks
366
+ })
367
+ const geometry = new THREE.BufferGeometry()
368
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(output.positions, 3))
369
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(output.uvs, 2))
370
+ // AO and surface sampling share the exact masonry UVs, including seams.
371
+ geometry.setAttribute('uv1', geometry.getAttribute('uv'))
372
+ geometry.setAttribute('themeNetUv', new THREE.Float32BufferAttribute(output.metricUvs, 4))
373
+ geometry.computeVertexNormals()
374
+ geometry.computeBoundingBox()
375
+ geometry.computeBoundingSphere()
376
+ geometry.userData.moduleCount = output.moduleCount
377
+ geometry.userData.sharedConnectedClosureCount = sharedConnectedClosures.size
378
+ return { geometry, wallBlocks, capBlocks, moduleCount: output.moduleCount, maxModuleLength: output.maxModuleLength }
379
+ }
380
+
381
+ function canonicalEditorBounds(runs: readonly CanonicalWallRun[], wallHeight: number): THREE.Box3 {
382
+ const bounds = new THREE.Box3()
383
+ const halfThickness = TRACK_RAIL_WALL_THICKNESS * 0.5
384
+ for (const run of runs) {
385
+ for (const frame of run.frames) {
386
+ for (const across of [-halfThickness, halfThickness]) {
387
+ bounds.expandByPoint(frame.base.clone().addScaledVector(frame.outward, across).addScaledVector(frame.up, -0.03))
388
+ bounds.expandByPoint(
389
+ frame.base
390
+ .clone()
391
+ .addScaledVector(frame.outward, across)
392
+ .addScaledVector(frame.up, wallHeight - 0.03),
393
+ )
394
+ }
395
+ }
396
+ }
397
+ return bounds
398
+ }
399
+
400
+ /**
401
+ * Builds the approved architecture directly from canonical wall boundaries.
402
+ * Every visible unit is swept along its boundary (including curves and grades)
403
+ * and all units are merged into one draw call. The canonical ribbon remains
404
+ * the only floor and Rapier collider, so this visual shell cannot alter motion.
405
+ */
406
+ export function addStoneTrackShellVisual(
407
+ host: THREE.Group,
408
+ id: string,
409
+ runs: readonly CanonicalWallRun[],
410
+ wallHeight: number,
411
+ materials: StoneTrackShellMaterials,
412
+ options: StoneTrackShellOptions,
413
+ ): THREE.Group | undefined {
414
+ if (wallHeight <= STONE_TRACK_SHELL_CAP_HEIGHT || runs.length === 0) return undefined
415
+ const group = new THREE.Group()
416
+ group.name = `${id}-shared-stone-track-shell`
417
+ group.userData.themeOnly = [...SHARED_STONE_TRACK_SHELL_THEME_IDS]
418
+ group.visible = group.userData.themeOnly.includes(options.themeId)
419
+ group.userData.initialThemeId = options.themeId
420
+ group.userData.visualOnly = true
421
+ group.userData.appearance = 'shared-stone-block-shell-v2'
422
+ group.userData.representation = 'procedural-swept-masonry-batch'
423
+ group.userData.courseCount = STONE_TRACK_SHELL_COURSE_COUNT
424
+ group.userData.targetPitch = STONE_TRACK_SHELL_TARGET_PITCH
425
+ group.userData.mortarGap = STONE_TRACK_SHELL_MORTAR_GAP
426
+ group.userData.previewGeometryPort = 'like-for-like'
427
+ group.userData.outwardOffset = STONE_TRACK_SHELL_PREVIEW_OUTWARD_OFFSET
428
+ group.userData.floorBoundaryUnderlap = STONE_TRACK_SHELL_FLOOR_UNDERLAP
429
+ group.userData.wallThickness = TRACK_RAIL_WALL_THICKNESS
430
+ group.userData.floorGeometry = 'canonical-ribbon-only'
431
+ group.userData.drawCallBudget = 1
432
+ const requestedPhases = Array.isArray(options.connectionPhase)
433
+ ? options.connectionPhase
434
+ : runs.map(() => options.connectionPhase)
435
+ const initialConnectionPhases = runs.map((_run, index) => {
436
+ const phase = requestedPhases[index] ?? requestedPhases[0]
437
+ return Number.isFinite(phase) ? phase! : 0
438
+ })
439
+ group.userData.connectionPhase = initialConnectionPhases[0] ?? 0
440
+ group.userData.connectionPhases = [...initialConnectionPhases]
441
+ group.userData.runMetricLengths = runs.map(run => run.length)
442
+ group.userData.runTerminals = runs.flatMap((run, runIndex) => [
443
+ {
444
+ runIndex,
445
+ terminal: 'start',
446
+ base: run.frames[0]!.base.toArray(),
447
+ tangent: run.frames[0]!.tangent.toArray(),
448
+ cap: run.capStart,
449
+ },
450
+ {
451
+ runIndex,
452
+ terminal: 'end',
453
+ base: run.frames.at(-1)!.base.toArray(),
454
+ tangent: run.frames.at(-1)!.tangent.toArray(),
455
+ cap: run.capEnd,
456
+ },
457
+ ])
458
+ group.userData.connectorRule = 'global-phase-single-docking-plane'
459
+ group.userData.connectorOverlap = STONE_TRACK_SHELL_CONNECTOR_OVERLAP
460
+ group.userData.connectedTerminalCaps = 'open-neighbor-owned'
461
+ group.userData.sharedConnectedClosureRule = 'one-owner-for-convergent-internal-terminals'
462
+
463
+ let mesh: THREE.Mesh | undefined
464
+ const buildGeometry = (phases: readonly number[]): void => {
465
+ const built = createBatchedGeometry(runs, wallHeight, phases)
466
+ if (mesh) {
467
+ const previousGeometry = mesh.geometry
468
+ mesh.geometry = built.geometry
469
+ previousGeometry.dispose()
470
+ } else {
471
+ mesh = new THREE.Mesh(built.geometry, materials.wall)
472
+ mesh.name = `${group.name}-stone-module-batch`
473
+ mesh.castShadow = true
474
+ mesh.receiveShadow = true
475
+ mesh.userData.visualOnly = true
476
+ // Render the protruding 3D masonry, but keep Track Lab's lime selection box
477
+ // on the canonical wall envelope. This mirrors the legacy wall dimensions
478
+ // and prevents a theme from appearing to resize the authored track piece.
479
+ const editorBounds = canonicalEditorBounds(runs, wallHeight)
480
+ mesh.userData.editorBoundsOverride = {
481
+ min: editorBounds.min.toArray(),
482
+ max: editorBounds.max.toArray(),
483
+ }
484
+ mesh.userData.editorBoundsSource = 'canonical-wall-envelope'
485
+ mesh.userData.drawCallBudget = 1
486
+ mesh.userData.sharedConnectedClosureCount = built.geometry.userData.sharedConnectedClosureCount
487
+ group.add(mesh)
488
+ }
489
+ group.userData.moduleCount = built.moduleCount
490
+ group.userData.roleCounts = { wallBlock: built.wallBlocks, capBlock: built.capBlocks }
491
+ group.userData.maxModuleLength = built.maxModuleLength
492
+ }
493
+ group.userData.moduleCount = 0
494
+ group.userData.roleCounts = { wallBlock: 0, capBlock: 0 }
495
+ group.userData.maxModuleLength = 0
496
+ if (!options.deferGeometry) buildGeometry(initialConnectionPhases)
497
+ const setMaterialConnectionPhase = (phaseOrPhases: number | readonly number[]): void => {
498
+ const requested = Array.isArray(phaseOrPhases) ? phaseOrPhases : runs.map(() => phaseOrPhases)
499
+ const phases = runs.map((_run, index) => requested[index] ?? requested[0] ?? 0)
500
+ if (phases.some(phase => !Number.isFinite(phase))) return
501
+ const current = group.userData.connectionPhases as readonly number[]
502
+ const phaseChanged = phases.length !== current.length || phases.some((phase, index) => phase !== current[index])
503
+ if (mesh && !phaseChanged) return
504
+ buildGeometry(phases)
505
+ group.userData.connectionPhase = phases[0] ?? 0
506
+ group.userData.connectionPhases = [...phases]
507
+ }
508
+ group.userData.setMaterialConnectionPhase = setMaterialConnectionPhase
509
+ host.add(group)
510
+
511
+ const ensureModel = (): Promise<THREE.Group> => Promise.resolve(group)
512
+ group.userData.ensureModel = ensureModel
513
+ group.userData.modelReady = ensureModel()
514
+ return group
515
+ }
516
+
517
+ export { isSharedStoneTrackShellAppearance, isSharedStoneTrackShellDefaultTheme }