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