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