@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,510 @@
1
+ import { geometryDetailModelUrl } from '../../../three/geometry-detail'
2
+ import * as THREE from 'three'
3
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
4
+
5
+ import {
6
+ UNDER_TRACK_SUPPORT_BAY_LENGTH,
7
+ UNDER_TRACK_SUPPORT_DEPTH,
8
+ UNDER_TRACK_SUPPORT_FADE_START_DEPTH,
9
+ UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE,
10
+ underTrackSupportFadeAtDepth,
11
+ } from './under-track-support-architecture'
12
+
13
+ export const JAPANESE_SAKURA_CASTLE_SUPPORT_MODEL_URL = '/models/supports/japanese-sakura-castle-support-v2.glb'
14
+
15
+ const AUTHORED_SUPPORT_DEPTH = 10.8
16
+ const AUTHORED_SUPPORT_BAY_LENGTH = 5
17
+ const DECK_UNDERSIDE_OFFSET = 0.38
18
+
19
+ export interface SakuraCastleSupportFrame {
20
+ point: THREE.Vector3
21
+ tangent: THREE.Vector3
22
+ right: THREE.Vector3
23
+ up: THREE.Vector3
24
+ width: number
25
+ }
26
+
27
+ export interface SakuraCastleSupportOptions {
28
+ capStart?: boolean
29
+ capEnd?: boolean
30
+ /** Cumulative topology distance at this piece's input connector. */
31
+ connectionPhase?: number
32
+ /** Complete topology-owned bays, including connector-crossing and short-piece bays. */
33
+ ownedBayDistances?: readonly (readonly [start: number, end: number])[]
34
+ }
35
+
36
+ let templatePromise: Promise<THREE.Object3D> | undefined
37
+
38
+ function loadTemplate(): Promise<THREE.Object3D> {
39
+ templatePromise ??= new GLTFLoader()
40
+ .loadAsync(geometryDetailModelUrl(JAPANESE_SAKURA_CASTLE_SUPPORT_MODEL_URL))
41
+ .then(gltf => {
42
+ gltf.scene.traverse(object => {
43
+ if (!(object instanceof THREE.Mesh)) return
44
+ const embedded = Array.isArray(object.material) ? object.material : [object.material]
45
+ embedded.forEach(material => material.dispose())
46
+ })
47
+ gltf.scene.updateMatrixWorld(true)
48
+ return gltf.scene
49
+ })
50
+ return templatePromise
51
+ }
52
+
53
+ const WORLD_UP = new THREE.Vector3(0, 1, 0)
54
+
55
+ function reverseTriangleWinding(geometry: THREE.BufferGeometry): void {
56
+ const index = geometry.getIndex()
57
+ if (index) {
58
+ for (let offset = 0; offset < index.count; offset += 3) {
59
+ const second = index.getX(offset + 1)
60
+ index.setX(offset + 1, index.getX(offset + 2))
61
+ index.setX(offset + 2, second)
62
+ }
63
+ index.needsUpdate = true
64
+ return
65
+ }
66
+
67
+ for (const attribute of Object.values(geometry.attributes)) {
68
+ if (!(attribute instanceof THREE.BufferAttribute || attribute instanceof THREE.InterleavedBufferAttribute)) continue
69
+ for (let offset = 0; offset < attribute.count; offset += 3) {
70
+ for (let component = 0; component < attribute.itemSize; component += 1) {
71
+ const second = attribute.getComponent(offset + 1, component)
72
+ attribute.setComponent(offset + 1, component, attribute.getComponent(offset + 2, component))
73
+ attribute.setComponent(offset + 2, component, second)
74
+ }
75
+ }
76
+ attribute.needsUpdate = true
77
+ }
78
+ }
79
+
80
+ function clipLongitudinalHalf(
81
+ geometry: THREE.BufferGeometry,
82
+ half: 'negative' | 'positive',
83
+ ): THREE.BufferGeometry {
84
+ const position = geometry.getAttribute('position')
85
+ const index = geometry.getIndex()
86
+ const attributes = Object.entries(geometry.attributes).filter(
87
+ (entry): entry is [string, THREE.BufferAttribute | THREE.InterleavedBufferAttribute] =>
88
+ entry[1] instanceof THREE.BufferAttribute || entry[1] instanceof THREE.InterleavedBufferAttribute,
89
+ )
90
+ type Vertex = Record<string, number[]>
91
+ const readVertex = (vertexIndex: number): Vertex =>
92
+ Object.fromEntries(
93
+ attributes.map(([name, attribute]) => [
94
+ name,
95
+ Array.from({ length: attribute.itemSize }, (_unused, component) =>
96
+ attribute.getComponent(vertexIndex, component),
97
+ ),
98
+ ]),
99
+ )
100
+ const interpolate = (from: Vertex, to: Vertex, amount: number): Vertex =>
101
+ Object.fromEntries(
102
+ attributes.map(([name, attribute]) => [
103
+ name,
104
+ Array.from(
105
+ { length: attribute.itemSize },
106
+ (_unused, component) =>
107
+ from[name]![component]! + (to[name]![component]! - from[name]![component]!) * amount,
108
+ ),
109
+ ]),
110
+ )
111
+ const inside = (vertex: Vertex) =>
112
+ half === 'positive' ? vertex.position![0]! >= -0.000_001 : vertex.position![0]! <= 0.000_001
113
+ const clippedAttributes = new Map(attributes.map(([name]) => [name, [] as number[]]))
114
+ const planeEdges = new Map<string, { count: number; from: Vertex; to: Vertex }>()
115
+ const planeKey = (vertex: Vertex) =>
116
+ `${Math.round(vertex.position![1]! * 1_000_000)}:${Math.round(vertex.position![2]! * 1_000_000)}`
117
+ const edgeKey = (from: Vertex, to: Vertex) => [planeKey(from), planeKey(to)].sort().join('|')
118
+ const triangleCount = (index?.count ?? position.count) / 3
119
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
120
+ const offset = triangle * 3
121
+ const polygon = [
122
+ readVertex(index?.getX(offset) ?? offset),
123
+ readVertex(index?.getX(offset + 1) ?? offset + 1),
124
+ readVertex(index?.getX(offset + 2) ?? offset + 2),
125
+ ]
126
+ const clipped: Vertex[] = []
127
+ for (let vertexIndex = 0; vertexIndex < polygon.length; vertexIndex += 1) {
128
+ const current = polygon[vertexIndex]!
129
+ const next = polygon[(vertexIndex + 1) % polygon.length]!
130
+ const currentInside = inside(current)
131
+ const nextInside = inside(next)
132
+ if (currentInside) clipped.push(current)
133
+ if (currentInside === nextInside) continue
134
+ const currentX = current.position![0]!
135
+ const nextX = next.position![0]!
136
+ clipped.push(interpolate(current, next, -currentX / (nextX - currentX)))
137
+ }
138
+ for (let vertexIndex = 0; vertexIndex < clipped.length; vertexIndex += 1) {
139
+ const from = clipped[vertexIndex]!
140
+ const to = clipped[(vertexIndex + 1) % clipped.length]!
141
+ if (Math.abs(from.position![0]!) > 0.000_01 || Math.abs(to.position![0]!) > 0.000_01) continue
142
+ const key = edgeKey(from, to)
143
+ const existing = planeEdges.get(key)
144
+ planeEdges.set(key, existing ? { ...existing, count: existing.count + 1 } : { count: 1, from, to })
145
+ }
146
+ for (let fan = 1; fan < clipped.length - 1; fan += 1) {
147
+ for (const vertex of [clipped[0]!, clipped[fan]!, clipped[fan + 1]!]) {
148
+ for (const [name] of attributes) clippedAttributes.get(name)!.push(...vertex[name]!)
149
+ }
150
+ }
151
+ }
152
+ const boundaryEdges = [...planeEdges.values()].filter(edge => edge.count % 2 === 1)
153
+ const verticesByKey = new Map<string, Vertex>()
154
+ const neighbours = new Map<string, Set<string>>()
155
+ const remainingEdges = new Set<string>()
156
+ for (const edge of boundaryEdges) {
157
+ const fromKey = planeKey(edge.from)
158
+ const toKey = planeKey(edge.to)
159
+ verticesByKey.set(fromKey, edge.from)
160
+ verticesByKey.set(toKey, edge.to)
161
+ const fromNeighbours = neighbours.get(fromKey) ?? new Set<string>()
162
+ const toNeighbours = neighbours.get(toKey) ?? new Set<string>()
163
+ fromNeighbours.add(toKey)
164
+ toNeighbours.add(fromKey)
165
+ neighbours.set(fromKey, fromNeighbours)
166
+ neighbours.set(toKey, toNeighbours)
167
+ remainingEdges.add([fromKey, toKey].sort().join('|'))
168
+ }
169
+ const appendCapVertex = (vertex: Vertex): void => {
170
+ for (const [name, attribute] of attributes) {
171
+ const values = [...vertex[name]!]
172
+ if (name === 'normal' && attribute.itemSize >= 3) values.splice(0, 3, half === 'positive' ? -1 : 1, 0, 0)
173
+ clippedAttributes.get(name)!.push(...values)
174
+ }
175
+ }
176
+ while (remainingEdges.size > 0) {
177
+ const firstEdge = remainingEdges.values().next().value as string
178
+ const [start, next] = firstEdge.split('|') as [string, string]
179
+ const loop = [start]
180
+ let previous = start
181
+ let current = next
182
+ remainingEdges.delete(firstEdge)
183
+ while (current !== start) {
184
+ loop.push(current)
185
+ const candidate = [...(neighbours.get(current) ?? [])].find(neighbour => {
186
+ if (neighbour === previous) return false
187
+ return remainingEdges.has([current, neighbour].sort().join('|'))
188
+ })
189
+ if (!candidate) break
190
+ remainingEdges.delete([current, candidate].sort().join('|'))
191
+ previous = current
192
+ current = candidate
193
+ }
194
+ if (current !== start || loop.length < 3) continue
195
+ const contour = loop.map(key => {
196
+ const vertex = verticesByKey.get(key)!
197
+ return new THREE.Vector2(vertex.position![1]!, vertex.position![2]!)
198
+ })
199
+ for (const triangle of THREE.ShapeUtils.triangulateShape(contour, [])) {
200
+ const vertices = triangle.map(vertexIndex => verticesByKey.get(loop[vertexIndex]!)!)
201
+ const [a, b, c] = vertices
202
+ const crossX =
203
+ (b!.position![1]! - a!.position![1]!) * (c!.position![2]! - a!.position![2]!) -
204
+ (b!.position![2]! - a!.position![2]!) * (c!.position![1]! - a!.position![1]!)
205
+ const outwardX = half === 'positive' ? -1 : 1
206
+ if (crossX * outwardX < 0) [vertices[1], vertices[2]] = [vertices[2]!, vertices[1]!]
207
+ vertices.forEach(appendCapVertex)
208
+ }
209
+ }
210
+ const clippedGeometry = new THREE.BufferGeometry()
211
+ for (const [name, attribute] of attributes) {
212
+ clippedGeometry.setAttribute(
213
+ name,
214
+ new THREE.Float32BufferAttribute(clippedAttributes.get(name)!, attribute.itemSize, attribute.normalized),
215
+ )
216
+ }
217
+ return clippedGeometry
218
+ }
219
+
220
+ function cumulativeDistances(frames: readonly SakuraCastleSupportFrame[]): number[] {
221
+ const distances = [0]
222
+ for (let index = 1; index < frames.length; index += 1) {
223
+ distances.push(distances[index - 1]! + frames[index - 1]!.point.distanceTo(frames[index]!.point))
224
+ }
225
+ return distances
226
+ }
227
+
228
+ function frameAtDistance(
229
+ frames: readonly SakuraCastleSupportFrame[],
230
+ distances: readonly number[],
231
+ target: number,
232
+ ): SakuraCastleSupportFrame {
233
+ const length = distances.at(-1) ?? 0
234
+ if (target < 0) {
235
+ const start = frames[0]!
236
+ return { ...start, point: start.point.clone().addScaledVector(start.tangent, target) }
237
+ }
238
+ if (target > length) {
239
+ const end = frames.at(-1)!
240
+ return { ...end, point: end.point.clone().addScaledVector(end.tangent, target - length) }
241
+ }
242
+ const clamped = target
243
+ let segment = 0
244
+ while (segment < distances.length - 2 && distances[segment + 1]! < clamped) segment += 1
245
+ const span = Math.max(0.000_001, distances[segment + 1]! - distances[segment]!)
246
+ const progress = (clamped - distances[segment]!) / span
247
+ const start = frames[segment]!
248
+ const end = frames[segment + 1]!
249
+ const tangent = start.tangent.clone().lerp(end.tangent, progress).normalize()
250
+ const right = start.right.clone().lerp(end.right, progress)
251
+ right.addScaledVector(tangent, -right.dot(tangent)).normalize()
252
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
253
+ return {
254
+ point: start.point.clone().lerp(end.point, progress),
255
+ tangent,
256
+ right,
257
+ up,
258
+ width: THREE.MathUtils.lerp(start.width, end.width, progress),
259
+ }
260
+ }
261
+
262
+ function distanceAlongPath(
263
+ frames: readonly SakuraCastleSupportFrame[],
264
+ distances: readonly number[],
265
+ sampled: SakuraCastleSupportFrame,
266
+ ): number {
267
+ let closestDistanceSquared = Number.POSITIVE_INFINITY
268
+ let closestPathDistance = 0
269
+ for (let index = 0; index < frames.length - 1; index += 1) {
270
+ const start = frames[index]!.point
271
+ const end = frames[index + 1]!.point
272
+ const segment = end.clone().sub(start)
273
+ const lengthSquared = segment.lengthSq()
274
+ const interpolation =
275
+ lengthSquared <= 0.000_000_1
276
+ ? 0
277
+ : THREE.MathUtils.clamp(sampled.point.clone().sub(start).dot(segment) / lengthSquared, 0, 1)
278
+ const closest = start.clone().addScaledVector(segment, interpolation)
279
+ const distanceSquared = closest.distanceToSquared(sampled.point)
280
+ if (distanceSquared >= closestDistanceSquared) continue
281
+ closestDistanceSquared = distanceSquared
282
+ closestPathDistance =
283
+ distances[index]! + Math.sqrt(lengthSquared) * interpolation
284
+ }
285
+ return closestPathDistance
286
+ }
287
+
288
+ function deformModule(
289
+ source: THREE.Object3D,
290
+ pathFrames: readonly SakuraCastleSupportFrame[],
291
+ distances: readonly number[],
292
+ centerDistance: number,
293
+ material: THREE.Material,
294
+ name: string,
295
+ longitudinalScale = 1,
296
+ connectionPhase = 0,
297
+ longitudinalHalf?: 'negative' | 'positive',
298
+ ): THREE.Group {
299
+ source.updateMatrixWorld(true)
300
+ const group = new THREE.Group()
301
+ group.name = name
302
+ source.traverse(object => {
303
+ if (!(object instanceof THREE.Mesh)) return
304
+ let geometry = object.geometry.clone().applyMatrix4(object.matrixWorld)
305
+ if (longitudinalHalf) {
306
+ const unclipped = geometry
307
+ geometry = clipLongitudinalHalf(unclipped, longitudinalHalf)
308
+ unclipped.dispose()
309
+ }
310
+ const centerFrame = frameAtDistance(pathFrames, distances, centerDistance)
311
+ const deformationBasis = new THREE.Matrix4().makeBasis(
312
+ centerFrame.tangent.clone().multiplyScalar(longitudinalScale),
313
+ WORLD_UP.clone().multiplyScalar(UNDER_TRACK_SUPPORT_DEPTH / AUTHORED_SUPPORT_DEPTH),
314
+ centerFrame.right.clone().multiplyScalar(UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE),
315
+ )
316
+ if (deformationBasis.determinant() * object.matrixWorld.determinant() < 0) {
317
+ reverseTriangleWinding(geometry)
318
+ }
319
+ const position = geometry.getAttribute('position')
320
+ const local = new THREE.Vector3()
321
+ const normal = geometry.getAttribute('normal')
322
+ const fades = new Float32Array(position.count)
323
+ const fadeDepthModes = new Float32Array(position.count)
324
+ const fadeDepths = new Float32Array(position.count)
325
+ const fadeStarts = new Float32Array(position.count)
326
+ const fadeEnds = new Float32Array(position.count)
327
+ const metric = new Float32Array(position.count * 4)
328
+ const orientedUvs = new Float32Array(position.count * 2)
329
+ for (let index = 0; index < position.count; index += 1) {
330
+ local.fromBufferAttribute(position, index)
331
+ const scaledLocalY = local.y * (UNDER_TRACK_SUPPORT_DEPTH / AUTHORED_SUPPORT_DEPTH)
332
+ const supportDepth = Math.max(0, -scaledLocalY)
333
+ const pathDistance = centerDistance + local.x * longitudinalScale
334
+ const pathFrame = frameAtDistance(pathFrames, distances, pathDistance)
335
+ const world = pathFrame.point
336
+ .clone()
337
+ .addScaledVector(pathFrame.right, local.z * UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE)
338
+ .addScaledVector(WORLD_UP, scaledLocalY - DECK_UNDERSIDE_OFFSET)
339
+ position.setXYZ(index, world.x, world.y, world.z)
340
+ fades[index] = underTrackSupportFadeAtDepth(supportDepth)
341
+ fadeDepthModes[index] = 1
342
+ fadeDepths[index] = supportDepth
343
+ fadeStarts[index] = UNDER_TRACK_SUPPORT_FADE_START_DEPTH
344
+ fadeEnds[index] = UNDER_TRACK_SUPPORT_DEPTH
345
+ orientedUvs.set(
346
+ [connectionPhase + pathDistance, world.clone().sub(pathFrame.point).dot(pathFrame.up)],
347
+ index * 2,
348
+ )
349
+ // z=1 selects directional UVs; w=0 declares those UVs in world units so
350
+ // the recipe retains ownership of scale while the path owns orientation.
351
+ metric.set([0, 0, 1, 0], index * 4)
352
+ }
353
+ position.needsUpdate = true
354
+ if (normal) geometry.deleteAttribute('normal')
355
+ geometry.computeVertexNormals()
356
+ geometry.setAttribute('uv', new THREE.BufferAttribute(orientedUvs, 2))
357
+ geometry.setAttribute('supportFade', new THREE.BufferAttribute(fades, 1))
358
+ geometry.setAttribute('supportFadeDepthMode', new THREE.BufferAttribute(fadeDepthModes, 1))
359
+ geometry.setAttribute('supportFadeDepth', new THREE.BufferAttribute(fadeDepths, 1))
360
+ geometry.setAttribute('supportFadeStart', new THREE.BufferAttribute(fadeStarts, 1))
361
+ geometry.setAttribute('supportFadeEnd', new THREE.BufferAttribute(fadeEnds, 1))
362
+ geometry.setAttribute('themeNetUv', new THREE.BufferAttribute(metric, 4))
363
+ geometry.computeBoundingBox()
364
+ geometry.computeBoundingSphere()
365
+ const mesh = new THREE.Mesh(geometry, material)
366
+ mesh.name = `${name}-${object.name}`
367
+ mesh.castShadow = true
368
+ mesh.receiveShadow = true
369
+ mesh.userData.decorative = true
370
+ mesh.userData.visualOnly = true
371
+ mesh.userData.supportStyle = 'path-deformed-approved-japanese-sakura-castle-v2'
372
+ group.add(mesh)
373
+ })
374
+ return group
375
+ }
376
+
377
+ function addTerminalHalfArch(
378
+ host: THREE.Group,
379
+ arch: THREE.Object3D,
380
+ pathFrames: readonly SakuraCastleSupportFrame[],
381
+ distances: readonly number[],
382
+ spanLength: number,
383
+ terminalDistance: number,
384
+ half: 'negative' | 'positive',
385
+ connectionPhase: number,
386
+ material: THREE.Material,
387
+ name: string,
388
+ ): void {
389
+ if (spanLength <= 0.000_001) return
390
+ const terminal = deformModule(
391
+ arch,
392
+ pathFrames,
393
+ distances,
394
+ terminalDistance,
395
+ material,
396
+ name,
397
+ (spanLength * 2) / AUTHORED_SUPPORT_BAY_LENGTH,
398
+ connectionPhase,
399
+ half,
400
+ )
401
+ terminal.userData.supportRole = 'exposed-terminal-half-arch'
402
+ host.add(terminal)
403
+ }
404
+
405
+ export function addJapaneseSakuraCastleSupportModel(
406
+ host: THREE.Group,
407
+ pieceId: string,
408
+ pathFrames: readonly SakuraCastleSupportFrame[],
409
+ sampledFrames: readonly SakuraCastleSupportFrame[],
410
+ fallbackMaterial: THREE.Material,
411
+ options: SakuraCastleSupportOptions,
412
+ template?: THREE.Object3D,
413
+ ): Promise<THREE.Group> {
414
+ host.userData.modelUrl = JAPANESE_SAKURA_CASTLE_SUPPORT_MODEL_URL
415
+ host.userData.constructionContract = 'approved-japanese-sakura-castle-blender-v2'
416
+ host.userData.endpointRule = 'cap-exposed-ends;outgoing-piece-owns-connected-arch'
417
+ host.userData.supportBentCount = sampledFrames.length
418
+ host.userData.supportDepth = UNDER_TRACK_SUPPORT_DEPTH
419
+ host.userData.supportBayLength = UNDER_TRACK_SUPPORT_BAY_LENGTH
420
+ host.userData.hasDragonHero = false
421
+ host.userData.dragonSideCoverage = 'none'
422
+
423
+ if (typeof document === 'undefined' && !template) {
424
+ host.userData.modelReady = Promise.resolve(host)
425
+ return host.userData.modelReady as Promise<THREE.Group>
426
+ }
427
+
428
+ const ready = template ? Promise.resolve(template) : loadTemplate()
429
+ host.userData.modelReady = ready.then(source => {
430
+ if (host.userData.supportDisposed === true) return host
431
+ const pier = source.getObjectByName('Pier_Module')
432
+ const arch = source.getObjectByName('ArchSpan_Module')
433
+ if (!pier || !arch) throw new Error('Japanese Sakura castle support GLB is missing its authored modules')
434
+
435
+ const distances = cumulativeDistances(pathFrames)
436
+ const sampledDistances = sampledFrames.map(frame => distanceAlongPath(pathFrames, distances, frame))
437
+ sampledFrames.forEach((_frame, index) => {
438
+ host.add(
439
+ deformModule(
440
+ pier,
441
+ pathFrames,
442
+ distances,
443
+ sampledDistances[index]!,
444
+ fallbackMaterial,
445
+ `${pieceId}-castle-pier-${index + 1}`,
446
+ UNDER_TRACK_SUPPORT_FOOTPRINT_SCALE,
447
+ options.connectionPhase,
448
+ ),
449
+ )
450
+ })
451
+ const ownedBays = options.ownedBayDistances
452
+ ? [...options.ownedBayDistances]
453
+ : sampledDistances.slice(0, -1).map((start, index) => [start, sampledDistances[index + 1]!] as const)
454
+ if (!options.ownedBayDistances && options.capEnd === false && sampledDistances.length > 0) {
455
+ ownedBays.push([sampledDistances.at(-1)!, sampledDistances.at(-1)! + UNDER_TRACK_SUPPORT_BAY_LENGTH])
456
+ }
457
+ for (const [index, [startDistance, endDistance]] of ownedBays.entries()) {
458
+ const crossesEndConnector =
459
+ endDistance > (distances.at(-1) ?? 0) + 0.000_001 ||
460
+ (!options.ownedBayDistances && options.capEnd === false && index === ownedBays.length - 1)
461
+ host.add(
462
+ deformModule(
463
+ arch,
464
+ pathFrames,
465
+ distances,
466
+ (startDistance + endDistance) / 2,
467
+ fallbackMaterial,
468
+ crossesEndConnector ? `${pieceId}-castle-connector-arch` : `${pieceId}-castle-arch-${index + 1}`,
469
+ (endDistance - startDistance) / AUTHORED_SUPPORT_BAY_LENGTH,
470
+ options.connectionPhase,
471
+ ),
472
+ )
473
+ }
474
+
475
+ const stoneMaterial = fallbackMaterial
476
+ const pathLength = distances.at(-1) ?? 0
477
+ const firstPierDistance = sampledDistances[0] ?? pathLength * 0.5
478
+ const lastPierDistance = sampledDistances.at(-1) ?? pathLength * 0.5
479
+ if (options.capStart !== false) {
480
+ addTerminalHalfArch(
481
+ host,
482
+ arch,
483
+ pathFrames,
484
+ distances,
485
+ firstPierDistance,
486
+ 0,
487
+ 'positive',
488
+ options.connectionPhase ?? 0,
489
+ stoneMaterial,
490
+ `${pieceId}-castle-start-half-arch`,
491
+ )
492
+ }
493
+ if (options.capEnd !== false) {
494
+ addTerminalHalfArch(
495
+ host,
496
+ arch,
497
+ pathFrames,
498
+ distances,
499
+ pathLength - lastPierDistance,
500
+ pathLength,
501
+ 'negative',
502
+ options.connectionPhase ?? 0,
503
+ stoneMaterial,
504
+ `${pieceId}-castle-end-half-arch`,
505
+ )
506
+ }
507
+ return host
508
+ })
509
+ return host.userData.modelReady as Promise<THREE.Group>
510
+ }