@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,946 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualCylinderGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+
5
+ import type { TrackAppearanceCanopyStyle } from '../../../three/track-theme'
6
+
7
+ export interface AppearanceCanopyFrame {
8
+ point: THREE.Vector3
9
+ tangent: THREE.Vector3
10
+ right: THREE.Vector3
11
+ up: THREE.Vector3
12
+ width: number
13
+ }
14
+
15
+ export interface AppearanceCanopyMaterials {
16
+ wall: THREE.Material
17
+ timber: THREE.Material
18
+ metal: THREE.Material
19
+ sakuraKawara?: THREE.Material
20
+ sakuraPlaster?: THREE.Material
21
+ sakuraMasonry?: THREE.Material
22
+ }
23
+
24
+ interface Beam {
25
+ start: THREE.Vector3
26
+ end: THREE.Vector3
27
+ width: number
28
+ depth: number
29
+ }
30
+
31
+ interface Fastener {
32
+ point: THREE.Vector3
33
+ outward: THREE.Vector3
34
+ }
35
+
36
+ export interface CanopySection {
37
+ frame: AppearanceCanopyFrame
38
+ leftEave: THREE.Vector3
39
+ ridge: THREE.Vector3
40
+ rightEave: THREE.Vector3
41
+ }
42
+
43
+ interface CoveredRun {
44
+ frames: AppearanceCanopyFrame[]
45
+ touchesStart: boolean
46
+ touchesEnd: boolean
47
+ }
48
+
49
+ export interface AppearanceCanopyOptions {
50
+ capStart?: boolean
51
+ capEnd?: boolean
52
+ }
53
+
54
+ const LOCAL_UP = new THREE.Vector3(0, 1, 0)
55
+ export const WILD_WEST_COVERED_MINE_CANOPY_ID = 'wild-west.covered-mine-canopy'
56
+ export const JAPANESE_SAKURA_ROOFED_GATE_CANOPY_ID = 'japanese-sakura.roofed-gate-canopy'
57
+ /** The standard six-unit covered-mine roof rises 0.92 wu over a 3.32 wu half-span. */
58
+ export const WILD_WEST_COVERED_MINE_ROOF_PITCH = 0.92 / 3.32
59
+
60
+ /**
61
+ * Intersections and Vortices have no single covered corridor. Start and finish
62
+ * pieces intentionally remain plain race terminals. Their walls still accept
63
+ * ordinary visual appearances, but roof presets would add architecture where
64
+ * none should exist.
65
+ */
66
+ export function supportsTrackAppearanceCanopy(runtimeKind: string): boolean {
67
+ return runtimeKind !== 'intersection' && runtimeKind !== 'vortex' && runtimeKind !== 'start' && runtimeKind !== 'finish'
68
+ }
69
+
70
+ export function appearanceCanopyStyle(
71
+ material: THREE.Material,
72
+ appearanceId?: string,
73
+ ): TrackAppearanceCanopyStyle | undefined {
74
+ if (appearanceId === WILD_WEST_COVERED_MINE_CANOPY_ID) return 'wild-west-covered-mine'
75
+ if (appearanceId === JAPANESE_SAKURA_ROOFED_GATE_CANOPY_ID) return 'japanese-sakura-roofed-gate'
76
+ const style = material.userData.themeAppearanceCanopyStyle
77
+ return style === 'wild-west-covered-mine' || style === 'japanese-sakura-roofed-gate'
78
+ ? style
79
+ : undefined
80
+ }
81
+
82
+ export function addTrackAppearanceCanopy(
83
+ root: THREE.Group,
84
+ id: string,
85
+ frames: readonly AppearanceCanopyFrame[],
86
+ negativeWallSegments: readonly boolean[],
87
+ positiveWallSegments: readonly boolean[],
88
+ materials: AppearanceCanopyMaterials,
89
+ style: TrackAppearanceCanopyStyle | undefined,
90
+ options: AppearanceCanopyOptions = {},
91
+ ): void {
92
+ if (!style || frames.length < 2) return
93
+ const runs = collectCoveredRuns(frames, negativeWallSegments, positiveWallSegments)
94
+ if (runs.length === 0) return
95
+
96
+ if (style === 'wild-west-covered-mine') {
97
+ runs.forEach((run, index) =>
98
+ addWildWestCoveredMine(root, `${id}-canopy-${index + 1}`, run.frames, materials, {
99
+ includeStartSupport: !run.touchesStart || options.capStart !== false,
100
+ includeEndSupport: !run.touchesEnd || options.capEnd !== false,
101
+ }),
102
+ )
103
+ return
104
+ }
105
+ if (style === 'japanese-sakura-roofed-gate') {
106
+ runs.forEach((run, index) =>
107
+ addSakuraRoofedGate(root, `${id}-canopy-${index + 1}`, run.frames, materials, {
108
+ includeStartSupport: !run.touchesStart || options.capStart !== false,
109
+ includeEndSupport: !run.touchesEnd || options.capEnd !== false,
110
+ }),
111
+ )
112
+ }
113
+ }
114
+
115
+ function collectCoveredRuns(
116
+ frames: readonly AppearanceCanopyFrame[],
117
+ negativeWallSegments: readonly boolean[],
118
+ positiveWallSegments: readonly boolean[],
119
+ ): CoveredRun[] {
120
+ const runs: CoveredRun[] = []
121
+ let runStart: number | undefined
122
+ for (let segment = 0; segment <= frames.length - 1; segment += 1) {
123
+ const covered = Boolean(negativeWallSegments[segment] && positiveWallSegments[segment])
124
+ if (covered && runStart === undefined) runStart = segment
125
+ if (covered || runStart === undefined) continue
126
+ const runFrames = frames.slice(runStart, segment + 1)
127
+ if (runFrames.length >= 2) {
128
+ runs.push({
129
+ frames: runFrames.map(cloneFrame),
130
+ touchesStart: runStart === 0,
131
+ touchesEnd: segment === frames.length - 1,
132
+ })
133
+ }
134
+ runStart = undefined
135
+ }
136
+ return runs
137
+ }
138
+
139
+ function cloneFrame(frame: AppearanceCanopyFrame): AppearanceCanopyFrame {
140
+ return {
141
+ point: frame.point.clone(),
142
+ tangent: frame.tangent.clone(),
143
+ right: frame.right.clone(),
144
+ up: frame.up.clone(),
145
+ width: frame.width,
146
+ }
147
+ }
148
+
149
+ function sampleFrames(frames: readonly AppearanceCanopyFrame[], spacing: number): AppearanceCanopyFrame[] {
150
+ const distances = [0]
151
+ for (let index = 0; index < frames.length - 1; index += 1) {
152
+ distances.push(distances.at(-1)! + frames[index]!.point.distanceTo(frames[index + 1]!.point))
153
+ }
154
+ const length = distances.at(-1) ?? 0
155
+ const count = Math.max(2, Math.ceil(length / spacing) + 1)
156
+ return Array.from({ length: count }, (_, sampleIndex) => {
157
+ const target = (sampleIndex / (count - 1)) * length
158
+ let segment = 0
159
+ while (segment < distances.length - 2 && distances[segment + 1]! < target) segment += 1
160
+ const segmentLength = distances[segment + 1]! - distances[segment]!
161
+ const progress = segmentLength <= 0.000_001 ? 0 : (target - distances[segment]!) / segmentLength
162
+ return interpolateFrame(frames[segment]!, frames[segment + 1]!, progress)
163
+ })
164
+ }
165
+
166
+ function interpolateFrame(
167
+ start: AppearanceCanopyFrame,
168
+ end: AppearanceCanopyFrame,
169
+ progress: number,
170
+ ): AppearanceCanopyFrame {
171
+ const tangent = start.tangent.clone().lerp(end.tangent, progress).normalize()
172
+ const rightHint = start.right.clone().lerp(end.right, progress)
173
+ rightHint.addScaledVector(tangent, -rightHint.dot(tangent))
174
+ if (rightHint.lengthSq() < 0.000_001) rightHint.crossVectors(start.up, tangent)
175
+ const right = rightHint.normalize()
176
+ const up = new THREE.Vector3().crossVectors(tangent, right).normalize()
177
+ const upHint = start.up.clone().lerp(end.up, progress)
178
+ if (up.dot(upHint) < 0) {
179
+ right.negate()
180
+ up.negate()
181
+ }
182
+ return {
183
+ point: start.point.clone().lerp(end.point, progress),
184
+ tangent,
185
+ right,
186
+ up,
187
+ width: THREE.MathUtils.lerp(start.width, end.width, progress),
188
+ }
189
+ }
190
+
191
+ interface SupportOptions {
192
+ includeStartSupport: boolean
193
+ includeEndSupport: boolean
194
+ }
195
+
196
+ function supportSections<T>(sections: readonly T[], options: SupportOptions): { section: T; index: number }[] {
197
+ return sections.flatMap((section, index) => {
198
+ if (index === 0 && !options.includeStartSupport) return []
199
+ if (index === sections.length - 1 && !options.includeEndSupport) return []
200
+ return [{ section, index }]
201
+ })
202
+ }
203
+
204
+ function addOpenPortSupportInsets(
205
+ frames: readonly AppearanceCanopyFrame[],
206
+ options: SupportOptions,
207
+ ): AppearanceCanopyFrame[] {
208
+ const inset = 0.32
209
+ const result = [...frames]
210
+ if (!options.includeStartSupport) {
211
+ const start = result[0]!
212
+ const next = result[1]!
213
+ const distance = start.point.distanceTo(next.point)
214
+ if (distance > inset * 1.25) result.splice(1, 0, interpolateFrame(start, next, inset / distance))
215
+ }
216
+ if (!options.includeEndSupport) {
217
+ const previous = result.at(-2)!
218
+ const end = result.at(-1)!
219
+ const distance = previous.point.distanceTo(end.point)
220
+ if (distance > inset * 1.25) {
221
+ result.splice(result.length - 1, 0, interpolateFrame(previous, end, 1 - inset / distance))
222
+ }
223
+ }
224
+ return result
225
+ }
226
+
227
+ function addWildWestCoveredMine(
228
+ root: THREE.Group,
229
+ id: string,
230
+ runFrames: readonly AppearanceCanopyFrame[],
231
+ materials: AppearanceCanopyMaterials,
232
+ supportOptions: SupportOptions,
233
+ ): void {
234
+ const eaveHeight = 2.8
235
+ const makeSection = (frame: AppearanceCanopyFrame): CanopySection => {
236
+ const halfSpan = frame.width / 2 + 0.32
237
+ const ridgeHeight = eaveHeight + halfSpan * WILD_WEST_COVERED_MINE_ROOF_PITCH
238
+ return {
239
+ frame,
240
+ leftEave: frame.point.clone().addScaledVector(frame.right, -halfSpan).addScaledVector(LOCAL_UP, eaveHeight),
241
+ ridge: frame.point.clone().addScaledVector(LOCAL_UP, ridgeHeight),
242
+ rightEave: frame.point.clone().addScaledVector(frame.right, halfSpan).addScaledVector(LOCAL_UP, eaveHeight),
243
+ }
244
+ }
245
+ const sections = addOpenPortSupportInsets(sampleFrames(runFrames, 3.35), supportOptions).map(makeSection)
246
+ const roofSections = sampleFrames(runFrames, 0.55).map(makeSection)
247
+ const timbers: Beam[] = []
248
+ const braces: Beam[] = []
249
+ const eaveFascias: Beam[] = []
250
+ const ridgeCaps: Beam[] = []
251
+ const ironStraps: Beam[] = []
252
+ const hardwarePlates: Beam[] = []
253
+ const fasteners: Fastener[] = []
254
+ const supported = supportSections(sections, supportOptions)
255
+ let portalFrameCount = 0
256
+ for (const { section, index } of supported) {
257
+ const halfSpan = section.frame.width / 2 + 0.32
258
+ const leftBase = section.frame.point.clone().addScaledVector(section.frame.right, -halfSpan).addScaledVector(LOCAL_UP, -0.15)
259
+ const rightBase = section.frame.point.clone().addScaledVector(section.frame.right, halfSpan).addScaledVector(LOCAL_UP, -0.15)
260
+ const isPortalBent =
261
+ (!supportOptions.includeStartSupport && index === 1) ||
262
+ (!supportOptions.includeEndSupport && index === sections.length - 2)
263
+ if (isPortalBent) portalFrameCount += 1
264
+ const postWidth = 0.38 + (isPortalBent ? 0.035 : 0)
265
+ const rafterWidth = 0.28 + (isPortalBent ? 0.025 : 0)
266
+ timbers.push(
267
+ beam(leftBase, section.leftEave, postWidth, postWidth),
268
+ beam(rightBase, section.rightEave, postWidth, postWidth),
269
+ beam(section.leftEave, section.ridge, rafterWidth, rafterWidth * 0.9),
270
+ beam(section.ridge, section.rightEave, rafterWidth, rafterWidth * 0.9),
271
+ beam(section.leftEave, section.rightEave, 0.34, 0.3),
272
+ )
273
+ for (const side of [-1, 1] as const) {
274
+ const postCenter = section.frame.point.clone().addScaledVector(section.frame.right, side * halfSpan)
275
+ ironStraps.push(
276
+ beam(
277
+ postCenter.clone().addScaledVector(section.frame.tangent, -0.14).addScaledVector(LOCAL_UP, 0.42),
278
+ postCenter.clone().addScaledVector(section.frame.tangent, 0.14).addScaledVector(LOCAL_UP, 0.42),
279
+ postWidth + 0.1,
280
+ postWidth + 0.1,
281
+ ),
282
+ )
283
+ hardwarePlates.push(
284
+ beam(
285
+ postCenter.clone().addScaledVector(section.frame.tangent, -0.15).addScaledVector(LOCAL_UP, eaveHeight - 0.12),
286
+ postCenter.clone().addScaledVector(section.frame.tangent, 0.15).addScaledVector(LOCAL_UP, eaveHeight - 0.12),
287
+ postWidth + 0.12,
288
+ 0.065,
289
+ ),
290
+ )
291
+ const outward = section.frame.right.clone().multiplyScalar(side).normalize()
292
+ for (const tangentOffset of [-0.11, 0.11]) {
293
+ for (const height of [eaveHeight - 0.23, eaveHeight - 0.01]) {
294
+ fasteners.push({
295
+ point: postCenter
296
+ .clone()
297
+ .addScaledVector(section.frame.tangent, tangentOffset)
298
+ .addScaledVector(LOCAL_UP, height)
299
+ .addScaledVector(outward, postWidth / 2 + 0.04),
300
+ outward,
301
+ })
302
+ }
303
+ }
304
+ }
305
+ }
306
+ for (let index = 0; index < sections.length - 1; index += 1) {
307
+ const current = sections[index]!
308
+ const next = sections[index + 1]!
309
+ eaveFascias.push(
310
+ beam(current.leftEave, next.leftEave, 0.36, 0.34),
311
+ beam(current.rightEave, next.rightEave, 0.36, 0.34),
312
+ )
313
+ ridgeCaps.push(beam(current.ridge, next.ridge, 0.3, 0.24))
314
+ for (const side of [-1, 1] as const) {
315
+ const startEave = side < 0 ? current.leftEave : current.rightEave
316
+ const endEave = side < 0 ? next.leftEave : next.rightEave
317
+ braces.push(
318
+ beam(
319
+ startEave.clone().addScaledVector(LOCAL_UP, -0.92),
320
+ startEave.clone().lerp(endEave, 0.28).addScaledVector(LOCAL_UP, -0.12),
321
+ 0.145,
322
+ 0.13,
323
+ ),
324
+ beam(
325
+ endEave.clone().addScaledVector(LOCAL_UP, -0.92),
326
+ endEave.clone().lerp(startEave, 0.28).addScaledVector(LOCAL_UP, -0.12),
327
+ 0.145,
328
+ 0.13,
329
+ ),
330
+ )
331
+ }
332
+ }
333
+
334
+ const timberMesh = addBeamBatch(
335
+ root,
336
+ `${id}-mine-timbers`,
337
+ timbers,
338
+ materials.timber,
339
+ 'rocky-mountain-wild-west',
340
+ false,
341
+ true,
342
+ )
343
+ const braceMesh = addBeamBatch(
344
+ root,
345
+ `${id}-mine-braces`,
346
+ braces,
347
+ materials.timber,
348
+ 'rocky-mountain-wild-west',
349
+ false,
350
+ true,
351
+ )
352
+ addBeamBatch(
353
+ root,
354
+ `${id}-mine-longitudinal-timbers`,
355
+ eaveFascias,
356
+ materials.timber,
357
+ 'rocky-mountain-wild-west',
358
+ false,
359
+ true,
360
+ )
361
+ addBeamBatch(
362
+ root,
363
+ `${id}-mine-ridge-cap`,
364
+ ridgeCaps,
365
+ materials.metal,
366
+ 'rocky-mountain-wild-west',
367
+ false,
368
+ true,
369
+ )
370
+ addBeamBatch(
371
+ root,
372
+ `${id}-mine-iron-straps`,
373
+ ironStraps,
374
+ materials.metal,
375
+ 'rocky-mountain-wild-west',
376
+ false,
377
+ true,
378
+ )
379
+ for (const mesh of [timberMesh, braceMesh]) {
380
+ if (!mesh) continue
381
+ mesh.userData.canopySectionCount = sections.length
382
+ mesh.userData.canopySupportSectionCount = supported.length
383
+ mesh.userData.canopyPortalFrameCount = portalFrameCount
384
+ }
385
+ addBeamBatch(
386
+ root,
387
+ `${id}-mine-iron-brackets`,
388
+ hardwarePlates,
389
+ materials.metal,
390
+ 'rocky-mountain-wild-west',
391
+ false,
392
+ true,
393
+ )
394
+ addFastenerBatch(root, `${id}-mine-iron-fasteners`, fasteners, materials.metal, 'rocky-mountain-wild-west')
395
+ addRoofSheets(root, `${id}-corrugated-roof`, roofSections, materials.metal, materials.metal)
396
+ }
397
+
398
+ export function addRoofSheets(
399
+ root: THREE.Group,
400
+ id: string,
401
+ sections: readonly CanopySection[],
402
+ material: THREE.Material,
403
+ patchMaterial: THREE.Material,
404
+ ): void {
405
+ const vertices: number[] = []
406
+ const indices: number[] = []
407
+ const uvs: number[] = []
408
+ const edgeCaps: Beam[] = []
409
+ const corrugationRibs: Beam[] = []
410
+ const distances = [0]
411
+ for (let index = 0; index < sections.length - 1; index += 1) {
412
+ distances.push(distances.at(-1)! + sections[index]!.frame.point.distanceTo(sections[index + 1]!.frame.point))
413
+ }
414
+ const roofThickness = 0.1
415
+ const topLift = 0.055
416
+ const undersideDrop = roofThickness - topLift
417
+ const addFace = (
418
+ points: readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3],
419
+ startDistance: number,
420
+ endDistance: number,
421
+ reverse: boolean,
422
+ ): void => {
423
+ const offset = vertices.length / 3
424
+ points.forEach(point => vertices.push(point.x, point.y, point.z))
425
+ uvs.push(0, startDistance, 1, startDistance, 0, endDistance, 1, endDistance)
426
+ if (reverse) indices.push(offset, offset + 1, offset + 2, offset + 1, offset + 3, offset + 2)
427
+ else indices.push(offset, offset + 2, offset + 1, offset + 1, offset + 2, offset + 3)
428
+ }
429
+ for (let index = 0; index < sections.length - 1; index += 1) {
430
+ const current = sections[index]!
431
+ const next = sections[index + 1]!
432
+ for (const [startA, startB, endA, endB] of [
433
+ [current.leftEave, current.ridge, next.leftEave, next.ridge],
434
+ [current.ridge, current.rightEave, next.ridge, next.rightEave],
435
+ ] as const) {
436
+ const topPoints = [startA, startB, endA, endB].map(point =>
437
+ point.clone().addScaledVector(LOCAL_UP, topLift),
438
+ ) as unknown as readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3]
439
+ const undersidePoints = [startA, startB, endA, endB].map(point =>
440
+ point.clone().addScaledVector(LOCAL_UP, -undersideDrop),
441
+ ) as unknown as readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3]
442
+ addFace(topPoints, distances[index]!, distances[index + 1]!, false)
443
+ addFace(undersidePoints, distances[index]!, distances[index + 1]!, true)
444
+ }
445
+ const leftTopStart = current.leftEave.clone().addScaledVector(LOCAL_UP, topLift)
446
+ const leftTopEnd = next.leftEave.clone().addScaledVector(LOCAL_UP, topLift)
447
+ const leftBottomStart = current.leftEave.clone().addScaledVector(LOCAL_UP, -undersideDrop)
448
+ const leftBottomEnd = next.leftEave.clone().addScaledVector(LOCAL_UP, -undersideDrop)
449
+ const rightTopStart = current.rightEave.clone().addScaledVector(LOCAL_UP, topLift)
450
+ const rightTopEnd = next.rightEave.clone().addScaledVector(LOCAL_UP, topLift)
451
+ const rightBottomStart = current.rightEave.clone().addScaledVector(LOCAL_UP, -undersideDrop)
452
+ const rightBottomEnd = next.rightEave.clone().addScaledVector(LOCAL_UP, -undersideDrop)
453
+ addFace(
454
+ [leftBottomStart, leftTopStart, leftBottomEnd, leftTopEnd],
455
+ distances[index]!,
456
+ distances[index + 1]!,
457
+ false,
458
+ )
459
+ addFace(
460
+ [rightTopStart, rightBottomStart, rightTopEnd, rightBottomEnd],
461
+ distances[index]!,
462
+ distances[index + 1]!,
463
+ false,
464
+ )
465
+ edgeCaps.push(
466
+ beam(leftTopStart, leftTopEnd, 0.055, 0.06),
467
+ beam(
468
+ current.ridge.clone().addScaledVector(LOCAL_UP, topLift + 0.02),
469
+ next.ridge.clone().addScaledVector(LOCAL_UP, topLift + 0.02),
470
+ 0.085,
471
+ 0.075,
472
+ ),
473
+ beam(rightTopStart, rightTopEnd, 0.055, 0.06),
474
+ )
475
+ }
476
+ for (const section of sections) {
477
+ corrugationRibs.push(
478
+ beam(
479
+ section.leftEave.clone().addScaledVector(LOCAL_UP, topLift + 0.015),
480
+ section.ridge.clone().addScaledVector(LOCAL_UP, topLift + 0.015),
481
+ 0.038,
482
+ 0.045,
483
+ ),
484
+ beam(
485
+ section.ridge.clone().addScaledVector(LOCAL_UP, topLift + 0.015),
486
+ section.rightEave.clone().addScaledVector(LOCAL_UP, topLift + 0.015),
487
+ 0.038,
488
+ 0.045,
489
+ ),
490
+ )
491
+ }
492
+ const geometry = new THREE.BufferGeometry()
493
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
494
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
495
+ geometry.setIndex(indices)
496
+ geometry.computeVertexNormals()
497
+ geometry.userData.canopyLayout = 'layered-continuous-gabled-ribbon'
498
+ geometry.userData.canopySectionCount = sections.length
499
+ geometry.userData.roofThickness = roofThickness
500
+ const roof = new THREE.Mesh(geometry, material)
501
+ roof.name = id
502
+ decorate(roof, 'rocky-mountain-wild-west', 'covered-mine-roof')
503
+ root.add(roof)
504
+ addBeamBatch(
505
+ root,
506
+ `${id}-edge-caps`,
507
+ edgeCaps,
508
+ material,
509
+ 'rocky-mountain-wild-west',
510
+ false,
511
+ true,
512
+ )
513
+ addBeamBatch(
514
+ root,
515
+ `${id}-raised-seams`,
516
+ corrugationRibs,
517
+ material,
518
+ 'rocky-mountain-wild-west',
519
+ false,
520
+ true,
521
+ )
522
+ addRoofPatchPanels(root, `${id}-weathered-patch-panels`, sections, patchMaterial, topLift + 0.025)
523
+ }
524
+
525
+ function addRoofPatchPanels(
526
+ root: THREE.Group,
527
+ name: string,
528
+ sections: readonly CanopySection[],
529
+ material: THREE.Material,
530
+ lift: number,
531
+ ): void {
532
+ const vertices: number[] = []
533
+ const indices: number[] = []
534
+ const uvs: number[] = []
535
+ const thickness = 0.035
536
+ let panelCount = 0
537
+
538
+ const appendPanel = (corners: readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3]): void => {
539
+ const top = corners.map(point => point.clone().addScaledVector(LOCAL_UP, lift)) as unknown as readonly [
540
+ THREE.Vector3,
541
+ THREE.Vector3,
542
+ THREE.Vector3,
543
+ THREE.Vector3,
544
+ ]
545
+ const bottom = top.map(point => point.clone().addScaledVector(LOCAL_UP, -thickness)) as unknown as readonly [
546
+ THREE.Vector3,
547
+ THREE.Vector3,
548
+ THREE.Vector3,
549
+ THREE.Vector3,
550
+ ]
551
+ const faces = [
552
+ [top[0], top[1], top[2], top[3]],
553
+ [bottom[1], bottom[0], bottom[3], bottom[2]],
554
+ [bottom[0], top[0], bottom[2], top[2]],
555
+ [top[1], bottom[1], top[3], bottom[3]],
556
+ [bottom[0], bottom[1], top[0], top[1]],
557
+ [top[2], top[3], bottom[2], bottom[3]],
558
+ ] as const
559
+ for (const face of faces) {
560
+ const offset = vertices.length / 3
561
+ face.forEach(point => vertices.push(point.x, point.y, point.z))
562
+ uvs.push(0, 0, 1, 0, 0, 1, 1, 1)
563
+ indices.push(offset, offset + 2, offset + 1, offset + 1, offset + 2, offset + 3)
564
+ }
565
+ panelCount += 1
566
+ }
567
+
568
+ const segmentCount = sections.length - 1
569
+ const selectedSegments = Array.from(
570
+ new Set(
571
+ segmentCount >= 8
572
+ ? [Math.floor(segmentCount * 0.28), Math.floor(segmentCount * 0.72)]
573
+ : [Math.floor(segmentCount * 0.5)],
574
+ ),
575
+ )
576
+ for (const index of selectedSegments) {
577
+ const current = sections[index]!
578
+ const next = sections[index + 1]!
579
+ const seed = deterministic(`${name}:${index}`)
580
+ const side = (index + (seed > 0.5 ? 1 : 0)) % 2 === 0 ? 'left' : 'right'
581
+ const startProgress = 0.08 + seed * 0.12
582
+ const endProgress = 0.72 + deterministic(`${name}:${index}:end`) * 0.18
583
+ const startOuter = side === 'left' ? current.leftEave : current.ridge
584
+ const startInner = side === 'left' ? current.ridge : current.rightEave
585
+ const endOuter = side === 'left' ? next.leftEave : next.ridge
586
+ const endInner = side === 'left' ? next.ridge : next.rightEave
587
+ appendPanel([
588
+ startOuter.clone().lerp(startInner, startProgress),
589
+ startOuter.clone().lerp(startInner, endProgress),
590
+ endOuter.clone().lerp(endInner, startProgress),
591
+ endOuter.clone().lerp(endInner, endProgress),
592
+ ])
593
+ }
594
+ if (panelCount === 0) return
595
+ const geometry = new THREE.BufferGeometry()
596
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
597
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
598
+ geometry.setIndex(indices)
599
+ geometry.computeVertexNormals()
600
+ geometry.userData.canopyPatchPanelCount = panelCount
601
+ geometry.userData.canopyPatchThickness = thickness
602
+ const patches = new THREE.Mesh(geometry, material)
603
+ patches.name = name
604
+ decorate(patches, 'rocky-mountain-wild-west', 'covered-mine-roof-patches')
605
+ root.add(patches)
606
+ }
607
+
608
+ function addSakuraRoofedGate(
609
+ root: THREE.Group,
610
+ id: string,
611
+ runFrames: readonly AppearanceCanopyFrame[],
612
+ materials: AppearanceCanopyMaterials,
613
+ supportOptions: SupportOptions,
614
+ ): void {
615
+ const themeId = 'japanese-sakura-lake'
616
+ const vermilion = materials.wall
617
+ const kawara = materials.sakuraKawara ?? materials.metal
618
+ const masonry = materials.sakuraMasonry ?? materials.metal
619
+ const supportFrames = addOpenPortSupportInsets(sampleFrames(runFrames, 1.86), supportOptions)
620
+ const supported = supportSections(supportFrames, supportOptions)
621
+ const roofFrames = sampleFrames(runFrames, 0.34)
622
+ const supports: Beam[] = []
623
+ const lowerRails: Beam[] = []
624
+ const feet: Beam[] = []
625
+ const bolts: THREE.Vector3[] = []
626
+
627
+ for (const { section: frame } of supported) {
628
+ const halfSpan = frame.width / 2 + 0.18
629
+ const leftBase = frame.point.clone().addScaledVector(frame.right, -halfSpan).addScaledVector(LOCAL_UP, -0.08)
630
+ const rightBase = frame.point.clone().addScaledVector(frame.right, halfSpan).addScaledVector(LOCAL_UP, -0.08)
631
+ const leftFootBase = frame.point.clone().addScaledVector(frame.right, -halfSpan).addScaledVector(frame.up, -0.08)
632
+ const rightFootBase = frame.point.clone().addScaledVector(frame.right, halfSpan).addScaledVector(frame.up, -0.08)
633
+ const leftTop = frame.point.clone().addScaledVector(frame.right, -halfSpan).addScaledVector(LOCAL_UP, 2.58)
634
+ const rightTop = frame.point.clone().addScaledVector(frame.right, halfSpan).addScaledVector(LOCAL_UP, 2.58)
635
+ supports.push(
636
+ beam(leftBase, leftTop, 0.34, 0.38),
637
+ beam(rightBase, rightTop, 0.34, 0.38),
638
+ beam(
639
+ frame.point.clone().addScaledVector(frame.right, -(halfSpan + 0.34)).addScaledVector(LOCAL_UP, 2.58),
640
+ frame.point.clone().addScaledVector(frame.right, halfSpan + 0.34).addScaledVector(LOCAL_UP, 2.58),
641
+ 0.28,
642
+ 0.42,
643
+ ),
644
+ beam(
645
+ frame.point.clone().addScaledVector(frame.right, -halfSpan).addScaledVector(LOCAL_UP, 2.32),
646
+ frame.point.clone().addScaledVector(frame.right, halfSpan).addScaledVector(LOCAL_UP, 2.32),
647
+ 0.18,
648
+ 0.34,
649
+ ),
650
+ )
651
+ feet.push(
652
+ beam(leftFootBase, leftFootBase.clone().addScaledVector(frame.up, 0.22), 0.64, 0.62),
653
+ beam(rightFootBase, rightFootBase.clone().addScaledVector(frame.up, 0.22), 0.64, 0.62),
654
+ )
655
+ bolts.push(
656
+ leftBase.clone().addScaledVector(LOCAL_UP, 0.76).addScaledVector(frame.tangent, 0.22),
657
+ leftTop.clone().addScaledVector(LOCAL_UP, -0.42).addScaledVector(frame.tangent, 0.22),
658
+ rightBase.clone().addScaledVector(LOCAL_UP, 0.76).addScaledVector(frame.tangent, 0.22),
659
+ rightTop.clone().addScaledVector(LOCAL_UP, -0.42).addScaledVector(frame.tangent, 0.22),
660
+ )
661
+ }
662
+
663
+ for (let index = 0; index < supportFrames.length - 1; index += 1) {
664
+ const current = supportFrames[index]!
665
+ const next = supportFrames[index + 1]!
666
+ for (const side of [-1, 1] as const) {
667
+ const currentHalfSpan = current.width / 2 + 0.18
668
+ const nextHalfSpan = next.width / 2 + 0.18
669
+ lowerRails.push(
670
+ beam(
671
+ current.point
672
+ .clone()
673
+ .addScaledVector(current.right, side * currentHalfSpan)
674
+ .addScaledVector(LOCAL_UP, 0.56),
675
+ next.point
676
+ .clone()
677
+ .addScaledVector(next.right, side * nextHalfSpan)
678
+ .addScaledVector(LOCAL_UP, 0.56),
679
+ 0.18,
680
+ 0.18,
681
+ ),
682
+ )
683
+ }
684
+ }
685
+
686
+ const supportMesh = addBeamBatch(root, `${id}-sakura-vermilion-frames`, supports, vermilion, themeId, false)
687
+ const lowerRailMesh = addBeamBatch(
688
+ root,
689
+ `${id}-sakura-vermilion-lower-rails`,
690
+ lowerRails,
691
+ vermilion,
692
+ themeId,
693
+ false,
694
+ true,
695
+ )
696
+ const footMesh = addBeamBatch(root, `${id}-sakura-masonry-feet`, feet, masonry, themeId, false)
697
+ addJointBatch(root, `${id}-sakura-gunmetal-bolts`, bolts, materials.metal, themeId, 'sakura-gate-hardware', 0.065)
698
+ if (supportMesh) {
699
+ supportMesh.userData.themeRole = 'sakura-roofed-gate-frame'
700
+ supportMesh.userData.canopySectionCount = roofFrames.length
701
+ supportMesh.userData.canopySupportSectionCount = supported.length
702
+ }
703
+ if (lowerRailMesh) {
704
+ lowerRailMesh.userData.themeRole = 'sakura-roofed-gate-lower-rail'
705
+ lowerRailMesh.userData.canopyLowerRailSegmentCount = lowerRails.length
706
+ }
707
+ if (footMesh) footMesh.userData.themeRole = 'sakura-roofed-gate-surface-aligned-foot'
708
+
709
+ const roofSections = roofFrames.map(frame => {
710
+ const halfSpan = frame.width / 2 + 0.82
711
+ return {
712
+ frame,
713
+ leftEave: frame.point.clone().addScaledVector(frame.right, -halfSpan).addScaledVector(LOCAL_UP, 2.6),
714
+ ridge: frame.point.clone().addScaledVector(LOCAL_UP, 3.5),
715
+ rightEave: frame.point.clone().addScaledVector(frame.right, halfSpan).addScaledVector(LOCAL_UP, 2.6),
716
+ }
717
+ })
718
+ addSakuraRoofBacking(root, `${id}-sakura-kawara-backing`, roofSections, kawara, themeId)
719
+ addSakuraRoofTiles(root, `${id}-sakura-kawara-tiles`, roofSections, kawara, themeId)
720
+ }
721
+
722
+ function addSakuraRoofBacking(
723
+ root: THREE.Group,
724
+ name: string,
725
+ sections: readonly CanopySection[],
726
+ material: THREE.Material,
727
+ themeId: string,
728
+ ): void {
729
+ const positions: number[] = []
730
+ const uvs: number[] = []
731
+ const indices: number[] = []
732
+ const distances = [0]
733
+ for (let index = 0; index < sections.length - 1; index += 1) {
734
+ distances.push(distances.at(-1)! + sections[index]!.frame.point.distanceTo(sections[index + 1]!.frame.point))
735
+ }
736
+ const append = (points: readonly THREE.Vector3[], startDistance: number, endDistance: number, reverse: boolean) => {
737
+ const offset = positions.length / 3
738
+ points.forEach(point => positions.push(point.x, point.y, point.z))
739
+ uvs.push(0, startDistance, 1, startDistance, 0, endDistance, 1, endDistance)
740
+ indices.push(
741
+ ...(reverse
742
+ ? [offset, offset + 1, offset + 2, offset + 1, offset + 3, offset + 2]
743
+ : [offset, offset + 2, offset + 1, offset + 1, offset + 2, offset + 3]),
744
+ )
745
+ }
746
+ for (let index = 0; index < sections.length - 1; index += 1) {
747
+ const current = sections[index]!
748
+ const next = sections[index + 1]!
749
+ for (const [a, b, c, d] of [
750
+ [current.leftEave, current.ridge, next.leftEave, next.ridge],
751
+ [current.ridge, current.rightEave, next.ridge, next.rightEave],
752
+ ] as const) {
753
+ append(
754
+ [a, b, c, d].map(point => point.clone().addScaledVector(LOCAL_UP, 0.02)),
755
+ distances[index]!,
756
+ distances[index + 1]!,
757
+ false,
758
+ )
759
+ append(
760
+ [a, b, c, d].map(point => point.clone().addScaledVector(LOCAL_UP, -0.09)),
761
+ distances[index]!,
762
+ distances[index + 1]!,
763
+ true,
764
+ )
765
+ }
766
+ }
767
+ const geometry = new THREE.BufferGeometry()
768
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
769
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
770
+ geometry.setIndex(indices)
771
+ geometry.computeVertexNormals()
772
+ geometry.userData.canopyLayout = 'continuous-sakura-kawara-backing'
773
+ geometry.userData.canopySectionCount = sections.length
774
+ const roof = new THREE.Mesh(geometry, material)
775
+ roof.name = name
776
+ decorate(roof, themeId, 'sakura-kawara-roof-backing')
777
+ root.add(roof)
778
+ }
779
+
780
+ function addSakuraRoofTiles(
781
+ root: THREE.Group,
782
+ name: string,
783
+ sections: readonly CanopySection[],
784
+ material: THREE.Material,
785
+ themeId: string,
786
+ ): void {
787
+ // The roof backing supplies the solid surface; tiles only need a curved outer shell.
788
+ const tileGeometry = new VisualCylinderGeometry(0.5, 0.5, 1, 4, 1, true, 0, Math.PI)
789
+ const tilesPerSection = 8
790
+ const tiles = new THREE.InstancedMesh(tileGeometry, material, sections.length * tilesPerSection)
791
+ tiles.name = name
792
+ let tileIndex = 0
793
+ for (const section of sections) {
794
+ for (const side of [-1, 1] as const) {
795
+ const eave = side < 0 ? section.leftEave : section.rightEave
796
+ for (let segment = 0; segment < 4; segment += 1) {
797
+ const startProgress = Math.max(0, segment / 4 - 0.018)
798
+ const endProgress = Math.min(1, (segment + 1) / 4 + 0.035)
799
+ const start = section.ridge.clone().lerp(eave, startProgress).addScaledVector(LOCAL_UP, 0.09)
800
+ const end = section.ridge.clone().lerp(eave, endProgress).addScaledVector(LOCAL_UP, 0.09)
801
+ const direction = end.clone().sub(start)
802
+ const length = direction.length()
803
+ const align = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, direction.normalize())
804
+ align.multiply(new THREE.Quaternion().setFromAxisAngle(LOCAL_UP, side < 0 ? Math.PI : 0))
805
+ tiles.setMatrixAt(
806
+ tileIndex++,
807
+ new THREE.Matrix4().compose(
808
+ start.clone().lerp(end, 0.5),
809
+ align,
810
+ new THREE.Vector3(0.21, length, 0.21),
811
+ ),
812
+ )
813
+ }
814
+ }
815
+ }
816
+ tiles.instanceMatrix.needsUpdate = true
817
+ tiles.userData.canopySectionCount = sections.length
818
+ tiles.userData.kawaraTileCount = tiles.count
819
+ tiles.userData.mirroredRoofSides = true
820
+ decorate(tiles, themeId, 'sakura-kawara-tile-shell')
821
+ root.add(tiles)
822
+
823
+ const eaveGeometry = new VisualCylinderGeometry(0.5, 0.5, 1, 12)
824
+ const eaveCaps = new THREE.InstancedMesh(eaveGeometry, material, sections.length * 2)
825
+ eaveCaps.name = `${name}-scalloped-eaves`
826
+ let capIndex = 0
827
+ for (const section of sections) {
828
+ for (const eave of [section.leftEave, section.rightEave]) {
829
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, section.frame.tangent)
830
+ eaveCaps.setMatrixAt(
831
+ capIndex++,
832
+ new THREE.Matrix4().compose(
833
+ eave.clone().addScaledVector(LOCAL_UP, 0.075),
834
+ quaternion,
835
+ new THREE.Vector3(0.27, 0.12, 0.27),
836
+ ),
837
+ )
838
+ }
839
+ }
840
+ eaveCaps.instanceMatrix.needsUpdate = true
841
+ decorate(eaveCaps, themeId, 'sakura-kawara-eave-cap')
842
+ root.add(eaveCaps)
843
+
844
+ const ridge: Beam[] = []
845
+ for (let index = 0; index < sections.length - 1; index += 1) {
846
+ ridge.push(beam(sections[index]!.ridge, sections[index + 1]!.ridge, 0.3, 0.3))
847
+ }
848
+ const ridgeMesh = addBeamBatch(root, `${name}-ridge`, ridge, material, themeId, true)
849
+ if (ridgeMesh) ridgeMesh.userData.themeRole = 'sakura-kawara-ridge'
850
+ }
851
+
852
+ function beam(start: THREE.Vector3, end: THREE.Vector3, width: number, depth: number): Beam {
853
+ return { start, end, width, depth }
854
+ }
855
+
856
+ function addBeamBatch(
857
+ root: THREE.Group,
858
+ name: string,
859
+ beams: readonly Beam[],
860
+ material: THREE.Material,
861
+ themeId: string,
862
+ rounded: boolean,
863
+ beveled = false,
864
+ ): THREE.InstancedMesh | undefined {
865
+ if (beams.length === 0) return undefined
866
+ const geometry = rounded
867
+ ? new VisualCylinderGeometry(0.5, 0.62, 1, 7)
868
+ : beveled
869
+ ? new VisualBoxGeometry(1, 1, 1, 2, 0.08)
870
+ : new THREE.BoxGeometry(1, 1, 1)
871
+ const mesh = new THREE.InstancedMesh(geometry, material, beams.length)
872
+ mesh.name = name
873
+ beams.forEach((item, index) => {
874
+ const direction = item.end.clone().sub(item.start)
875
+ const length = direction.length()
876
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, direction.normalize())
877
+ mesh.setMatrixAt(
878
+ index,
879
+ new THREE.Matrix4().compose(
880
+ item.start.clone().lerp(item.end, 0.5),
881
+ quaternion,
882
+ new THREE.Vector3(item.width, length, item.depth),
883
+ ),
884
+ )
885
+ })
886
+ mesh.instanceMatrix.needsUpdate = true
887
+ decorate(mesh, themeId, rounded ? 'sakura-kawara-ridge' : 'covered-mine-timber')
888
+ root.add(mesh)
889
+ return mesh
890
+ }
891
+
892
+ function addFastenerBatch(
893
+ root: THREE.Group,
894
+ name: string,
895
+ fasteners: readonly Fastener[],
896
+ material: THREE.Material,
897
+ themeId: string,
898
+ ): THREE.InstancedMesh | undefined {
899
+ if (fasteners.length === 0) return undefined
900
+ const geometry = new VisualCylinderGeometry(0.045, 0.045, 0.045, 12)
901
+ const mesh = new THREE.InstancedMesh(geometry, material, fasteners.length)
902
+ mesh.name = name
903
+ fasteners.forEach(({ point, outward }, index) => {
904
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, outward.clone().normalize())
905
+ mesh.setMatrixAt(index, new THREE.Matrix4().compose(point, quaternion, new THREE.Vector3(1, 1, 1)))
906
+ })
907
+ mesh.instanceMatrix.needsUpdate = true
908
+ decorate(mesh, themeId, 'covered-mine-fasteners')
909
+ root.add(mesh)
910
+ return mesh
911
+ }
912
+
913
+ function addJointBatch(
914
+ root: THREE.Group,
915
+ name: string,
916
+ points: readonly THREE.Vector3[],
917
+ material: THREE.Material,
918
+ themeId: string,
919
+ role: string,
920
+ radius: number,
921
+ ): void {
922
+ if (points.length === 0) return
923
+ const mesh = new THREE.InstancedMesh(new THREE.DodecahedronGeometry(radius, 0), material, points.length)
924
+ mesh.name = name
925
+ points.forEach((point, index) => mesh.setMatrixAt(index, new THREE.Matrix4().makeTranslation(...point.toArray())))
926
+ mesh.instanceMatrix.needsUpdate = true
927
+ decorate(mesh, themeId, role)
928
+ root.add(mesh)
929
+ }
930
+
931
+ function decorate(object: THREE.Object3D, themeId: string, role: string): void {
932
+ object.userData.themeOnly = themeId
933
+ object.userData.themeRole = role
934
+ object.userData.decorative = true
935
+ object.castShadow = true
936
+ object.receiveShadow = true
937
+ }
938
+
939
+ function deterministic(seed: string): number {
940
+ let hash = 2_166_136_261
941
+ for (const character of seed) {
942
+ hash ^= character.charCodeAt(0)
943
+ hash = Math.imul(hash, 16_777_619)
944
+ }
945
+ return (hash >>> 0) / 4_294_967_296
946
+ }