@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,729 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualSphereGeometry, VisualExtrudeGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
5
+
6
+ import { addJapaneseSakuraKawaraRoof } from './japanese-sakura-kawara-roof'
7
+ import {
8
+ addEgyptElevatorBlenderPlatform,
9
+ addEgyptElevatorBlenderShell,
10
+ applyEgyptElevatorBlenderMaterials,
11
+ type EgyptElevatorBlenderMaterials,
12
+ } from './egypt-elevator-blender'
13
+ import { addSpaceStationWallVisual, type SpaceStationWallPathRun } from './track-appearance-space-station-wall'
14
+ import { createCanonicalWallRun, type RawWallFrame } from './track-surface-topology'
15
+
16
+ export const EGYPT_ELEVATOR_THEME_ID = 'ancient-egypt'
17
+ export const JAPAN_ELEVATOR_THEME_ID = 'japanese-sakura-lake'
18
+ export const SPACE_ELEVATOR_THEME_ID = 'intergalactic-space-station'
19
+
20
+ export interface PortalDerivedElevatorMaterials {
21
+ egypt: {
22
+ sandstone: THREE.Material
23
+ carvedStone: THREE.Material
24
+ pyramidStone: THREE.Material
25
+ pyramidGold: THREE.Material
26
+ bronze: THREE.Material
27
+ gold: THREE.Material
28
+ paving: THREE.Material
29
+ signal: THREE.Material
30
+ }
31
+ japan: {
32
+ vermilion: THREE.Material
33
+ timber: THREE.Material
34
+ plaster: THREE.Material
35
+ stone: THREE.Material
36
+ kawara: THREE.Material
37
+ hardware: THREE.Material
38
+ signal: THREE.Material
39
+ }
40
+ space: {
41
+ chassis: THREE.Material
42
+ panel: THREE.Material
43
+ rib: THREE.Material
44
+ trim: THREE.Material
45
+ light: THREE.Material
46
+ }
47
+ }
48
+
49
+ interface ElevatorSkinOptions {
50
+ activeThemeId?: string
51
+ id: string
52
+ bottom: THREE.Vector3
53
+ rotation: THREE.Quaternion
54
+ width: number
55
+ height: number
56
+ depth: number
57
+ releaseHeight: number
58
+ materials: PortalDerivedElevatorMaterials
59
+ }
60
+
61
+ interface Batch {
62
+ name: string
63
+ material: THREE.Material
64
+ parts: THREE.BufferGeometry[]
65
+ }
66
+
67
+ interface RearEnclosureLayout {
68
+ panelZ: number
69
+ panelBottom: number
70
+ panelTop: number
71
+ openingBottom: number
72
+ openingTop: number
73
+ lowerHeight: number
74
+ upperHeight: number
75
+ }
76
+
77
+ const UNIT = new THREE.Vector3(1, 1, 1)
78
+ const IDENTITY = new THREE.Quaternion()
79
+ const LOCAL_UP = new THREE.Vector3(0, 1, 0)
80
+
81
+ function rearEnclosureLayout(height: number, depth: number, releaseHeight: number): RearEnclosureLayout {
82
+ const panelBottom = -0.12
83
+ const panelTop = height + 0.68
84
+ const openingBottom = THREE.MathUtils.clamp(releaseHeight - 0.34, 0.9, height - 1.5)
85
+ const openingTop = Math.min(height + 0.42, releaseHeight + 1.48)
86
+ return {
87
+ panelZ: -depth / 2 - 0.05,
88
+ panelBottom,
89
+ panelTop,
90
+ openingBottom,
91
+ openingTop,
92
+ lowerHeight: openingBottom - panelBottom,
93
+ upperHeight: Math.max(0.24, panelTop - openingTop),
94
+ }
95
+ }
96
+
97
+ function markRearEnclosure(root: THREE.Group, layout: RearEnclosureLayout, construction: string): void {
98
+ root.userData.hasBackWall = true
99
+ root.userData.releaseOpeningBottom = layout.openingBottom
100
+ root.userData.releaseOpeningTop = layout.openingTop
101
+ root.userData.rearEnclosureConstruction = construction
102
+ }
103
+
104
+ function metricPlanarUvs(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
105
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
106
+ if (prepared !== geometry) geometry.dispose()
107
+ const position = prepared.getAttribute('position')
108
+ const normal = prepared.getAttribute('normal')
109
+ const uv = new Float32Array(position.count * 2)
110
+ const themeNetUv = new Float32Array(position.count * 4)
111
+ for (let index = 0; index < position.count; index += 1) {
112
+ const x = position.getX(index)
113
+ const y = position.getY(index)
114
+ const z = position.getZ(index)
115
+ const nx = Math.abs(normal.getX(index))
116
+ const ny = Math.abs(normal.getY(index))
117
+ const nz = Math.abs(normal.getZ(index))
118
+ if (ny >= nx && ny >= nz) {
119
+ uv[index * 2] = x
120
+ uv[index * 2 + 1] = z
121
+ } else if (nx >= nz) {
122
+ uv[index * 2] = z
123
+ uv[index * 2 + 1] = y
124
+ } else {
125
+ uv[index * 2] = x
126
+ uv[index * 2 + 1] = y
127
+ }
128
+ themeNetUv[index * 4 + 2] = 1
129
+ }
130
+ prepared.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
131
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(themeNetUv, 4))
132
+ return prepared
133
+ }
134
+
135
+ function transformed(
136
+ geometry: THREE.BufferGeometry,
137
+ position: THREE.Vector3,
138
+ rotation = IDENTITY,
139
+ scale = UNIT,
140
+ ): THREE.BufferGeometry {
141
+ geometry.applyMatrix4(new THREE.Matrix4().compose(position, rotation, scale))
142
+ return metricPlanarUvs(geometry)
143
+ }
144
+
145
+ function roundedBox(size: THREE.Vector3, position: THREE.Vector3, radius = 0.045): THREE.BufferGeometry {
146
+ return transformed(
147
+ new VisualBoxGeometry(size.x, size.y, size.z, 2, Math.min(radius, size.x / 4, size.y / 4, size.z / 4)),
148
+ position,
149
+ )
150
+ }
151
+
152
+ function beam(start: THREE.Vector3, end: THREE.Vector3, width: number, depth: number): THREE.BufferGeometry {
153
+ const direction = end.clone().sub(start)
154
+ return transformed(
155
+ new VisualBoxGeometry(width, direction.length(), depth, 2, Math.min(width, depth) * 0.16),
156
+ start.clone().lerp(end, 0.5),
157
+ new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, direction.clone().normalize()),
158
+ )
159
+ }
160
+
161
+ function addBatch(group: THREE.Group, id: string, batch: Batch): THREE.Mesh {
162
+ const geometry = mergeGeometries(batch.parts, false)
163
+ batch.parts.forEach(part => part.dispose())
164
+ if (!geometry) throw new Error(`Unable to merge elevator batch "${batch.name}".`)
165
+ geometry.computeBoundingBox()
166
+ geometry.computeBoundingSphere()
167
+ geometry.userData.visualOnly = true
168
+ const mesh = new THREE.Mesh(geometry, batch.material)
169
+ mesh.name = `${id}-${batch.name}`
170
+ mesh.castShadow = true
171
+ mesh.receiveShadow = true
172
+ mesh.userData.decorative = true
173
+ mesh.userData.visualOnly = true
174
+ group.add(mesh)
175
+ return mesh
176
+ }
177
+
178
+ function markTheme(group: THREE.Group, themeId: string, style: string): void {
179
+ group.userData.themeOnly = themeId
180
+ group.userData.decorative = true
181
+ group.userData.visualOnly = true
182
+ group.userData.elevatorStyle = style
183
+ group.traverse(object => {
184
+ object.userData.themeOnly = themeId
185
+ object.userData.decorative = true
186
+ object.userData.visualOnly = true
187
+ object.userData.elevatorStyle = style
188
+ if (object instanceof THREE.Mesh || object instanceof THREE.InstancedMesh) {
189
+ object.castShadow = true
190
+ object.receiveShadow = true
191
+ }
192
+ })
193
+ }
194
+
195
+ function liveEgyptBlenderMaterials(materials: PortalDerivedElevatorMaterials): EgyptElevatorBlenderMaterials {
196
+ return {
197
+ get sandstone() {
198
+ return materials.egypt.sandstone
199
+ },
200
+ get carvedStone() {
201
+ return materials.egypt.carvedStone
202
+ },
203
+ get pyramidStone() {
204
+ return materials.egypt.pyramidStone
205
+ },
206
+ get pyramidGold() {
207
+ return materials.egypt.pyramidGold
208
+ },
209
+ get bronze() {
210
+ return materials.egypt.bronze
211
+ },
212
+ get paving() {
213
+ return materials.egypt.paving
214
+ },
215
+ }
216
+ }
217
+
218
+ function addEgyptSkin(group: THREE.Group, options: ElevatorSkinOptions): THREE.Group {
219
+ const { id, width, height, depth, releaseHeight, materials } = options
220
+ const root = new THREE.Group()
221
+ root.name = `${id}-egypt-temple-hoist`
222
+ const rear = rearEnclosureLayout(height, depth, releaseHeight)
223
+ markRearEnclosure(root, rear, 'blender-authored-recessed-ashlar-chamber')
224
+ root.userData.roofConstruction = 'six-course-tura-limestone-pyramid-with-hammered-gold-pyramidion'
225
+ root.userData.platformCount = 4
226
+ root.userData.approvedReference = '/docs/material-contracts/theme-elevator-egypt-approved-v3.png'
227
+ const blenderMaterials = liveEgyptBlenderMaterials(materials)
228
+ root.userData.applySemanticTheme = () => applyEgyptElevatorBlenderMaterials(root, blenderMaterials)
229
+ addEgyptElevatorBlenderShell(root, id, blenderMaterials, new THREE.Vector3(width / 5.5, height / 8.5, depth / 4))
230
+
231
+ root.position.copy(options.bottom)
232
+ root.quaternion.copy(options.rotation)
233
+ markTheme(root, EGYPT_ELEVATOR_THEME_ID, 'approved-egypt-blender-pylon-hoist-v5')
234
+ group.add(root)
235
+ return root
236
+ }
237
+
238
+ function addJapanSkin(group: THREE.Group, options: ElevatorSkinOptions): THREE.Group {
239
+ const { id, width, height, depth, releaseHeight, materials } = options
240
+ const root = new THREE.Group()
241
+ root.name = `${id}-japanese-gatehouse-hoist`
242
+ const vermilion: THREE.BufferGeometry[] = []
243
+ const timber: THREE.BufferGeometry[] = []
244
+ const plaster: THREE.BufferGeometry[] = []
245
+ const plasterGables: THREE.BufferGeometry[] = []
246
+ const rearPlaster: THREE.BufferGeometry[] = []
247
+ const stone: THREE.BufferGeometry[] = []
248
+ const hardware: THREE.BufferGeometry[] = []
249
+ const postX = width / 2 + 0.2
250
+ const postZ = depth / 2 + 0.18
251
+ const roofHalfDepth = depth / 2 + 0.46
252
+ const ridgeY = height + 1.7
253
+ const eaveY = ridgeY - 0.82 * (roofHalfDepth / 1.32)
254
+
255
+ for (const x of [-postX, postX]) {
256
+ for (const z of [-postZ, postZ]) {
257
+ vermilion.push(
258
+ roundedBox(new THREE.Vector3(0.54, height + 0.5, 0.54), new THREE.Vector3(x, height / 2 + 0.02, z), 0.07),
259
+ )
260
+ stone.push(
261
+ roundedBox(new THREE.Vector3(0.78, 0.34, 0.78), new THREE.Vector3(x, -0.19, z), 0.055),
262
+ roundedBox(new THREE.Vector3(0.64, 0.2, 0.66), new THREE.Vector3(x, 0.05, z), 0.04),
263
+ )
264
+ hardware.push(
265
+ roundedBox(
266
+ new THREE.Vector3(0.15, 0.34, 0.055),
267
+ new THREE.Vector3(x - 0.14, 0.3, z + Math.sign(z) * 0.28),
268
+ 0.018,
269
+ ),
270
+ roundedBox(
271
+ new THREE.Vector3(0.15, 0.34, 0.055),
272
+ new THREE.Vector3(x + 0.14, 0.3, z + Math.sign(z) * 0.28),
273
+ 0.018,
274
+ ),
275
+ )
276
+ }
277
+ }
278
+
279
+ for (const z of [-postZ, postZ]) {
280
+ vermilion.push(
281
+ roundedBox(new THREE.Vector3(width + 1.18, 0.34, 0.68), new THREE.Vector3(0, height + 0.06, z), 0.055),
282
+ roundedBox(new THREE.Vector3(width + 0.42, 0.22, 0.54), new THREE.Vector3(0, height - 0.27, z), 0.045),
283
+ )
284
+ for (let bracket = -6; bracket <= 6; bracket += 1) {
285
+ vermilion.push(
286
+ roundedBox(
287
+ new THREE.Vector3(0.18, 0.22, 0.82),
288
+ new THREE.Vector3((bracket * width) / 13, height + 0.28, z),
289
+ 0.025,
290
+ ),
291
+ )
292
+ }
293
+ for (const side of [-1, 1] as const) {
294
+ vermilion.push(
295
+ beam(
296
+ new THREE.Vector3(side * (postX - 0.08), height - 0.72, z),
297
+ new THREE.Vector3(side * (postX - 0.6), height - 0.18, z),
298
+ 0.2,
299
+ 0.34,
300
+ ),
301
+ )
302
+ plaster.push(
303
+ roundedBox(
304
+ new THREE.Vector3(0.3, 0.8, 0.46),
305
+ new THREE.Vector3(side * (postX - 0.31), height - 0.58, z),
306
+ 0.035,
307
+ ),
308
+ roundedBox(new THREE.Vector3(0.36, 0.2, 0.52), new THREE.Vector3(side * (postX - 0.5), height - 0.18, z), 0.03),
309
+ )
310
+ }
311
+ }
312
+
313
+ const sharedRear = rearEnclosureLayout(height, depth, releaseHeight)
314
+ const rear: RearEnclosureLayout = {
315
+ ...sharedRear,
316
+ panelTop: height + 0.1,
317
+ upperHeight: Math.max(0.24, height + 0.1 - sharedRear.openingTop),
318
+ }
319
+ markRearEnclosure(root, rear, 'castle-plaster-panels-with-vermilion-release-frame')
320
+ for (const side of [-1, 1] as const) {
321
+ const x = side * (postX - 0.11)
322
+ // The side infill is a continuous architectural backing from its stone
323
+ // sill to the roof gable. Stopping it at the nominal wall height exposed
324
+ // the diagonal timber behind it as triangular "missing material" wedges.
325
+ const plasterBottom = 0.45
326
+ const plasterTop = eaveY + 0.02
327
+ // Underlap the corner posts as well; the old depth stopped 0.3 units
328
+ // before each post and exposed the framing at both vertical seams.
329
+ const plasterDepth = depth - 0.14
330
+ plaster.push(
331
+ roundedBox(
332
+ new THREE.Vector3(0.16, plasterTop - plasterBottom, plasterDepth),
333
+ new THREE.Vector3(x, (plasterBottom + plasterTop) / 2, 0),
334
+ 0.03,
335
+ ),
336
+ )
337
+ stone.push(roundedBox(new THREE.Vector3(0.23, 0.32, depth - 0.64), new THREE.Vector3(x, 0.16, 0), 0.03))
338
+ for (const y of [0.62, 2.58, 4.54, 6.5, height - 0.32]) {
339
+ timber.push(roundedBox(new THREE.Vector3(0.25, 0.16, depth - 0.5), new THREE.Vector3(x, y, 0), 0.035))
340
+ }
341
+ }
342
+ rearPlaster.push(
343
+ roundedBox(
344
+ new THREE.Vector3(width - 0.46, rear.lowerHeight, 0.16),
345
+ new THREE.Vector3(0, (rear.panelBottom + rear.openingBottom) / 2, rear.panelZ),
346
+ 0.035,
347
+ ),
348
+ roundedBox(
349
+ new THREE.Vector3(width - 0.46, rear.upperHeight, 0.16),
350
+ new THREE.Vector3(0, (rear.openingTop + rear.panelTop) / 2, rear.panelZ),
351
+ 0.035,
352
+ ),
353
+ )
354
+ for (const y of [rear.openingBottom - 0.08, rear.openingTop + 0.08]) {
355
+ vermilion.push(
356
+ roundedBox(new THREE.Vector3(width - 0.14, 0.2, 0.24), new THREE.Vector3(0, y, rear.panelZ + 0.02), 0.04),
357
+ )
358
+ }
359
+
360
+ const gableShape = new THREE.Shape()
361
+ gableShape.moveTo(-roofHalfDepth + 0.18, eaveY - 0.02)
362
+ gableShape.lineTo(0, ridgeY - 0.15)
363
+ gableShape.lineTo(roofHalfDepth - 0.18, eaveY - 0.02)
364
+ gableShape.closePath()
365
+ for (const side of [-1, 1] as const) {
366
+ const gable = new VisualExtrudeGeometry(gableShape, {
367
+ depth: 0.16,
368
+ steps: 1,
369
+ bevelEnabled: true,
370
+ bevelSegments: 2,
371
+ bevelSize: 0.025,
372
+ bevelThickness: 0.018,
373
+ })
374
+ gable.applyMatrix4(
375
+ new THREE.Matrix4().compose(
376
+ new THREE.Vector3(side * (postX - 0.08), 0, 0),
377
+ new THREE.Quaternion().setFromAxisAngle(LOCAL_UP, (side * Math.PI) / 2),
378
+ UNIT,
379
+ ),
380
+ )
381
+ plasterGables.push(metricPlanarUvs(gable))
382
+ const gableX = side * postX
383
+ timber.push(
384
+ roundedBox(
385
+ new THREE.Vector3(0.2, 0.18, roofHalfDepth * 2 - 0.28),
386
+ new THREE.Vector3(gableX, eaveY - 0.1, 0),
387
+ 0.035,
388
+ ),
389
+ beam(
390
+ new THREE.Vector3(gableX, eaveY - 0.01, -roofHalfDepth + 0.12),
391
+ new THREE.Vector3(gableX, ridgeY - 0.05, 0),
392
+ 0.18,
393
+ 0.2,
394
+ ),
395
+ beam(
396
+ new THREE.Vector3(gableX, ridgeY - 0.05, 0),
397
+ new THREE.Vector3(gableX, eaveY - 0.01, roofHalfDepth - 0.12),
398
+ 0.18,
399
+ 0.2,
400
+ ),
401
+ )
402
+ }
403
+
404
+ addBatch(root, id, { name: 'japan-vermilion-joinery', material: materials.japan.vermilion, parts: vermilion })
405
+ addBatch(root, id, { name: 'japan-castle-timber-framing', material: materials.japan.timber, parts: timber })
406
+ addBatch(root, id, { name: 'japan-castle-plaster-infill', material: materials.japan.plaster, parts: plaster })
407
+ addBatch(root, id, { name: 'japan-castle-plaster-gables', material: materials.japan.plaster, parts: plasterGables })
408
+ addBatch(root, id, { name: 'japan-rear-enclosure', material: materials.japan.plaster, parts: rearPlaster })
409
+ addBatch(root, id, { name: 'japan-foundation-stone', material: materials.japan.stone, parts: stone })
410
+ addBatch(root, id, { name: 'japan-black-silver-hardware', material: materials.japan.hardware, parts: hardware })
411
+ addJapaneseSakuraKawaraRoof(root, materials.japan.kawara, {
412
+ name: `${id}-japan`,
413
+ ridgeY: height + 1.7,
414
+ widthScale: (width + 1.18) / 5.48,
415
+ halfDepth: roofHalfDepth,
416
+ capCourses: 9,
417
+ themeRolePrefix: 'elevator-sakura',
418
+ })
419
+ root.userData.constructionContract = 'japanese-sakura-obstacle-kit-elevator-v3'
420
+ root.userData.sidePlasterCoverage = 'continuous-sill-to-gable'
421
+ root.userData.reusedComponents = [
422
+ 'portal-kawara-roof',
423
+ 'portal-vermilion-frame',
424
+ 'castle-wall-infill',
425
+ 'bridge-hardware',
426
+ ]
427
+ root.position.copy(options.bottom)
428
+ root.quaternion.copy(options.rotation)
429
+ markTheme(root, JAPAN_ELEVATOR_THEME_ID, 'portal-derived-japanese-gatehouse-hoist')
430
+ group.add(root)
431
+ return root
432
+ }
433
+
434
+ function addSpaceSkin(group: THREE.Group, options: ElevatorSkinOptions): THREE.Group {
435
+ const { id, width, height, depth, releaseHeight, materials } = options
436
+ const root = new THREE.Group()
437
+ root.name = `${id}-space-armored-cargo-lift`
438
+ const chassis: THREE.BufferGeometry[] = []
439
+ const panel: THREE.BufferGeometry[] = []
440
+ const rib: THREE.BufferGeometry[] = []
441
+ const trim: THREE.BufferGeometry[] = []
442
+ const lights: THREE.BufferGeometry[] = []
443
+ const postX = width / 2 + 0.27
444
+ const postZ = depth / 2 + 0.24
445
+ const sideCoreX = width / 2 + 0.14
446
+ const wallModuleBottoms = [0.18, 4.12]
447
+ const wallModuleHeight = 3.54
448
+ const wallDepthStart = -depth / 2 - 0.12
449
+ const wallDepthEnd = depth / 2 + 0.12
450
+ const spaceWallMaterials = {
451
+ chassis: materials.space.chassis,
452
+ panel: materials.space.panel,
453
+ rib: materials.space.rib,
454
+ trim: materials.space.trim,
455
+ light: materials.space.light,
456
+ }
457
+ const sideWallRuns: SpaceStationWallPathRun[] = []
458
+
459
+ const canonicalElevatorWallRun = (frames: readonly RawWallFrame[]): SpaceStationWallPathRun => {
460
+ const run = createCanonicalWallRun(frames, { role: 'outer' })
461
+ if (!run) throw new Error('Space Station elevator wall requires a valid canonical boundary')
462
+ return run
463
+ }
464
+ const sideRun = (x: number, baseY: number): SpaceStationWallPathRun =>
465
+ canonicalElevatorWallRun(
466
+ [wallDepthStart, wallDepthEnd].map(z => ({
467
+ base: new THREE.Vector3(x, baseY, z),
468
+ tangent: new THREE.Vector3(0, 0, 1),
469
+ up: new THREE.Vector3(0, 1, 0),
470
+ outward: new THREE.Vector3(-1, 0, 0),
471
+ })),
472
+ )
473
+ const rearRun = (baseY: number): SpaceStationWallPathRun =>
474
+ canonicalElevatorWallRun(
475
+ [-width / 2 - 0.12, width / 2 + 0.12].map(x => ({
476
+ base: new THREE.Vector3(x, baseY, -depth / 2 - 0.14),
477
+ tangent: new THREE.Vector3(1, 0, 0),
478
+ up: new THREE.Vector3(0, 1, 0),
479
+ outward: new THREE.Vector3(0, 0, 1),
480
+ })),
481
+ )
482
+
483
+ for (const x of [-postX, postX]) {
484
+ for (const z of [-postZ, postZ]) {
485
+ chassis.push(
486
+ roundedBox(new THREE.Vector3(0.62, height + 0.82, 0.58), new THREE.Vector3(x, height / 2 + 0.06, z), 0.09),
487
+ )
488
+ rib.push(
489
+ roundedBox(new THREE.Vector3(0.82, 0.32, 0.78), new THREE.Vector3(x, -0.2, z), 0.065),
490
+ roundedBox(new THREE.Vector3(0.76, 0.3, 0.72), new THREE.Vector3(x, height + 0.43, z), 0.065),
491
+ )
492
+ for (const y of [0.38, 4.04, 8.18]) {
493
+ trim.push(roundedBox(new THREE.Vector3(0.68, 0.18, 0.66), new THREE.Vector3(x, y, z), 0.035))
494
+ }
495
+ }
496
+ }
497
+
498
+ for (const baseY of wallModuleBottoms) {
499
+ for (const side of [-1, 1] as const) {
500
+ chassis.push(
501
+ roundedBox(
502
+ new THREE.Vector3(0.2, wallModuleHeight, wallDepthEnd - wallDepthStart),
503
+ new THREE.Vector3(side * sideCoreX, baseY + wallModuleHeight / 2, 0),
504
+ 0.035,
505
+ ),
506
+ )
507
+ }
508
+ sideWallRuns.push(sideRun(-sideCoreX, baseY), sideRun(sideCoreX, baseY))
509
+ }
510
+
511
+ addSpaceStationWallVisual(root, `${id}-space-side-armored-bays`, sideWallRuns, wallModuleHeight, spaceWallMaterials)
512
+
513
+ const rear = rearEnclosureLayout(height, depth, releaseHeight)
514
+ markRearEnclosure(root, rear, 'large-space-station-armored-bays-with-release-opening')
515
+ chassis.push(
516
+ roundedBox(
517
+ new THREE.Vector3(width + 0.24, rear.lowerHeight, 0.2),
518
+ new THREE.Vector3(0, (rear.panelBottom + rear.openingBottom) / 2, -depth / 2 - 0.14),
519
+ 0.035,
520
+ ),
521
+ )
522
+ addSpaceStationWallVisual(
523
+ root,
524
+ `${id}-space-rear-lower-module`,
525
+ [rearRun(rear.panelBottom)],
526
+ rear.lowerHeight,
527
+ spaceWallMaterials,
528
+ )
529
+ const upperRearHeight = rear.panelTop - rear.openingTop
530
+ chassis.push(
531
+ roundedBox(
532
+ new THREE.Vector3(width + 0.24, upperRearHeight, 0.2),
533
+ new THREE.Vector3(0, rear.openingTop + upperRearHeight / 2, -depth / 2 - 0.14),
534
+ 0.035,
535
+ ),
536
+ )
537
+ addSpaceStationWallVisual(
538
+ root,
539
+ `${id}-space-rear-upper-module`,
540
+ [rearRun(rear.openingTop)],
541
+ upperRearHeight,
542
+ spaceWallMaterials,
543
+ )
544
+
545
+ for (const y of [rear.openingBottom - 0.08, rear.openingTop + 0.08]) {
546
+ rib.push(
547
+ roundedBox(new THREE.Vector3(width - 0.08, 0.22, 0.28), new THREE.Vector3(0, y, rear.panelZ + 0.01), 0.055),
548
+ )
549
+ lights.push(roundedBox(new THREE.Vector3(1.2, 0.08, 0.08), new THREE.Vector3(0, y, rear.panelZ + 0.19), 0.035))
550
+ }
551
+
552
+ const frontHeaderBase = height - 0.22
553
+ const headerHeight = 0.82
554
+ chassis.push(
555
+ roundedBox(
556
+ new THREE.Vector3(width + 0.92, headerHeight, 0.24),
557
+ new THREE.Vector3(0, frontHeaderBase + headerHeight / 2, postZ),
558
+ 0.055,
559
+ ),
560
+ )
561
+ panel.push(
562
+ roundedBox(
563
+ new THREE.Vector3(width - 0.34, headerHeight - 0.26, 0.09),
564
+ new THREE.Vector3(0, frontHeaderBase + headerHeight / 2, postZ + 0.17),
565
+ 0.035,
566
+ ),
567
+ )
568
+ rib.push(
569
+ roundedBox(
570
+ new THREE.Vector3(0.18, headerHeight - 0.12, 0.16),
571
+ new THREE.Vector3(0, frontHeaderBase + headerHeight / 2, postZ + 0.22),
572
+ 0.04,
573
+ ),
574
+ )
575
+ lights.push(
576
+ roundedBox(
577
+ new THREE.Vector3(1.04, 0.1, 0.08),
578
+ new THREE.Vector3(0, frontHeaderBase + headerHeight / 2, postZ + 0.28),
579
+ 0.035,
580
+ ),
581
+ )
582
+
583
+ for (const side of [-1, 1] as const) {
584
+ for (const y of [2.08, 6.02]) {
585
+ lights.push(
586
+ roundedBox(new THREE.Vector3(0.26, 0.045, 0.07), new THREE.Vector3(side * postX, y, postZ + 0.32), 0.014),
587
+ )
588
+ }
589
+ }
590
+
591
+ addBatch(root, id, { name: 'space-load-chassis', material: materials.space.chassis, parts: chassis })
592
+ addBatch(root, id, { name: 'space-header-panel', material: materials.space.panel, parts: panel })
593
+ addBatch(root, id, { name: 'space-wrapped-ribs', material: materials.space.rib, parts: rib })
594
+ addBatch(root, id, { name: 'space-structural-trim', material: materials.space.trim, parts: trim })
595
+ addBatch(root, id, { name: 'space-cyan-channels', material: materials.space.light, parts: lights })
596
+ root.userData.constructionContract = 'space-station-armored-wall-elevator-v3'
597
+ root.userData.reusedComponents = [
598
+ 'armored-wall-bay',
599
+ 'wrapped-seam-rib',
600
+ 'recessed-cyan-channel',
601
+ 'portal-chassis-pylon',
602
+ ]
603
+
604
+ root.position.copy(options.bottom)
605
+ root.quaternion.copy(options.rotation)
606
+ markTheme(root, SPACE_ELEVATOR_THEME_ID, 'portal-derived-space-cargo-lift')
607
+ group.add(root)
608
+ return root
609
+ }
610
+
611
+ export function addPortalDerivedElevatorSkins(group: THREE.Group, options: ElevatorSkinOptions): THREE.Group[] {
612
+ switch (options.activeThemeId) {
613
+ case EGYPT_ELEVATOR_THEME_ID:
614
+ return [addEgyptSkin(group, options)]
615
+ case JAPAN_ELEVATOR_THEME_ID:
616
+ return [addJapanSkin(group, options)]
617
+ case SPACE_ELEVATOR_THEME_ID:
618
+ return [addSpaceSkin(group, options)]
619
+ default:
620
+ return []
621
+ }
622
+ }
623
+
624
+ function addShelfMesh(
625
+ group: THREE.Group,
626
+ name: string,
627
+ geometry: THREE.BufferGeometry,
628
+ material: THREE.Material,
629
+ position: THREE.Vector3,
630
+ ): THREE.Mesh {
631
+ const mesh = new THREE.Mesh(geometry, material)
632
+ mesh.name = name
633
+ mesh.position.copy(position)
634
+ mesh.castShadow = true
635
+ mesh.receiveShadow = true
636
+ mesh.userData.decorative = true
637
+ mesh.userData.visualOnly = true
638
+ group.add(mesh)
639
+ return mesh
640
+ }
641
+
642
+ export function decoratePortalDerivedElevatorShelf(
643
+ shelf: THREE.Mesh,
644
+ id: string,
645
+ width: number,
646
+ depth: number,
647
+ materials: PortalDerivedElevatorMaterials,
648
+ activeThemeId?: string,
649
+ ): void {
650
+ if (activeThemeId === EGYPT_ELEVATOR_THEME_ID) {
651
+ const egypt = new THREE.Group()
652
+ egypt.name = `${id}-egypt-approved-platform-cassette`
653
+ const blenderMaterials = liveEgyptBlenderMaterials(materials)
654
+ egypt.userData.applySemanticTheme = () => applyEgyptElevatorBlenderMaterials(egypt, blenderMaterials)
655
+ addEgyptElevatorBlenderPlatform(egypt, id, blenderMaterials, new THREE.Vector3((width - 0.56) / 4.94, 1, depth / 4))
656
+ markTheme(egypt, EGYPT_ELEVATOR_THEME_ID, 'approved-egypt-blender-platform-v5')
657
+ shelf.add(egypt)
658
+ return
659
+ }
660
+
661
+ if (activeThemeId === JAPAN_ELEVATOR_THEME_ID) {
662
+ const japan = new THREE.Group()
663
+ japan.name = `${id}-japan-bridge-cassette`
664
+ addShelfMesh(
665
+ japan,
666
+ `${id}-japan-timber-deck`,
667
+ new VisualBoxGeometry(width - 0.08, 0.08, depth - 0.1, 2, 0.035),
668
+ materials.japan.timber,
669
+ new THREE.Vector3(0, 0.13, 0),
670
+ )
671
+ addShelfMesh(
672
+ japan,
673
+ `${id}-japan-bullnose-fascia`,
674
+ new VisualBoxGeometry(width, 0.25, 0.16, 2, 0.065),
675
+ materials.japan.vermilion,
676
+ new THREE.Vector3(0, 0.18, depth / 2 - 0.02),
677
+ )
678
+ for (const x of [-width / 2 + 0.18, width / 2 - 0.18]) {
679
+ addShelfMesh(
680
+ japan,
681
+ `${id}-japan-corner-collar-${x}`,
682
+ new VisualBoxGeometry(0.34, 0.34, 0.24, 2, 0.045),
683
+ materials.japan.hardware,
684
+ new THREE.Vector3(x, 0.2, depth / 2 - 0.02),
685
+ )
686
+ const finial = addShelfMesh(
687
+ japan,
688
+ `${id}-japan-giboshi-${x}`,
689
+ new VisualSphereGeometry(0.12, 12, 8),
690
+ materials.japan.hardware,
691
+ new THREE.Vector3(x, 0.42, depth / 2 - 0.02),
692
+ )
693
+ finial.scale.y = 1.28
694
+ }
695
+ markTheme(japan, JAPAN_ELEVATOR_THEME_ID, 'japan-bridge-cassette')
696
+ shelf.add(japan)
697
+ return
698
+ }
699
+
700
+ if (activeThemeId === SPACE_ELEVATOR_THEME_ID) {
701
+ const space = new THREE.Group()
702
+ space.name = `${id}-space-cargo-cassette`
703
+ addShelfMesh(
704
+ space,
705
+ `${id}-space-cargo-deck`,
706
+ new VisualBoxGeometry(width - 0.08, 0.09, depth - 0.1, 2, 0.04),
707
+ materials.space.trim,
708
+ new THREE.Vector3(0, 0.13, 0),
709
+ )
710
+ addShelfMesh(
711
+ space,
712
+ `${id}-space-armored-fascia`,
713
+ new VisualBoxGeometry(width, 0.27, 0.16, 2, 0.055),
714
+ materials.space.chassis,
715
+ new THREE.Vector3(0, 0.18, depth / 2 - 0.02),
716
+ )
717
+ for (const x of [-width * 0.31, 0, width * 0.31]) {
718
+ addShelfMesh(
719
+ space,
720
+ `${id}-space-cyan-marker-${x}`,
721
+ new VisualBoxGeometry(0.34, 0.1, 0.06, 2, 0.03),
722
+ materials.space.light,
723
+ new THREE.Vector3(x, 0.19, depth / 2 + 0.08),
724
+ )
725
+ }
726
+ markTheme(space, SPACE_ELEVATOR_THEME_ID, 'space-cargo-cassette')
727
+ shelf.add(space)
728
+ }
729
+ }