@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,1031 @@
1
+ import { geometryDetailProfile } from '../../../three/geometry-detail'
2
+ import * as THREE from 'three'
3
+
4
+ import { distributePathPattern } from './path-pattern-layout'
5
+ import { addCanonicalWallTerminalSkins } from './track-appearance-wall-terminal'
6
+ import { TRACK_RAIL_WALL_THICKNESS } from './track-definition'
7
+ import {
8
+ resampleCanonicalWallRun,
9
+ type CanonicalWallRun,
10
+ type RawWallFrame,
11
+ } from './track-surface-topology'
12
+
13
+ export type SakuraCastleWallPathFrame = RawWallFrame
14
+ export type SakuraCastleWallPathRun = CanonicalWallRun
15
+
16
+ export interface SakuraCastleWallMaterials {
17
+ timber: THREE.Material
18
+ masonry: THREE.Material
19
+ kawara: THREE.Material
20
+ }
21
+
22
+ const FRAME_SPACING = 1.2 * geometryDetailProfile.ribbonTolerance
23
+ const POST_MAXIMUM_SPACING = 1.2
24
+ const KAWARA_TILE_SPACING = 0.22
25
+ const KAWARA_TILE_ROWS = 2
26
+ const ROOF_HALF_SPAN = 0.27
27
+ const ROOF_RISE = 0.17
28
+ const ROOF_EAVE_LIFT = 0.015
29
+ const CONNECTED_ROOF_SEAM_OVERLAP = 0.03
30
+ let sharedTimberPostGeometry: THREE.BoxGeometry | undefined
31
+ let sharedKawaraTileGeometry: THREE.BufferGeometry | undefined
32
+
33
+ /**
34
+ * Adds the visual language selected by the Sakura castle-wall appearance:
35
+ * masonry sill, timber framing, and a shallow kawara roof. The ordinary wall
36
+ * mesh remains the plaster body and the only corresponding Rapier collider.
37
+ */
38
+ export function addSakuraCastleWallVisual(
39
+ root: THREE.Group,
40
+ id: string,
41
+ runs: readonly SakuraCastleWallPathRun[],
42
+ wallHeight: number,
43
+ materials: SakuraCastleWallMaterials,
44
+ ): THREE.Group | undefined {
45
+ if (wallHeight <= 0.08) return undefined
46
+ const group = new THREE.Group()
47
+ group.name = `${id}-sakura-castle-wall`
48
+ group.userData.themeOnly = 'japanese-sakura-lake'
49
+ group.userData.decorative = true
50
+ group.userData.visualOnly = true
51
+ group.userData.appearance = 'japanese-sakura-castle-wall'
52
+
53
+ const postMatrices: THREE.Matrix4[] = []
54
+ const railPositions: number[] = []
55
+ const railUvs: number[] = []
56
+ const railMetricUvs: number[] = []
57
+ const railIndices: number[] = []
58
+ const tileMatrices: THREE.Matrix4[] = []
59
+ let tilePlacementRunCount = 0
60
+ let connectedRoofOverlapRunCount = 0
61
+ let tJunctionRoofOverlapRunCount = 0
62
+ let tJunctionTileMinimumStartProjection = Number.POSITIVE_INFINITY
63
+ let runCount = 0
64
+ const masonryHeight = Math.min(0.27, wallHeight * 0.24)
65
+ const terminalKey = (frame: SakuraCastleWallPathFrame): string =>
66
+ frame.base.toArray().map(value => value.toFixed(4)).join(':')
67
+ const connectedEndOwner = new Map<string, number>()
68
+ runs.forEach((run, runIndex) => {
69
+ if (run.endOwnership !== 'connected' || run.frames.length < 2) return
70
+ const key = terminalKey(run.frames.at(-1)!)
71
+ if (!connectedEndOwner.has(key)) connectedEndOwner.set(key, runIndex)
72
+ })
73
+ const tJunctionConnectedStarts = new Set<number>()
74
+ runs.forEach((run, runIndex) => {
75
+ if (run.startOwnership !== 'connected' || run.frames.length < 2) return
76
+ const start = run.frames[0]!.base
77
+ const joinsReceiver = runs.some((receiver, receiverIndex) => {
78
+ if (receiverIndex === runIndex || receiver.frames.length < 2) return false
79
+ for (let index = 0; index < receiver.frames.length - 1; index += 1) {
80
+ const segmentStart = receiver.frames[index]!.base
81
+ const segmentEnd = receiver.frames[index + 1]!.base
82
+ const segment = segmentEnd.clone().sub(segmentStart).setY(0)
83
+ const lengthSquared = segment.lengthSq()
84
+ if (lengthSquared <= 0.000_001) continue
85
+ const progress = THREE.MathUtils.clamp(
86
+ start.clone().sub(segmentStart).setY(0).dot(segment) / lengthSquared,
87
+ 0,
88
+ 1,
89
+ )
90
+ // T-junction contacts lie inside the receiver segment. Ordinary
91
+ // end-to-start wall seams remain governed by the cadence phase logic.
92
+ if (progress <= 0.001 || progress >= 0.999) continue
93
+ const closest = segmentStart.clone().addScaledVector(segment, progress)
94
+ const receiverContactTolerance = TRACK_RAIL_WALL_THICKNESS / 2 + 0.001
95
+ if (
96
+ closest.setY(start.y).distanceToSquared(start) <=
97
+ receiverContactTolerance * receiverContactTolerance
98
+ ) return true
99
+ }
100
+ return false
101
+ })
102
+ if (joinsReceiver) tJunctionConnectedStarts.add(runIndex)
103
+ })
104
+
105
+ for (const [runIndex, run] of runs.entries()) {
106
+ if (run.frames.length < 2) continue
107
+ const canonicalRun = run
108
+ const frames = resampleCanonicalWallRun(canonicalRun, FRAME_SPACING).frames
109
+ const distances = pathDistances(frames)
110
+ const totalLength = distances.at(-1) ?? 0
111
+ if (totalLength <= 0.001) continue
112
+ runCount += 1
113
+ const capStart = run.capStart
114
+ const capEnd = run.capEnd
115
+ const ownsConnectedRoofEnd =
116
+ capEnd || connectedEndOwner.get(terminalKey(canonicalRun.frames.at(-1)!)) === runIndex
117
+ if (!capEnd && ownsConnectedRoofEnd) connectedRoofOverlapRunCount += 1
118
+
119
+ for (const face of [-1, 1] as const) {
120
+ const masonry = new THREE.Mesh(
121
+ // The sill is a facade over the canonical plaster backing, so it must
122
+ // use the exact same path rows. A separately resampled curve uses
123
+ // different chords and can pass behind the plaster between vertices.
124
+ createFaceBandGeometry(
125
+ canonicalRun.frames,
126
+ canonicalRun.frames.map(frame => frame.distance),
127
+ canonicalRun.length,
128
+ face,
129
+ 0.008,
130
+ masonryHeight,
131
+ capStart,
132
+ capEnd,
133
+ ),
134
+ materials.masonry,
135
+ )
136
+ masonry.name = `${id}-sakura-castle-masonry-${runIndex + 1}-${face < 0 ? 'inside' : 'outside'}`
137
+ masonry.castShadow = true
138
+ masonry.receiveShadow = true
139
+ group.add(masonry)
140
+
141
+ // The roof is one continuous sweep over the canonical wall run. The old
142
+ // sharp-corner split created independently capped roof chunks, leaving
143
+ // holes and overlaps precisely where curved pieces changed direction.
144
+ const roofFrames = resampleCanonicalWallRun(canonicalRun, FRAME_SPACING).frames
145
+ const roofTileFrames = roofFramesWithTJunctionStartOverlap(
146
+ roofFrames,
147
+ tJunctionConnectedStarts.has(runIndex),
148
+ )
149
+ if (face < 0 && tJunctionConnectedStarts.has(runIndex)) tJunctionRoofOverlapRunCount += 1
150
+ const roofBackingFrames = roofFramesWithOwnedConnectedEnd(
151
+ roofTileFrames,
152
+ capEnd,
153
+ ownsConnectedRoofEnd,
154
+ )
155
+ const roofDistances = pathDistances(roofBackingFrames)
156
+ const roofLength = roofDistances.at(-1) ?? 0
157
+ const roof = new THREE.Mesh(
158
+ createRoofSlopeGeometry(roofBackingFrames, roofDistances, roofLength, wallHeight, face),
159
+ materials.kawara,
160
+ )
161
+ roof.name = `${id}-sakura-castle-kawara-${runIndex + 1}-${face < 0 ? 'inside' : 'outside'}`
162
+ roof.castShadow = true
163
+ roof.receiveShadow = true
164
+ group.add(roof)
165
+ const fascia = new THREE.Mesh(
166
+ createRoofEaveFasciaGeometry(roofBackingFrames, roofDistances, roofLength, wallHeight, face),
167
+ materials.kawara,
168
+ )
169
+ fascia.name = `${id}-sakura-castle-kawara-eave-${runIndex + 1}-${face < 0 ? 'inside' : 'outside'}`
170
+ fascia.castShadow = true
171
+ fascia.receiveShadow = true
172
+ group.add(fascia)
173
+ // Tiles must use the same canonical, uniformly sampled frame field as
174
+ // the roof sweep. Raw authored frames can contain duplicate seam points
175
+ // or abrupt basis changes at piece boundaries; sampling those directly
176
+ // occasionally rotates a tile's slope axis upright.
177
+ const tileRuns = kawaraTilePlacementRuns(roofTileFrames)
178
+ const tileMatrixStart = tileMatrices.length
179
+ for (const tileFrames of tileRuns) {
180
+ tilePlacementRunCount += 1
181
+ appendKawaraTileMatrices(
182
+ tileMatrices,
183
+ tileFrames,
184
+ wallHeight,
185
+ face,
186
+ )
187
+ }
188
+ if (tJunctionConnectedStarts.has(runIndex) && tileMatrices.length > tileMatrixStart) {
189
+ const terminal = canonicalRun.frames[0]!.base
190
+ const continuation = canonicalRun.frames[1]!.base.clone().sub(terminal).normalize()
191
+ for (let matrixIndex = tileMatrixStart; matrixIndex < tileMatrices.length; matrixIndex += 1) {
192
+ const matrix = tileMatrices[matrixIndex]!
193
+ const center = new THREE.Vector3().setFromMatrixPosition(matrix)
194
+ tJunctionTileMinimumStartProjection = Math.min(
195
+ tJunctionTileMinimumStartProjection,
196
+ center.clone().sub(terminal).dot(continuation),
197
+ )
198
+ }
199
+ }
200
+ }
201
+
202
+ const ridgeFrames = roofFramesWithOwnedConnectedEnd(
203
+ roofFramesWithTJunctionStartOverlap(frames, tJunctionConnectedStarts.has(runIndex)),
204
+ capEnd,
205
+ ownsConnectedRoofEnd,
206
+ )
207
+ const ridgeDistances = pathDistances(ridgeFrames)
208
+ const ridgeLength = ridgeDistances.at(-1) ?? 0
209
+ const ridge = new THREE.Mesh(
210
+ createContinuousKawaraRidgeGeometry(
211
+ ridgeFrames,
212
+ ridgeDistances,
213
+ ridgeLength,
214
+ wallHeight,
215
+ capStart,
216
+ capEnd,
217
+ ),
218
+ materials.kawara,
219
+ )
220
+ ridge.name = `${id}-sakura-castle-kawara-ridge-${runIndex + 1}`
221
+ ridge.userData.decorative = true
222
+ ridge.userData.visualOnly = true
223
+ ridge.userData.pathConstruction = 'continuous-swept-tube'
224
+ ridge.castShadow = true
225
+ ridge.receiveShadow = true
226
+ group.add(ridge)
227
+
228
+ for (const face of [-1, 1] as const) {
229
+ for (const height of [masonryHeight + 0.045, wallHeight - 0.065]) {
230
+ appendSweptTimberRail(
231
+ railPositions,
232
+ railUvs,
233
+ railMetricUvs,
234
+ railIndices,
235
+ frames,
236
+ distances,
237
+ totalLength,
238
+ face,
239
+ height,
240
+ capStart,
241
+ capEnd,
242
+ )
243
+ }
244
+ }
245
+ }
246
+
247
+ const postPlacements = distributePathPattern(
248
+ runs.map(run => ({
249
+ frames: run.frames.map(frame => ({
250
+ point: frame.base,
251
+ tangent: frame.tangent,
252
+ distance: frame.distance,
253
+ })),
254
+ length: run.length,
255
+ closed: run.closed,
256
+ // A connected wall terminal needs one architectural owner. The upstream
257
+ // run owns its end and the downstream run omits its start, producing one
258
+ // continuous vertical timber at the join instead of either a duplicate
259
+ // pair or the previous empty post bay.
260
+ includeStart: run.capStart,
261
+ includeEnd: true,
262
+ })),
263
+ {
264
+ maximumSpacing: POST_MAXIMUM_SPACING,
265
+ dedupeDistance: 0.025,
266
+ },
267
+ )
268
+ const worldUp = new THREE.Vector3(0, 1, 0)
269
+ // The masonry sill owns the lower wall band. Timber posts begin at its top
270
+ // seam (under the lower swept rail) instead of continuing through the
271
+ // brickwork to the deck.
272
+ const verticalPostBottom = masonryHeight - 0.03
273
+ const verticalPostHeight = Math.max(0.12, wallHeight - 0.06 - verticalPostBottom)
274
+ for (const placement of postPlacements) {
275
+ const tangent = placement.tangent.clone().setY(0)
276
+ if (tangent.lengthSq() <= 0.000_001) tangent.set(0, 0, 1)
277
+ tangent.normalize()
278
+ const frame: SakuraCastleWallPathFrame = {
279
+ base: placement.point,
280
+ tangent,
281
+ up: worldUp,
282
+ outward: new THREE.Vector3().crossVectors(tangent, worldUp).normalize(),
283
+ }
284
+ postMatrices.push(
285
+ new THREE.Matrix4().compose(
286
+ placement.point.clone().addScaledVector(worldUp, verticalPostBottom + verticalPostHeight / 2),
287
+ verticalFrameQuaternion(frame),
288
+ new THREE.Vector3(0.11, verticalPostHeight, TRACK_RAIL_WALL_THICKNESS + 0.055),
289
+ ),
290
+ )
291
+ }
292
+
293
+ if (runCount === 0) return undefined
294
+ if (postMatrices.length > 0) {
295
+ group.add(
296
+ instancedBatch(
297
+ `${id}-sakura-castle-timber-posts`,
298
+ getSharedTimberPostGeometry(),
299
+ materials.timber,
300
+ postMatrices,
301
+ true,
302
+ ),
303
+ )
304
+ }
305
+ if (railPositions.length > 0) {
306
+ const rails = new THREE.Mesh(
307
+ createGeometry(
308
+ railPositions,
309
+ railUvs,
310
+ railMetricUvs,
311
+ railIndices,
312
+ 'sakura-castle-continuous-timber-rail-geometry',
313
+ ),
314
+ materials.timber,
315
+ )
316
+ rails.name = `${id}-sakura-castle-timber-rails`
317
+ rails.userData.decorative = true
318
+ rails.userData.visualOnly = true
319
+ rails.userData.pathConstruction = 'continuous-swept-beam'
320
+ rails.castShadow = true
321
+ rails.receiveShadow = true
322
+ group.add(rails)
323
+ }
324
+ if (tileMatrices.length > 0) {
325
+ group.add(
326
+ instancedBatch(
327
+ `${id}-sakura-castle-kawara-tiles`,
328
+ getSharedKawaraTileGeometry(),
329
+ materials.kawara,
330
+ tileMatrices,
331
+ true,
332
+ ),
333
+ )
334
+ }
335
+ addSakuraCastleTerminalGables(group, `${id}-sakura-castle`, runs, wallHeight, materials.kawara)
336
+ addCanonicalWallTerminalSkins(group, `${id}-sakura-castle`, runs, masonryHeight, {
337
+ material: materials.masonry,
338
+ })
339
+ const upperTerminalHeight = Math.max(0, wallHeight - masonryHeight)
340
+ if (upperTerminalHeight > 0.001) {
341
+ addCanonicalWallTerminalSkins(
342
+ group,
343
+ `${id}-sakura-castle-timber`,
344
+ runs,
345
+ upperTerminalHeight,
346
+ {
347
+ material: materials.timber,
348
+ verticalOffset: masonryHeight,
349
+ },
350
+ )
351
+ }
352
+ group.userData.runCount = runCount
353
+ group.userData.postCount = postMatrices.length
354
+ group.userData.roofTileCount = tileMatrices.length
355
+ group.userData.tilePlacementRunCount = tilePlacementRunCount
356
+ group.userData.connectedRoofOverlapRunCount = connectedRoofOverlapRunCount
357
+ group.userData.tJunctionRoofOverlapRunCount = tJunctionRoofOverlapRunCount
358
+ group.userData.tJunctionRoofOverlapDistance = TRACK_RAIL_WALL_THICKNESS / 2 + CONNECTED_ROOF_SEAM_OVERLAP
359
+ group.userData.tJunctionTileMinimumStartProjection = Number.isFinite(tJunctionTileMinimumStartProjection)
360
+ ? tJunctionTileMinimumStartProjection
361
+ : undefined
362
+ group.userData.roofConstruction = 'continuous-canonical-sweep'
363
+ group.userData.runTerminalOwnership = runs.map(run => ({
364
+ start: run.startOwnership,
365
+ end: run.endOwnership,
366
+ startBase: run.frames[0]!.base.toArray(),
367
+ endBase: run.frames.at(-1)!.base.toArray(),
368
+ }))
369
+ root.add(group)
370
+ return group
371
+ }
372
+
373
+ function addSakuraCastleTerminalGables(
374
+ root: THREE.Group,
375
+ id: string,
376
+ runs: readonly SakuraCastleWallPathRun[],
377
+ wallHeight: number,
378
+ material: THREE.Material,
379
+ ): void {
380
+ const positions: number[] = []
381
+ const indices: number[] = []
382
+ for (const run of runs) {
383
+ if (run.closed || run.frames.length < 2) continue
384
+ for (const [terminal, ownership] of [
385
+ ['start', run.startOwnership],
386
+ ['end', run.endOwnership],
387
+ ] as const) {
388
+ if (ownership !== 'exposed') continue
389
+ const frame = terminal === 'start' ? run.frames[0]! : run.frames.at(-1)!
390
+ const normal = frame.tangent.clone().multiplyScalar(terminal === 'start' ? -1 : 1).normalize()
391
+ const left = frame.base
392
+ .clone()
393
+ .addScaledVector(frame.outward, -ROOF_HALF_SPAN)
394
+ .addScaledVector(frame.up, wallHeight + ROOF_EAVE_LIFT - 0.03)
395
+ const ridge = frame.base.clone().addScaledVector(frame.up, wallHeight + ROOF_RISE - 0.03)
396
+ const right = frame.base
397
+ .clone()
398
+ .addScaledVector(frame.outward, ROOF_HALF_SPAN)
399
+ .addScaledVector(frame.up, wallHeight + ROOF_EAVE_LIFT - 0.03)
400
+ const offset = positions.length / 3
401
+ positions.push(...left.toArray(), ...ridge.toArray(), ...right.toArray())
402
+ const candidate = ridge.clone().sub(left).cross(right.clone().sub(left))
403
+ if (candidate.dot(normal) >= 0) indices.push(offset, offset + 1, offset + 2)
404
+ else indices.push(offset, offset + 2, offset + 1)
405
+ }
406
+ }
407
+ if (indices.length === 0) return
408
+ const geometry = new THREE.BufferGeometry()
409
+ geometry.name = 'sakura-castle-terminal-gable-geometry'
410
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
411
+ geometry.setIndex(indices)
412
+ geometry.computeVertexNormals()
413
+ const mesh = new THREE.Mesh(geometry, material)
414
+ mesh.name = `${id}-terminal-gables`
415
+ mesh.castShadow = true
416
+ mesh.receiveShadow = true
417
+ mesh.userData.decorative = true
418
+ mesh.userData.visualOnly = true
419
+ root.add(mesh)
420
+ }
421
+
422
+ function createFaceBandGeometry(
423
+ frames: readonly SakuraCastleWallPathFrame[],
424
+ distances: readonly number[],
425
+ totalLength: number,
426
+ face: -1 | 1,
427
+ bottom: number,
428
+ top: number,
429
+ extendStart: boolean,
430
+ extendEnd: boolean,
431
+ ): THREE.BufferGeometry {
432
+ const positions: number[] = []
433
+ const uvs: number[] = []
434
+ const metricUvs: number[] = []
435
+ const indices: number[] = []
436
+ const faceOffset = face * (TRACK_RAIL_WALL_THICKNESS / 2 + 0.006)
437
+ frames.forEach((frame, index) => {
438
+ let facadeOffset = frame.outward.clone().multiplyScalar(faceOffset)
439
+ if (index > 0 && index < frames.length - 1) {
440
+ const incoming = frame.base.clone().sub(frames[index - 1]!.base).setY(0)
441
+ const outgoing = frames[index + 1]!.base.clone().sub(frame.base).setY(0)
442
+ if (incoming.lengthSq() > 0.000_001 && outgoing.lengthSq() > 0.000_001) {
443
+ incoming.normalize()
444
+ outgoing.normalize()
445
+ const incomingOutward = new THREE.Vector3(incoming.z, 0, -incoming.x)
446
+ const outgoingOutward = new THREE.Vector3(outgoing.z, 0, -outgoing.x)
447
+ if (incomingOutward.dot(frame.outward) < 0) incomingOutward.negate()
448
+ if (outgoingOutward.dot(frame.outward) < 0) outgoingOutward.negate()
449
+ const miter = incomingOutward.add(outgoingOutward)
450
+ if (miter.lengthSq() > 0.000_001) {
451
+ miter.normalize()
452
+ const projection = Math.max(0.000_001, Math.abs(miter.dot(outgoingOutward)))
453
+ facadeOffset = miter.multiplyScalar(faceOffset / projection)
454
+ }
455
+ }
456
+ }
457
+ // Connected wall ends already share the exact canonical connector row.
458
+ // Extending their facade past that row makes two pieces overlap and lets
459
+ // one sill protrude below/around the other. The wrap is only required at
460
+ // a genuinely exposed terminal.
461
+ const terminalExtension = index === 0 && extendStart
462
+ ? frame.tangent.clone().normalize().multiplyScalar(-Math.abs(faceOffset))
463
+ : index === frames.length - 1 && extendEnd
464
+ ? frame.tangent.clone().normalize().multiplyScalar(Math.abs(faceOffset))
465
+ : new THREE.Vector3()
466
+ for (const height of [bottom, top]) {
467
+ const point = frame.base
468
+ .clone()
469
+ .add(facadeOffset)
470
+ .add(terminalExtension)
471
+ .addScaledVector(frame.up, height - 0.03)
472
+ positions.push(point.x, point.y, point.z)
473
+ uvs.push(distances[index]!, height)
474
+ metricUvs.push(distances[index]!, height, 1, totalLength)
475
+ }
476
+ })
477
+ appendOrientedStripIndices(indices, positions, frames, frame => frame.outward.clone().multiplyScalar(face))
478
+ return createGeometry(positions, uvs, metricUvs, indices, 'sakura-castle-masonry-band-geometry')
479
+ }
480
+
481
+ function createRoofSlopeGeometry(
482
+ frames: readonly SakuraCastleWallPathFrame[],
483
+ distances: readonly number[],
484
+ totalLength: number,
485
+ wallHeight: number,
486
+ face: -1 | 1,
487
+ ): THREE.BufferGeometry {
488
+ const positions: number[] = []
489
+ const uvs: number[] = []
490
+ const metricUvs: number[] = []
491
+ const indices: number[] = []
492
+ const slopeLength = Math.hypot(ROOF_HALF_SPAN, ROOF_RISE - ROOF_EAVE_LIFT)
493
+ frames.forEach((frame, index) => {
494
+ const eave = frame.base
495
+ .clone()
496
+ .addScaledVector(frame.outward, face * ROOF_HALF_SPAN)
497
+ .addScaledVector(frame.up, wallHeight + ROOF_EAVE_LIFT - 0.03)
498
+ const ridge = frame.base.clone().addScaledVector(frame.up, wallHeight + ROOF_RISE - 0.03)
499
+ for (const [point, crossSlope] of [
500
+ [eave, slopeLength],
501
+ [ridge, 0],
502
+ ] as const) {
503
+ positions.push(point.x, point.y, point.z)
504
+ uvs.push(distances[index]!, crossSlope)
505
+ metricUvs.push(distances[index]!, crossSlope, 1, totalLength)
506
+ }
507
+ })
508
+ appendOrientedStripIndices(indices, positions, frames, frame => frame.up)
509
+ return createGeometry(positions, uvs, metricUvs, indices, 'sakura-castle-kawara-slope-geometry')
510
+ }
511
+
512
+ /**
513
+ * Open wall connectors meet on one docking plane. The run ending at that
514
+ * plane owns a tiny visual roof underlap; the run starting there stays exact.
515
+ * This closes transform/curve sampling tolerance without double-owning tiles
516
+ * or changing the canonical wall and collider boundary.
517
+ */
518
+ function roofFramesWithOwnedConnectedEnd(
519
+ frames: readonly SakuraCastleWallPathFrame[],
520
+ capEnd: boolean,
521
+ ownsConnectedEnd = true,
522
+ ): SakuraCastleWallPathFrame[] {
523
+ const owned = frames.map(frame => ({
524
+ base: frame.base.clone(),
525
+ tangent: frame.tangent.clone(),
526
+ up: frame.up.clone(),
527
+ outward: frame.outward.clone(),
528
+ }))
529
+ if (!capEnd && ownsConnectedEnd && owned.length > 0) {
530
+ const end = owned.at(-1)!
531
+ const previous = owned[Math.max(0, owned.length - 2)]!
532
+ const continuation = end.base.clone().sub(previous.base)
533
+ if (continuation.lengthSq() > 0.000_001) {
534
+ end.base.addScaledVector(continuation.normalize(), CONNECTED_ROOF_SEAM_OVERLAP)
535
+ }
536
+ }
537
+ return owned
538
+ }
539
+
540
+ function roofFramesWithTJunctionStartOverlap(
541
+ frames: readonly SakuraCastleWallPathFrame[],
542
+ overlapsReceiver: boolean,
543
+ ): SakuraCastleWallPathFrame[] {
544
+ const owned = frames.map(frame => ({
545
+ base: frame.base.clone(),
546
+ tangent: frame.tangent.clone(),
547
+ up: frame.up.clone(),
548
+ outward: frame.outward.clone(),
549
+ }))
550
+ if (!overlapsReceiver || owned.length < 2) return owned
551
+ const start = owned[0]!
552
+ const next = owned[1]!
553
+ const continuation = next.base.clone().sub(start.base)
554
+ if (continuation.lengthSq() <= 0.000_001) return owned
555
+ start.base.addScaledVector(
556
+ continuation.normalize(),
557
+ -(TRACK_RAIL_WALL_THICKNESS / 2 + CONNECTED_ROOF_SEAM_OVERLAP),
558
+ )
559
+ return owned
560
+ }
561
+
562
+ function createRoofEaveFasciaGeometry(
563
+ frames: readonly SakuraCastleWallPathFrame[],
564
+ distances: readonly number[],
565
+ totalLength: number,
566
+ wallHeight: number,
567
+ face: -1 | 1,
568
+ ): THREE.BufferGeometry {
569
+ const positions: number[] = []
570
+ const uvs: number[] = []
571
+ const metricUvs: number[] = []
572
+ const indices: number[] = []
573
+ frames.forEach((frame, index) => {
574
+ const eave = roofSurfacePoint(frame, wallHeight, face, 1)
575
+ for (const drop of [0, 0.075]) {
576
+ const point = eave.clone().addScaledVector(frame.up, -drop)
577
+ positions.push(point.x, point.y, point.z)
578
+ uvs.push(distances[index]!, drop)
579
+ metricUvs.push(distances[index]!, drop, 1, totalLength)
580
+ }
581
+ })
582
+ appendOrientedStripIndices(
583
+ indices,
584
+ positions,
585
+ frames,
586
+ frame => frame.outward.clone().multiplyScalar(face),
587
+ )
588
+ return createGeometry(positions, uvs, metricUvs, indices, 'sakura-castle-kawara-eave-fascia-geometry')
589
+ }
590
+
591
+ function appendOrientedStripIndices(
592
+ indices: number[],
593
+ positions: readonly number[],
594
+ frames: readonly SakuraCastleWallPathFrame[],
595
+ desiredNormal: (frame: SakuraCastleWallPathFrame) => THREE.Vector3,
596
+ ): void {
597
+ for (let index = 0; index < frames.length - 1; index += 1) {
598
+ const current = index * 2
599
+ const next = current + 2
600
+ const target = desiredNormal(interpolateFrame(frames[index]!, frames[index + 1]!, 0.5)).normalize()
601
+ // Curved and graded strips are non-planar quads. Orient each triangle
602
+ // independently; borrowing the first triangle's winding for the second
603
+ // made alternating wedges disappear under FrontSide culling.
604
+ appendTriangleFacing(indices, positions, current, next, current + 1, target)
605
+ appendTriangleFacing(indices, positions, current + 1, next, next + 1, target)
606
+ }
607
+ }
608
+
609
+ function appendTriangleFacing(
610
+ indices: number[],
611
+ positions: readonly number[],
612
+ aIndex: number,
613
+ bIndex: number,
614
+ cIndex: number,
615
+ target: THREE.Vector3,
616
+ ): void {
617
+ const a = vectorAt(positions, aIndex)
618
+ const normal = vectorAt(positions, bIndex).sub(a).cross(vectorAt(positions, cIndex).sub(a))
619
+ if (normal.dot(target) >= 0) indices.push(aIndex, bIndex, cIndex)
620
+ else indices.push(aIndex, cIndex, bIndex)
621
+ }
622
+
623
+ function createContinuousKawaraRidgeGeometry(
624
+ frames: readonly SakuraCastleWallPathFrame[],
625
+ distances: readonly number[],
626
+ totalLength: number,
627
+ wallHeight: number,
628
+ capStart: boolean,
629
+ capEnd: boolean,
630
+ ): THREE.BufferGeometry {
631
+ const positions: number[] = []
632
+ const uvs: number[] = []
633
+ const metricUvs: number[] = []
634
+ const indices: number[] = []
635
+ const radius = 0.075
636
+ const radialSegments = 6
637
+ const circumference = Math.PI * radius * 2
638
+
639
+ frames.forEach((frame, frameIndex) => {
640
+ const center = frame.base.clone().addScaledVector(frame.up, wallHeight + ROOF_RISE - 0.03)
641
+ for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) {
642
+ const angle = (radialIndex / radialSegments) * Math.PI * 2
643
+ const point = center
644
+ .clone()
645
+ .addScaledVector(frame.outward, Math.cos(angle) * radius)
646
+ .addScaledVector(frame.up, Math.sin(angle) * radius)
647
+ positions.push(point.x, point.y, point.z)
648
+ uvs.push(distances[frameIndex]!, radialIndex / radialSegments)
649
+ metricUvs.push(distances[frameIndex]!, (radialIndex / radialSegments) * circumference, 1, totalLength)
650
+ }
651
+ })
652
+
653
+ for (let frameIndex = 0; frameIndex < frames.length - 1; frameIndex += 1) {
654
+ const current = frameIndex * radialSegments
655
+ const next = current + radialSegments
656
+ const frame = interpolateFrame(frames[frameIndex]!, frames[frameIndex + 1]!, 0.5)
657
+ for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) {
658
+ const following = (radialIndex + 1) % radialSegments
659
+ const angle = ((radialIndex + 0.5) / radialSegments) * Math.PI * 2
660
+ const target = frame.outward
661
+ .clone()
662
+ .multiplyScalar(Math.cos(angle))
663
+ .addScaledVector(frame.up, Math.sin(angle))
664
+ .normalize()
665
+ appendTriangleFacing(indices, positions, current + radialIndex, next + radialIndex, current + following, target)
666
+ appendTriangleFacing(indices, positions, current + following, next + radialIndex, next + following, target)
667
+ }
668
+ }
669
+
670
+ const appendCap = (frameIndex: number, target: THREE.Vector3): void => {
671
+ const frame = frames[frameIndex]!
672
+ const center = frame.base.clone().addScaledVector(frame.up, wallHeight + ROOF_RISE - 0.03)
673
+ const centerIndex = positions.length / 3
674
+ positions.push(center.x, center.y, center.z)
675
+ uvs.push(distances[frameIndex]!, 0.5)
676
+ metricUvs.push(distances[frameIndex]!, circumference / 2, 1, totalLength)
677
+ const ringStart = frameIndex * radialSegments
678
+ for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) {
679
+ const following = (radialIndex + 1) % radialSegments
680
+ appendTriangleFacing(indices, positions, centerIndex, ringStart + radialIndex, ringStart + following, target)
681
+ }
682
+ }
683
+
684
+ if (capStart) appendCap(0, frames[0]!.tangent.clone().negate())
685
+ if (capEnd) appendCap(frames.length - 1, frames.at(-1)!.tangent)
686
+
687
+ return createGeometry(positions, uvs, metricUvs, indices, 'sakura-castle-continuous-kawara-ridge-geometry')
688
+ }
689
+
690
+ function appendSweptTimberRail(
691
+ positions: number[],
692
+ uvs: number[],
693
+ metricUvs: number[],
694
+ indices: number[],
695
+ frames: readonly SakuraCastleWallPathFrame[],
696
+ distances: readonly number[],
697
+ totalLength: number,
698
+ face: -1 | 1,
699
+ height: number,
700
+ capStart: boolean,
701
+ capEnd: boolean,
702
+ ): void {
703
+ const firstVertex = positions.length / 3
704
+ const halfHeight = 0.0425
705
+ const halfDepth = 0.026
706
+ const faceOffset = face * (TRACK_RAIL_WALL_THICKNESS / 2 + halfDepth)
707
+ const crossSections = [
708
+ [-halfHeight, -halfDepth],
709
+ [halfHeight, -halfDepth],
710
+ [halfHeight, halfDepth],
711
+ [-halfHeight, halfDepth],
712
+ ] as const
713
+
714
+ frames.forEach((frame, frameIndex) => {
715
+ const center = frame.base
716
+ .clone()
717
+ .addScaledVector(frame.outward, faceOffset)
718
+ .addScaledVector(frame.up, height - 0.03)
719
+ crossSections.forEach(([vertical, depth], crossIndex) => {
720
+ const point = center.clone().addScaledVector(frame.up, vertical).addScaledVector(frame.outward, depth)
721
+ positions.push(point.x, point.y, point.z)
722
+ uvs.push(distances[frameIndex]!, crossIndex / crossSections.length)
723
+ metricUvs.push(crossIndex / crossSections.length, distances[frameIndex]!, 1, totalLength)
724
+ })
725
+ })
726
+
727
+ for (let frameIndex = 0; frameIndex < frames.length - 1; frameIndex += 1) {
728
+ const current = firstVertex + frameIndex * crossSections.length
729
+ const next = current + crossSections.length
730
+ const frame = interpolateFrame(frames[frameIndex]!, frames[frameIndex + 1]!, 0.5)
731
+ for (let side = 0; side < crossSections.length; side += 1) {
732
+ const following = (side + 1) % crossSections.length
733
+ const [verticalA, depthA] = crossSections[side]!
734
+ const [verticalB, depthB] = crossSections[following]!
735
+ const target = frame.up
736
+ .clone()
737
+ .multiplyScalar(verticalA + verticalB)
738
+ .addScaledVector(frame.outward, depthA + depthB)
739
+ .normalize()
740
+ appendTriangleFacing(indices, positions, current + side, next + side, current + following, target)
741
+ appendTriangleFacing(indices, positions, current + following, next + side, next + following, target)
742
+ }
743
+ }
744
+
745
+ const start = firstVertex
746
+ const end = firstVertex + (frames.length - 1) * crossSections.length
747
+ if (capStart) {
748
+ appendTriangleFacing(indices, positions, start, start + 1, start + 2, frames[0]!.tangent.clone().negate())
749
+ appendTriangleFacing(indices, positions, start, start + 2, start + 3, frames[0]!.tangent.clone().negate())
750
+ }
751
+ if (capEnd) {
752
+ appendTriangleFacing(indices, positions, end, end + 2, end + 1, frames.at(-1)!.tangent)
753
+ appendTriangleFacing(indices, positions, end, end + 3, end + 2, frames.at(-1)!.tangent)
754
+ }
755
+ }
756
+
757
+ function vectorAt(positions: readonly number[], vertex: number): THREE.Vector3 {
758
+ const offset = vertex * 3
759
+ return new THREE.Vector3(positions[offset]!, positions[offset + 1]!, positions[offset + 2]!)
760
+ }
761
+
762
+ function kawaraTilePlacementRuns(
763
+ frames: readonly SakuraCastleWallPathFrame[],
764
+ ): SakuraCastleWallPathFrame[][] {
765
+ if (frames.length < 3) return [[...frames]]
766
+ const runs: SakuraCastleWallPathFrame[][] = []
767
+ let current = [frames[0]!]
768
+ // Ordinary bends keep one rotating cadence. Only a near-right-angle authored
769
+ // junction resets placement so no rigid tile bridges across empty space.
770
+ const sharpCornerCosine = Math.cos(THREE.MathUtils.degToRad(78))
771
+ for (let index = 1; index < frames.length - 1; index += 1) {
772
+ const corner = frames[index]!
773
+ current.push(corner)
774
+ const incoming = corner.base.clone().sub(frames[index - 1]!.base)
775
+ const outgoing = frames[index + 1]!.base.clone().sub(corner.base)
776
+ if (
777
+ incoming.lengthSq() > 0.000_001 &&
778
+ outgoing.lengthSq() > 0.000_001 &&
779
+ incoming.normalize().dot(outgoing.normalize()) < sharpCornerCosine
780
+ ) {
781
+ runs.push(current)
782
+ current = [corner]
783
+ }
784
+ }
785
+ current.push(frames.at(-1)!)
786
+ runs.push(current)
787
+ return runs
788
+ }
789
+
790
+ function appendKawaraTileMatrices(
791
+ target: THREE.Matrix4[],
792
+ sourceFrames: readonly SakuraCastleWallPathFrame[],
793
+ wallHeight: number,
794
+ face: -1 | 1,
795
+ ): void {
796
+ const distances = pointDistances(sourceFrames.map(frame => roofSurfacePoint(frame, wallHeight, face, 1)))
797
+ const totalLength = distances.at(-1) ?? 0
798
+ if (totalLength <= 0.001) return
799
+ // Rigid tiles never straddle independently generated runs. Every run owns
800
+ // one half cadence cell at each terminal, so connected tile edges meet at
801
+ // the canonical seam without either curve projecting a tile backwards into
802
+ // its neighbour. The continuous roof backing and ridge still own the seam.
803
+ const startOffset = 0.5
804
+ const endOffset = 0.5
805
+ const tileCount = Math.max(
806
+ 1,
807
+ Math.round(totalLength / KAWARA_TILE_SPACING - startOffset - endOffset + 1),
808
+ )
809
+ const intervalCount = startOffset + tileCount - 1 + endOffset
810
+ const tileArcLength = totalLength / Math.max(0.5, intervalCount)
811
+ for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
812
+ const centerDistance = (startOffset + tileIndex) * tileArcLength
813
+ const startDistance = Math.max(0, centerDistance - tileArcLength / 2)
814
+ const endDistance = Math.min(totalLength, centerDistance + tileArcLength / 2)
815
+ const startFrame = sampleFrame(sourceFrames, distances, startDistance)
816
+ const endFrame = sampleFrame(sourceFrames, distances, endDistance)
817
+ const middleFrame = sampleFrame(sourceFrames, distances, centerDistance)
818
+ const ridge = roofSurfacePoint(middleFrame, wallHeight, face, 0)
819
+ const eave = roofSurfacePoint(middleFrame, wallHeight, face, 1)
820
+ // Build one orthonormal roof frame from the canonical centre sample.
821
+ // Using the curved surface chord as a matrix axis sheared isolated tiles
822
+ // whenever that chord contained a normal component on the inside of a
823
+ // bend. Those sheared tiles are the apparent upright fins.
824
+ const longitudinal = middleFrame.tangent.clone().normalize()
825
+ const slopeDirection = eave.clone().sub(ridge)
826
+ slopeDirection.addScaledVector(longitudinal, -slopeDirection.dot(longitudinal))
827
+ const slopeLength = slopeDirection.length()
828
+ if (slopeLength <= 0.001) continue
829
+ slopeDirection.normalize()
830
+ let surfaceNormal = new THREE.Vector3().crossVectors(slopeDirection, longitudinal).normalize()
831
+ if (surfaceNormal.dot(middleFrame.up) < 0) {
832
+ longitudinal.negate()
833
+ surfaceNormal = new THREE.Vector3().crossVectors(slopeDirection, longitudinal).normalize()
834
+ }
835
+ const rotation = new THREE.Quaternion().setFromRotationMatrix(
836
+ new THREE.Matrix4().makeBasis(longitudinal, surfaceNormal, slopeDirection),
837
+ )
838
+
839
+ for (let row = 0; row < KAWARA_TILE_ROWS; row += 1) {
840
+ const startFraction = row === 0 ? 0.015 : 0.45
841
+ const endFraction = row === 0 ? 0.56 : 0.985
842
+ const centerFraction = (startFraction + endFraction) / 2
843
+ const startCenter = roofSurfacePoint(startFrame, wallHeight, face, centerFraction)
844
+ const endCenter = roofSurfacePoint(endFrame, wallHeight, face, centerFraction)
845
+ const chord = endCenter.clone().sub(startCenter)
846
+ // Arc length chooses the cadence, but a rigid tile spans the straight
847
+ // 3D chord between its cell boundaries. Giving it the longer arc length
848
+ // makes adjacent instances overlap on the short side of a curve. Do not
849
+ // project the chord onto a rotating frame: that was the steep-slope
850
+ // shrink bug.
851
+ const reachesBeyondRun = centerDistance - tileArcLength / 2 < 0 || centerDistance + tileArcLength / 2 > totalLength
852
+ const tileWidth = reachesBeyondRun
853
+ ? tileArcLength
854
+ : Math.max(0.04, Math.min(tileArcLength, chord.length()))
855
+ const rowLength = slopeLength * (endFraction - startFraction)
856
+ const center = roofSurfacePoint(middleFrame, wallHeight, face, centerFraction)
857
+ .addScaledVector(surfaceNormal, 0.012 + row * 0.008)
858
+ const matrix = new THREE.Matrix4().compose(
859
+ center,
860
+ rotation,
861
+ new THREE.Vector3(tileWidth, 1, rowLength),
862
+ )
863
+ target.push(matrix)
864
+ }
865
+ }
866
+ }
867
+
868
+ function roofSurfacePoint(
869
+ frame: SakuraCastleWallPathFrame,
870
+ wallHeight: number,
871
+ face: -1 | 1,
872
+ slopeFraction: number,
873
+ ): THREE.Vector3 {
874
+ const ridge = frame.base.clone().addScaledVector(frame.up, wallHeight + ROOF_RISE - 0.03)
875
+ const eave = frame.base
876
+ .clone()
877
+ .addScaledVector(frame.outward, face * ROOF_HALF_SPAN)
878
+ .addScaledVector(frame.up, wallHeight + ROOF_EAVE_LIFT - 0.03)
879
+ return ridge.lerp(eave, slopeFraction)
880
+ }
881
+
882
+ function pointDistances(points: readonly THREE.Vector3[]): number[] {
883
+ const distances = [0]
884
+ for (let index = 1; index < points.length; index += 1) {
885
+ distances.push(distances[index - 1]! + points[index]!.distanceTo(points[index - 1]!))
886
+ }
887
+ return distances
888
+ }
889
+
890
+ function getSharedTimberPostGeometry(): THREE.BoxGeometry {
891
+ if (sharedTimberPostGeometry) return sharedTimberPostGeometry
892
+ sharedTimberPostGeometry = new THREE.BoxGeometry(1, 1, 1)
893
+ sharedTimberPostGeometry.name = 'sakura-castle-timber-post-geometry'
894
+ return sharedTimberPostGeometry
895
+ }
896
+
897
+ function getSharedKawaraTileGeometry(): THREE.BufferGeometry {
898
+ if (sharedKawaraTileGeometry) return sharedKawaraTileGeometry
899
+ const positions: number[] = []
900
+ const uvs: number[] = []
901
+ const metricUvs: number[] = []
902
+ const indices: number[] = []
903
+ const acrossSegments = 2
904
+ // Every tile row has the same cross-section and the slope axis is linear,
905
+ // so the old middle row described the exact same surface twice.
906
+ const slopeSegments = 1
907
+ const rowSize = acrossSegments + 1
908
+ const textureFootprint = 0.4
909
+ for (let slopeIndex = 0; slopeIndex <= slopeSegments; slopeIndex += 1) {
910
+ const v = slopeIndex / slopeSegments
911
+ const z = v - 0.5
912
+ for (let acrossIndex = 0; acrossIndex <= acrossSegments; acrossIndex += 1) {
913
+ const u = acrossIndex / acrossSegments
914
+ const x = u - 0.5
915
+ const arch = Math.max(0, Math.cos(x * Math.PI)) * 0.032
916
+ positions.push(x, arch + v * 0.006, z)
917
+ const rotatedU = v
918
+ const rotatedV = 1 - u
919
+ const textureU = 0.5 + (rotatedU - 0.5) * textureFootprint
920
+ const textureV = 0.5 + (rotatedV - 0.5) * textureFootprint
921
+ uvs.push(textureU, textureV)
922
+ // The tile is instanced in its wall-local frame. Mark its authored UVs
923
+ // so the shared material rotates with each instance instead of falling
924
+ // back to one world-projected texture direction.
925
+ metricUvs.push(textureU, textureV, 1, 1)
926
+ }
927
+ }
928
+ for (let slopeIndex = 0; slopeIndex < slopeSegments; slopeIndex += 1) {
929
+ const currentRow = slopeIndex * rowSize
930
+ const nextRow = currentRow + rowSize
931
+ for (let acrossIndex = 0; acrossIndex < acrossSegments; acrossIndex += 1) {
932
+ const current = currentRow + acrossIndex
933
+ const next = nextRow + acrossIndex
934
+ indices.push(current, next, current + 1, current + 1, next, next + 1)
935
+ }
936
+ }
937
+ const geometry = new THREE.BufferGeometry()
938
+ geometry.name = 'sakura-castle-kawara-tile-geometry'
939
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
940
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
941
+ geometry.setAttribute('themeNetUv', new THREE.Float32BufferAttribute(metricUvs, 4))
942
+ geometry.setIndex(indices)
943
+ geometry.computeVertexNormals()
944
+ geometry.computeBoundingBox()
945
+ geometry.computeBoundingSphere()
946
+ sharedKawaraTileGeometry = geometry
947
+ return sharedKawaraTileGeometry
948
+ }
949
+
950
+ function createGeometry(
951
+ positions: number[],
952
+ uvs: number[],
953
+ metricUvs: number[],
954
+ indices: number[],
955
+ name: string,
956
+ ): THREE.BufferGeometry {
957
+ const geometry = new THREE.BufferGeometry()
958
+ geometry.name = name
959
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
960
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
961
+ geometry.setAttribute('themeNetUv', new THREE.Float32BufferAttribute(metricUvs, 4))
962
+ geometry.setIndex(indices)
963
+ geometry.computeVertexNormals()
964
+ geometry.computeBoundingBox()
965
+ geometry.computeBoundingSphere()
966
+ return geometry
967
+ }
968
+
969
+ function pathDistances(frames: readonly SakuraCastleWallPathFrame[]): number[] {
970
+ const distances = [0]
971
+ for (let index = 1; index < frames.length; index += 1) {
972
+ distances.push(distances[index - 1]! + frames[index]!.base.distanceTo(frames[index - 1]!.base))
973
+ }
974
+ return distances
975
+ }
976
+
977
+ function sampleFrame(
978
+ frames: readonly SakuraCastleWallPathFrame[],
979
+ distances: readonly number[],
980
+ target: number,
981
+ ): SakuraCastleWallPathFrame {
982
+ let segment = 0
983
+ while (segment < distances.length - 2 && distances[segment + 1]! < target) segment += 1
984
+ const length = distances[segment + 1]! - distances[segment]!
985
+ return interpolateFrame(frames[segment]!, frames[segment + 1]!, length <= 0.000_001 ? 0 : (target - distances[segment]!) / length)
986
+ }
987
+
988
+ function interpolateFrame(
989
+ start: SakuraCastleWallPathFrame,
990
+ end: SakuraCastleWallPathFrame,
991
+ progress: number,
992
+ ): SakuraCastleWallPathFrame {
993
+ const tangent = start.tangent.clone().lerp(end.tangent, progress).normalize()
994
+ const up = start.up.clone().lerp(end.up, progress)
995
+ up.addScaledVector(tangent, -up.dot(tangent)).normalize()
996
+ const outward = new THREE.Vector3().crossVectors(tangent, up).normalize()
997
+ const outwardHint = start.outward.clone().lerp(end.outward, progress)
998
+ if (outward.dot(outwardHint) < 0) outward.negate()
999
+ return { base: start.base.clone().lerp(end.base, progress), tangent, up, outward }
1000
+ }
1001
+
1002
+ function verticalFrameQuaternion(frame: SakuraCastleWallPathFrame): THREE.Quaternion {
1003
+ const up = new THREE.Vector3(0, 1, 0)
1004
+ const outward = frame.outward.clone().addScaledVector(up, -frame.outward.dot(up))
1005
+ if (outward.lengthSq() < 0.000_001) outward.crossVectors(frame.tangent, up)
1006
+ outward.normalize()
1007
+ const tangent = new THREE.Vector3().crossVectors(up, outward).normalize()
1008
+ outward.crossVectors(tangent, up).normalize()
1009
+ return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(tangent, up, outward))
1010
+ }
1011
+
1012
+ function instancedBatch(
1013
+ name: string,
1014
+ geometry: THREE.BufferGeometry,
1015
+ material: THREE.Material,
1016
+ matrices: readonly THREE.Matrix4[],
1017
+ sharedGeometry = false,
1018
+ ): THREE.InstancedMesh {
1019
+ const mesh = new THREE.InstancedMesh(geometry, material, matrices.length)
1020
+ mesh.name = name
1021
+ mesh.userData.decorative = true
1022
+ mesh.userData.visualOnly = true
1023
+ mesh.userData.sharedGeometry = sharedGeometry
1024
+ mesh.castShadow = true
1025
+ mesh.receiveShadow = true
1026
+ matrices.forEach((matrix, index) => mesh.setMatrixAt(index, matrix))
1027
+ mesh.instanceMatrix.needsUpdate = true
1028
+ mesh.computeBoundingBox()
1029
+ mesh.computeBoundingSphere()
1030
+ return mesh
1031
+ }