@marble.fun/track-catalog 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- package/src/three/wild-west-sand-appearance.ts +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { trackSurfaceBasis, type TrackSurfaceBasis } from '@marble.fun/physics-engine'
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
export type TrackWallBoundaryRole = 'outer' | 'junction' | 'divider'
|
|
4
|
+
export type TrackBoundaryOwnership = 'exposed' | 'connected' | 'closed-loop'
|
|
5
|
+
|
|
6
|
+
export const TRACK_SURFACE_BOUNDARY_INTERIOR = 0
|
|
7
|
+
export const TRACK_SURFACE_BOUNDARY_EXPOSED = 1
|
|
8
|
+
export const TRACK_SURFACE_BOUNDARY_CONNECTED = 2
|
|
9
|
+
|
|
10
|
+
export interface CanonicalWallFrame {
|
|
11
|
+
base: THREE.Vector3
|
|
12
|
+
tangent: THREE.Vector3
|
|
13
|
+
up: THREE.Vector3
|
|
14
|
+
outward: THREE.Vector3
|
|
15
|
+
distance: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CanonicalWallRun {
|
|
19
|
+
frames: readonly CanonicalWallFrame[]
|
|
20
|
+
role: TrackWallBoundaryRole
|
|
21
|
+
capStart: boolean
|
|
22
|
+
capEnd: boolean
|
|
23
|
+
startOwnership: TrackBoundaryOwnership
|
|
24
|
+
endOwnership: TrackBoundaryOwnership
|
|
25
|
+
closed: boolean
|
|
26
|
+
length: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RawWallFrame {
|
|
30
|
+
base: THREE.Vector3
|
|
31
|
+
tangent: THREE.Vector3
|
|
32
|
+
up: THREE.Vector3
|
|
33
|
+
outward: THREE.Vector3
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CanonicalWallTerminal = 'start' | 'end'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Finds convergent terminals that are open only because two runs inside the
|
|
40
|
+
* same visual assembly meet there. Ordinary end-to-start track connectors
|
|
41
|
+
* remain neighbor-owned and open; two starts or two ends at one point need a
|
|
42
|
+
* single local visual closure so modular facades cannot expose a slit.
|
|
43
|
+
*/
|
|
44
|
+
export function canonicalConvergentConnectedTerminalClosures(
|
|
45
|
+
runs: readonly CanonicalWallRun[],
|
|
46
|
+
): ReadonlySet<string> {
|
|
47
|
+
const groups = new Map<string, Array<{ runIndex: number; terminal: CanonicalWallTerminal }>>()
|
|
48
|
+
runs.forEach((run, runIndex) => {
|
|
49
|
+
if (run.closed || run.frames.length < 2) return
|
|
50
|
+
for (const [terminal, ownership] of [
|
|
51
|
+
['start', run.startOwnership],
|
|
52
|
+
['end', run.endOwnership],
|
|
53
|
+
] as const) {
|
|
54
|
+
if (ownership !== 'connected') continue
|
|
55
|
+
const frame = terminal === 'start' ? run.frames[0]! : run.frames.at(-1)!
|
|
56
|
+
const key = frame.base.toArray().map(value => value.toFixed(5)).join(':')
|
|
57
|
+
const entries = groups.get(key) ?? []
|
|
58
|
+
entries.push({ runIndex, terminal })
|
|
59
|
+
groups.set(key, entries)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
const owners = new Set<string>()
|
|
63
|
+
for (const entries of groups.values()) {
|
|
64
|
+
if (entries.length < 2 || !entries.every(entry => entry.terminal === entries[0]!.terminal)) continue
|
|
65
|
+
const owner = entries[0]!
|
|
66
|
+
owners.add(`${owner.runIndex}:${owner.terminal}`)
|
|
67
|
+
}
|
|
68
|
+
return owners
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EPSILON = 0.000_001
|
|
72
|
+
|
|
73
|
+
function finiteVector(vector: THREE.Vector3): boolean {
|
|
74
|
+
return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function segmentsIntersect(
|
|
78
|
+
a: THREE.Vector3,
|
|
79
|
+
b: THREE.Vector3,
|
|
80
|
+
c: THREE.Vector3,
|
|
81
|
+
d: THREE.Vector3,
|
|
82
|
+
): boolean {
|
|
83
|
+
const first = b.clone().sub(a)
|
|
84
|
+
const second = d.clone().sub(c)
|
|
85
|
+
const between = a.clone().sub(c)
|
|
86
|
+
const firstLength = first.dot(first)
|
|
87
|
+
const crossLength = first.dot(second)
|
|
88
|
+
const secondLength = second.dot(second)
|
|
89
|
+
const firstOffset = first.dot(between)
|
|
90
|
+
const secondOffset = second.dot(between)
|
|
91
|
+
const determinant = firstLength * secondLength - crossLength * crossLength
|
|
92
|
+
let firstNumerator: number
|
|
93
|
+
let firstDenominator = determinant
|
|
94
|
+
let secondNumerator: number
|
|
95
|
+
let secondDenominator: number
|
|
96
|
+
|
|
97
|
+
if (determinant <= EPSILON) {
|
|
98
|
+
firstNumerator = 0
|
|
99
|
+
firstDenominator = 1
|
|
100
|
+
secondNumerator = secondOffset
|
|
101
|
+
secondDenominator = secondLength
|
|
102
|
+
} else {
|
|
103
|
+
secondDenominator = determinant
|
|
104
|
+
firstNumerator = crossLength * secondOffset - secondLength * firstOffset
|
|
105
|
+
secondNumerator = firstLength * secondOffset - crossLength * firstOffset
|
|
106
|
+
if (firstNumerator < 0) {
|
|
107
|
+
firstNumerator = 0
|
|
108
|
+
secondNumerator = secondOffset
|
|
109
|
+
secondDenominator = secondLength
|
|
110
|
+
} else if (firstNumerator > firstDenominator) {
|
|
111
|
+
firstNumerator = firstDenominator
|
|
112
|
+
secondNumerator = secondOffset + crossLength
|
|
113
|
+
secondDenominator = secondLength
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (secondNumerator < 0) {
|
|
118
|
+
secondNumerator = 0
|
|
119
|
+
if (-firstOffset < 0) firstNumerator = 0
|
|
120
|
+
else if (-firstOffset > firstLength) firstNumerator = firstDenominator
|
|
121
|
+
else {
|
|
122
|
+
firstNumerator = -firstOffset
|
|
123
|
+
firstDenominator = firstLength
|
|
124
|
+
}
|
|
125
|
+
} else if (secondNumerator > secondDenominator) {
|
|
126
|
+
secondNumerator = secondDenominator
|
|
127
|
+
const terminalOffset = -firstOffset + crossLength
|
|
128
|
+
if (terminalOffset < 0) firstNumerator = 0
|
|
129
|
+
else if (terminalOffset > firstLength) firstNumerator = firstDenominator
|
|
130
|
+
else {
|
|
131
|
+
firstNumerator = terminalOffset
|
|
132
|
+
firstDenominator = firstLength
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const firstProgress = Math.abs(firstNumerator) <= EPSILON ? 0 : firstNumerator / firstDenominator
|
|
137
|
+
const secondProgress = Math.abs(secondNumerator) <= EPSILON ? 0 : secondNumerator / secondDenominator
|
|
138
|
+
return between.addScaledVector(first, firstProgress).addScaledVector(second, -secondProgress).lengthSq() <= EPSILON * EPSILON
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertSimplePath(points: readonly THREE.Vector3[], closed: boolean): void {
|
|
142
|
+
const segmentCount = points.length - 1
|
|
143
|
+
for (let left = 0; left < segmentCount; left += 1) {
|
|
144
|
+
for (let right = left + 1; right < segmentCount; right += 1) {
|
|
145
|
+
if (right === left + 1) continue
|
|
146
|
+
if (closed && left === 0 && right === segmentCount - 1) continue
|
|
147
|
+
if (segmentsIntersect(points[left]!, points[left + 1]!, points[right]!, points[right + 1]!)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Wall boundary self-intersects between segments ${left} and ${right}: ${JSON.stringify([
|
|
150
|
+
points[left]!.toArray(),
|
|
151
|
+
points[left + 1]!.toArray(),
|
|
152
|
+
points[right]!.toArray(),
|
|
153
|
+
points[right + 1]!.toArray(),
|
|
154
|
+
])}`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Converts one source boundary into the only frame contract themed walls may
|
|
163
|
+
* consume. Path positions own the tangent; authored axes are orientation hints
|
|
164
|
+
* only. Invalid topology fails here instead of exploding inside a theme.
|
|
165
|
+
*/
|
|
166
|
+
export function createCanonicalWallRun(
|
|
167
|
+
source: readonly RawWallFrame[],
|
|
168
|
+
options: {
|
|
169
|
+
role: TrackWallBoundaryRole
|
|
170
|
+
capStart?: boolean
|
|
171
|
+
capEnd?: boolean
|
|
172
|
+
/** Width-changing lanes must derive orientation from their moving boundary. */
|
|
173
|
+
tangentSource?: 'authored' | 'boundary'
|
|
174
|
+
},
|
|
175
|
+
): CanonicalWallRun | undefined {
|
|
176
|
+
const points: RawWallFrame[] = []
|
|
177
|
+
for (const frame of source) {
|
|
178
|
+
if (![frame.base, frame.tangent, frame.up, frame.outward].every(finiteVector)) {
|
|
179
|
+
throw new Error('Wall boundary contains a non-finite frame')
|
|
180
|
+
}
|
|
181
|
+
const previous = points.at(-1)
|
|
182
|
+
if (previous && previous.base.distanceToSquared(frame.base) <= EPSILON * EPSILON) continue
|
|
183
|
+
points.push({
|
|
184
|
+
base: frame.base.clone(),
|
|
185
|
+
tangent: frame.tangent.clone(),
|
|
186
|
+
up: frame.up.clone(),
|
|
187
|
+
outward: frame.outward.clone(),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
if (points.length < 2) return undefined
|
|
191
|
+
|
|
192
|
+
const closed = points[0]!.base.distanceToSquared(points.at(-1)!.base) <= 0.001 * 0.001
|
|
193
|
+
if (closed && points.length < 4) return undefined
|
|
194
|
+
assertSimplePath(points.map(frame => frame.base), closed)
|
|
195
|
+
const uniqueCount = closed ? points.length - 1 : points.length
|
|
196
|
+
const frames: CanonicalWallFrame[] = []
|
|
197
|
+
let distance = 0
|
|
198
|
+
|
|
199
|
+
for (let index = 0; index < uniqueCount; index += 1) {
|
|
200
|
+
const sourceFrame = points[index]!
|
|
201
|
+
const previous = points[closed ? (index - 1 + uniqueCount) % uniqueCount : Math.max(0, index - 1)]!
|
|
202
|
+
const next = points[closed ? (index + 1) % uniqueCount : Math.min(uniqueCount - 1, index + 1)]!
|
|
203
|
+
// Walls follow the boundary's elevation, but their construction frame is
|
|
204
|
+
// always vertical. Including grade in the frame tangent forces `up` to
|
|
205
|
+
// lean backwards on slopes and makes every themed wall/roof inherit that
|
|
206
|
+
// piece-specific skew.
|
|
207
|
+
const worldUp = new THREE.Vector3(0, 1, 0)
|
|
208
|
+
const pathTangent = next.base.clone().sub(previous.base)
|
|
209
|
+
pathTangent.y = 0
|
|
210
|
+
if (pathTangent.lengthSq() <= EPSILON) {
|
|
211
|
+
pathTangent.copy(sourceFrame.tangent)
|
|
212
|
+
pathTangent.y = 0
|
|
213
|
+
}
|
|
214
|
+
if (pathTangent.lengthSq() <= EPSILON) return undefined
|
|
215
|
+
pathTangent.normalize()
|
|
216
|
+
if (pathTangent.dot(sourceFrame.tangent) < 0) pathTangent.negate()
|
|
217
|
+
const authoredTangent = sourceFrame.tangent.clone()
|
|
218
|
+
authoredTangent.y = 0
|
|
219
|
+
if (authoredTangent.lengthSq() > EPSILON) authoredTangent.normalize()
|
|
220
|
+
// Authored derivatives preserve smooth curve/grade continuity. The path
|
|
221
|
+
// chord takes ownership only when they materially disagree (normally a
|
|
222
|
+
// width transition or malformed producer), using one global threshold.
|
|
223
|
+
const openTerminal = !closed && (index === 0 || index === uniqueCount - 1)
|
|
224
|
+
const tangent =
|
|
225
|
+
openTerminal && authoredTangent.lengthSq() > EPSILON
|
|
226
|
+
? authoredTangent
|
|
227
|
+
: options.tangentSource === 'boundary'
|
|
228
|
+
? pathTangent
|
|
229
|
+
: uniqueCount > 2 &&
|
|
230
|
+
authoredTangent.lengthSq() > EPSILON &&
|
|
231
|
+
pathTangent.dot(authoredTangent) >= Math.cos(THREE.MathUtils.degToRad(20))
|
|
232
|
+
? authoredTangent
|
|
233
|
+
: pathTangent
|
|
234
|
+
|
|
235
|
+
const up = worldUp.clone()
|
|
236
|
+
const outward = new THREE.Vector3().crossVectors(tangent, up)
|
|
237
|
+
if (outward.lengthSq() <= EPSILON) return undefined
|
|
238
|
+
outward.normalize()
|
|
239
|
+
// A mirrored lane reverses the path-local tangent, not world up. Flipping
|
|
240
|
+
// up to satisfy the outward hint turns roofs upside-down; flipping tangent
|
|
241
|
+
// preserves the physical boundary order while keeping the frame proper.
|
|
242
|
+
if (outward.dot(sourceFrame.outward) < 0) {
|
|
243
|
+
tangent.negate()
|
|
244
|
+
outward.crossVectors(tangent, up).normalize()
|
|
245
|
+
}
|
|
246
|
+
if (frames.length > 0) distance += sourceFrame.base.distanceTo(frames.at(-1)!.base)
|
|
247
|
+
frames.push({ base: sourceFrame.base.clone(), tangent, up, outward, distance })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (closed) {
|
|
251
|
+
distance += frames.at(-1)!.base.distanceTo(frames[0]!.base)
|
|
252
|
+
frames.push({
|
|
253
|
+
base: frames[0]!.base.clone(),
|
|
254
|
+
tangent: frames[0]!.tangent.clone(),
|
|
255
|
+
up: frames[0]!.up.clone(),
|
|
256
|
+
outward: frames[0]!.outward.clone(),
|
|
257
|
+
distance,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
const capStart = !closed && options.capStart !== false
|
|
261
|
+
const capEnd = !closed && options.capEnd !== false
|
|
262
|
+
return {
|
|
263
|
+
frames,
|
|
264
|
+
role: options.role,
|
|
265
|
+
capStart,
|
|
266
|
+
capEnd,
|
|
267
|
+
startOwnership: closed ? 'closed-loop' : capStart ? 'exposed' : 'connected',
|
|
268
|
+
endOwnership: closed ? 'closed-loop' : capEnd ? 'exposed' : 'connected',
|
|
269
|
+
closed,
|
|
270
|
+
length: distance,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function canonicalWallFrameAt(run: CanonicalWallRun, targetDistance: number): CanonicalWallFrame {
|
|
275
|
+
const target = THREE.MathUtils.clamp(targetDistance, 0, run.length)
|
|
276
|
+
let low = 1
|
|
277
|
+
let high = run.frames.length - 1
|
|
278
|
+
while (low < high) {
|
|
279
|
+
const middle = (low + high) >>> 1
|
|
280
|
+
if (run.frames[middle]!.distance < target) low = middle + 1
|
|
281
|
+
else high = middle
|
|
282
|
+
}
|
|
283
|
+
const segment = Math.max(0, low - 1)
|
|
284
|
+
const start = run.frames[segment]!
|
|
285
|
+
const end = run.frames[segment + 1]!
|
|
286
|
+
const span = end.distance - start.distance
|
|
287
|
+
const progress = span <= EPSILON ? 0 : (target - start.distance) / span
|
|
288
|
+
const tangent = start.tangent.clone().lerp(end.tangent, progress).normalize()
|
|
289
|
+
const up = start.up.clone().lerp(end.up, progress)
|
|
290
|
+
up.addScaledVector(tangent, -up.dot(tangent)).normalize()
|
|
291
|
+
const outward = new THREE.Vector3().crossVectors(tangent, up).normalize()
|
|
292
|
+
if (outward.dot(start.outward.clone().lerp(end.outward, progress)) < 0) outward.negate()
|
|
293
|
+
up.crossVectors(outward, tangent).normalize()
|
|
294
|
+
return {
|
|
295
|
+
base: start.base.clone().lerp(end.base, progress),
|
|
296
|
+
tangent,
|
|
297
|
+
up,
|
|
298
|
+
outward,
|
|
299
|
+
distance: target,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function resampleCanonicalWallRun(run: CanonicalWallRun, spacing: number): CanonicalWallRun {
|
|
304
|
+
const safeSpacing = Math.max(0.01, spacing)
|
|
305
|
+
const count = Math.max(2, Math.ceil(run.length / safeSpacing) + 1)
|
|
306
|
+
const cornerCosine = Math.cos(THREE.MathUtils.degToRad(12))
|
|
307
|
+
// Source curves may contain dozens of tessellation frames. Retaining every
|
|
308
|
+
// one defeats metric resampling and compresses decorative seams on the
|
|
309
|
+
// shorter side of a bend. Preserve only genuine authored corners.
|
|
310
|
+
const cornerDistances = run.frames.flatMap((frame, index) => {
|
|
311
|
+
if (index === 0 || index >= run.frames.length - 1) return []
|
|
312
|
+
const incoming = frame.base.clone().sub(run.frames[index - 1]!.base)
|
|
313
|
+
const outgoing = run.frames[index + 1]!.base.clone().sub(frame.base)
|
|
314
|
+
if (incoming.lengthSq() <= EPSILON || outgoing.lengthSq() <= EPSILON) return []
|
|
315
|
+
return incoming.normalize().dot(outgoing.normalize()) < cornerCosine ? [frame.distance] : []
|
|
316
|
+
})
|
|
317
|
+
const targets = [
|
|
318
|
+
0,
|
|
319
|
+
...cornerDistances,
|
|
320
|
+
run.length,
|
|
321
|
+
...Array.from({ length: count }, (_, index) => (index / (count - 1)) * run.length),
|
|
322
|
+
]
|
|
323
|
+
.sort((left, right) => left - right)
|
|
324
|
+
.filter((distance, index, values) => index === 0 || distance - values[index - 1]! > EPSILON)
|
|
325
|
+
return { ...run, frames: targets.map(distance => canonicalWallFrameAt(run, distance)) }
|
|
326
|
+
}
|