@kite3d/engine 0.15.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/dist/authoring.d.ts +54 -0
- package/dist/authoring.js +165 -0
- package/dist/authoring.js.map +1 -0
- package/dist/authoringValidation.d.ts +110 -0
- package/dist/authoringValidation.js +488 -0
- package/dist/authoringValidation.js.map +1 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/fileTypes.d.ts +5 -0
- package/dist/fileTypes.js +51 -0
- package/dist/fileTypes.js.map +1 -0
- package/dist/importMap.d.ts +15 -0
- package/dist/importMap.js +62 -0
- package/dist/importMap.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +2756 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.js +5 -0
- package/dist/migrations.js.map +1 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +13 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugins/GeneratorComponent.d.ts +40 -0
- package/dist/plugins/HtmlUiComponent.d.ts +52 -0
- package/dist/plugins/HtmlUiComponent.example.d.ts +0 -0
- package/dist/plugins/cannon/Cannon3DBodyComponent.d.ts +27 -0
- package/dist/plugins/cannon/Cannon3DShapeComponent.d.ts +57 -0
- package/dist/plugins/cannon/CannonPhysicsPlugin.d.ts +63 -0
- package/dist/plugins/cannon/CannonRagdollComponent.d.ts +148 -0
- package/dist/plugins/cannon/helper.d.ts +24 -0
- package/dist/plugins/cannon/threeToCannon.d.ts +63 -0
- package/dist/plugins/cannon/utils.d.ts +9 -0
- package/dist/projectFormat.js +108 -0
- package/dist/projectFormat.js.map +1 -0
- package/dist/runtime/createGame.d.ts +29 -0
- package/dist/runtime/index.d.ts +18 -0
- package/dist/runtime/migrations.d.ts +5 -0
- package/dist/runtime/nestedAssets.d.ts +26 -0
- package/dist/runtime/projectFormat.d.ts +83 -0
- package/dist/runtime/version.d.ts +1 -0
- package/dist/runtime.js +72835 -0
- package/dist/runtime.js.map +1 -0
- package/dist/sceneSerialization.d.ts +17 -0
- package/dist/sceneSerialization.js +174 -0
- package/dist/sceneSerialization.js.map +1 -0
- package/dist/scripts.d.ts +17 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +86 -0
- package/src/authoring.ts +293 -0
- package/src/authoringValidation.ts +815 -0
- package/src/defaults.ts +277 -0
- package/src/fileTypes.ts +53 -0
- package/src/importMap.ts +105 -0
- package/src/index.ts +15 -0
- package/src/paths.ts +9 -0
- package/src/plugins/GeneratorComponent.ts +275 -0
- package/src/plugins/HtmlUiComponent.example.ts +202 -0
- package/src/plugins/HtmlUiComponent.md +219 -0
- package/src/plugins/HtmlUiComponent.ts +320 -0
- package/src/plugins/cannon/Cannon3DBodyComponent.ts +227 -0
- package/src/plugins/cannon/Cannon3DShapeComponent.ts +258 -0
- package/src/plugins/cannon/CannonPhysicsPlugin.ts +409 -0
- package/src/plugins/cannon/CannonRagdollComponent.ts +1170 -0
- package/src/plugins/cannon/helper.ts +255 -0
- package/src/plugins/cannon/threeToCannon.ts +441 -0
- package/src/plugins/cannon/utils.ts +185 -0
- package/src/runtime/createGame.ts +337 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/migrations.ts +6 -0
- package/src/runtime/nestedAssets.ts +226 -0
- package/src/runtime/projectFormat.ts +233 -0
- package/src/runtime/version.ts +3 -0
- package/src/sceneSerialization.ts +289 -0
- package/src/scripts.ts +52 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Box3,
|
|
3
|
+
EntityComponentPlugin,
|
|
4
|
+
Frustum,
|
|
5
|
+
Matrix4,
|
|
6
|
+
Vector3,
|
|
7
|
+
type IObject3D,
|
|
8
|
+
type ThreeViewer,
|
|
9
|
+
} from 'threepipe'
|
|
10
|
+
import {
|
|
11
|
+
KITE3D_AUTHORING_METADATA_KEY,
|
|
12
|
+
getAuthoringMetadata,
|
|
13
|
+
getRuntimeObjectMetadata,
|
|
14
|
+
getTrackedRuntimeObjects,
|
|
15
|
+
type AuthoringMetadata,
|
|
16
|
+
type RuntimeMutableProperty,
|
|
17
|
+
} from './authoring.ts'
|
|
18
|
+
|
|
19
|
+
export type AuthoringFailureCode =
|
|
20
|
+
| 'NO_VISIBLE_AUTHORED_CONTENT'
|
|
21
|
+
| 'GENERATOR_PREVIEW_MISSING'
|
|
22
|
+
| 'RUNTIME_OBJECT_AFTER_STOP'
|
|
23
|
+
| 'MISSING_AUTHORING_SOURCE'
|
|
24
|
+
| 'RUNTIME_SOURCE_DRIFT'
|
|
25
|
+
| 'CAMERA_NOT_USEFUL'
|
|
26
|
+
| 'CAMERA_CONTAINMENT_UNVERIFIED'
|
|
27
|
+
| 'PERSISTENCE_DRIFT'
|
|
28
|
+
|
|
29
|
+
export interface AuthoringValidationIssue {
|
|
30
|
+
code: AuthoringFailureCode
|
|
31
|
+
severity: 'error' | 'warning'
|
|
32
|
+
message: string
|
|
33
|
+
object?: {uuid: string, name: string}
|
|
34
|
+
path?: string
|
|
35
|
+
before?: unknown
|
|
36
|
+
after?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AuthoringQualityReport {
|
|
40
|
+
ok: boolean
|
|
41
|
+
status: 'pass' | 'fail'
|
|
42
|
+
summary: string
|
|
43
|
+
issues: AuthoringValidationIssue[]
|
|
44
|
+
checks: {
|
|
45
|
+
visibleAuthoredContent: boolean
|
|
46
|
+
selectableAuthoredContent: boolean
|
|
47
|
+
relationshipsValid: boolean
|
|
48
|
+
generatorPreviews: boolean
|
|
49
|
+
cameraUseful: boolean
|
|
50
|
+
}
|
|
51
|
+
metrics: {
|
|
52
|
+
authoredObjectCount: number
|
|
53
|
+
renderableCount: number
|
|
54
|
+
visibleRenderableCount: number
|
|
55
|
+
selectableCount: number
|
|
56
|
+
generatorCount: number
|
|
57
|
+
generatorPreviewCount: number
|
|
58
|
+
cameraFramedRenderableCount: number
|
|
59
|
+
cameraInsideRenderableCount: number
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RuntimeCleanupReport {
|
|
64
|
+
ok: boolean
|
|
65
|
+
status: 'pass' | 'fail'
|
|
66
|
+
summary: string
|
|
67
|
+
issues: AuthoringValidationIssue[]
|
|
68
|
+
trackedObjectCount: number
|
|
69
|
+
outsideRenderableCount: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SemanticSceneSnapshot {
|
|
73
|
+
schemaVersion: 1
|
|
74
|
+
scene: unknown[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PersistenceChange {
|
|
78
|
+
path: string
|
|
79
|
+
before: unknown
|
|
80
|
+
after: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PersistenceReport {
|
|
84
|
+
ok: boolean
|
|
85
|
+
status: 'pass' | 'fail'
|
|
86
|
+
summary: string
|
|
87
|
+
issues: AuthoringValidationIssue[]
|
|
88
|
+
changes: PersistenceChange[]
|
|
89
|
+
beforeBytes: number
|
|
90
|
+
afterBytes: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ViewerLike = Pick<ThreeViewer, 'scene'>
|
|
94
|
+
type SceneLike = IObject3D & {modelRoot: IObject3D, mainCamera?: IObject3D, defaultCamera?: IObject3D}
|
|
95
|
+
const CAMERA_BOUNDS_EPSILON = 1e-6
|
|
96
|
+
|
|
97
|
+
interface SourceRecord {
|
|
98
|
+
object: IObject3D
|
|
99
|
+
metadata: AuthoringMetadata
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate the stopped authoring hierarchy, its sources, previews, and saved camera. */
|
|
103
|
+
export function authoringQualityReport(viewer: ViewerLike): AuthoringQualityReport {
|
|
104
|
+
const scene = viewer.scene as SceneLike
|
|
105
|
+
if (!scene?.modelRoot) throw new Error('Authoring validation requires viewer.scene.modelRoot')
|
|
106
|
+
scene.updateMatrixWorld(true)
|
|
107
|
+
|
|
108
|
+
const issues: AuthoringValidationIssue[] = []
|
|
109
|
+
const sources = collectSources(scene.modelRoot)
|
|
110
|
+
const generators: SourceRecord[] = []
|
|
111
|
+
const previews: SourceRecord[] = []
|
|
112
|
+
const boundedPreviewSourceIds = new Set<string>()
|
|
113
|
+
const visibleBounds: Array<{object: IObject3D, bounds: Box3}> = []
|
|
114
|
+
let authoredObjectCount = 0
|
|
115
|
+
let renderableCount = 0
|
|
116
|
+
let visibleRenderableCount = 0
|
|
117
|
+
let selectableCount = 0
|
|
118
|
+
|
|
119
|
+
scene.modelRoot.traverse((object) => {
|
|
120
|
+
if (object === scene.modelRoot) return
|
|
121
|
+
authoredObjectCount += 1
|
|
122
|
+
const metadata = getAuthoringMetadata(object)
|
|
123
|
+
if (metadata?.role === 'generator' && !metadata.sourceId) generators.push({object, metadata})
|
|
124
|
+
if (metadata?.role === 'generator' && metadata.sourceId) previews.push({object, metadata})
|
|
125
|
+
if (!isRenderable(object)) return
|
|
126
|
+
renderableCount += 1
|
|
127
|
+
if (!isEffectivelyVisible(object, scene.modelRoot)) return
|
|
128
|
+
visibleRenderableCount += 1
|
|
129
|
+
if (isSelectable(object)) selectableCount += 1
|
|
130
|
+
const bounds = new Box3().setFromObject(object)
|
|
131
|
+
if (!bounds.isEmpty() && finiteVector(bounds.min) && finiteVector(bounds.max)) {
|
|
132
|
+
visibleBounds.push({object, bounds})
|
|
133
|
+
if (metadata?.role === 'generator' && metadata.sourceId) boundedPreviewSourceIds.add(metadata.sourceId)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
for (const preview of previews) {
|
|
138
|
+
const source = sources.get(preview.metadata.sourceId || '')
|
|
139
|
+
if (!source || source.metadata.role !== 'generator') {
|
|
140
|
+
pushIssue(issues, {
|
|
141
|
+
code: 'MISSING_AUTHORING_SOURCE',
|
|
142
|
+
severity: 'error',
|
|
143
|
+
message: `Generator output source "${preview.metadata.sourceId || '(missing)'}" is not present beneath modelRoot.`,
|
|
144
|
+
object: objectEvidence(preview.object),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const generator of generators) {
|
|
149
|
+
if (!boundedPreviewSourceIds.has(generator.metadata.id)) {
|
|
150
|
+
pushIssue(issues, {
|
|
151
|
+
code: 'GENERATOR_PREVIEW_MISSING',
|
|
152
|
+
severity: 'error',
|
|
153
|
+
message: 'An authored generator needs a bounded stopped-mode preview.',
|
|
154
|
+
object: objectEvidence(generator.object),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
issues.push(...runtimeRelationshipIssues(scene, sources))
|
|
159
|
+
|
|
160
|
+
const emptyScene = authoredObjectCount === 0 && generators.length === 0
|
|
161
|
+
if (visibleRenderableCount === 0 || selectableCount === 0) {
|
|
162
|
+
issues.push({
|
|
163
|
+
code: 'NO_VISIBLE_AUTHORED_CONTENT',
|
|
164
|
+
severity: emptyScene ? 'warning' : 'error',
|
|
165
|
+
message: emptyScene
|
|
166
|
+
? 'The scene is empty; add authored content when you are ready.'
|
|
167
|
+
: visibleRenderableCount === 0
|
|
168
|
+
? 'No visible renderable authored content exists beneath modelRoot.'
|
|
169
|
+
: 'Visible authored renderables are not selectable.',
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const camera = scene.defaultCamera || scene.mainCamera
|
|
174
|
+
const cameraSource = scene.defaultCamera ? 'scene.defaultCamera' : 'scene.mainCamera'
|
|
175
|
+
const cameraResult = emptyScene
|
|
176
|
+
? {useful: false, framed: 0, inside: 0}
|
|
177
|
+
: validateCamera(camera, cameraSource, visibleBounds, issues)
|
|
178
|
+
const errors = issues.filter(({severity}) => severity === 'error')
|
|
179
|
+
const relationshipsValid = !errors.some(({code}) =>
|
|
180
|
+
code === 'MISSING_AUTHORING_SOURCE' || code === 'RUNTIME_SOURCE_DRIFT')
|
|
181
|
+
const generatorPreviews = !errors.some(({code}) => code === 'GENERATOR_PREVIEW_MISSING')
|
|
182
|
+
const ok = errors.length === 0
|
|
183
|
+
return {
|
|
184
|
+
ok,
|
|
185
|
+
status: ok ? 'pass' : 'fail',
|
|
186
|
+
summary: ok ? 'Stopped-mode authored representation passed.' : `${errors.length} authoring check(s) failed.`,
|
|
187
|
+
issues,
|
|
188
|
+
checks: {
|
|
189
|
+
visibleAuthoredContent: visibleRenderableCount > 0,
|
|
190
|
+
selectableAuthoredContent: selectableCount > 0,
|
|
191
|
+
relationshipsValid,
|
|
192
|
+
generatorPreviews,
|
|
193
|
+
cameraUseful: cameraResult.useful,
|
|
194
|
+
},
|
|
195
|
+
metrics: {
|
|
196
|
+
authoredObjectCount,
|
|
197
|
+
renderableCount,
|
|
198
|
+
visibleRenderableCount,
|
|
199
|
+
selectableCount,
|
|
200
|
+
generatorCount: generators.length,
|
|
201
|
+
generatorPreviewCount: boundedPreviewSourceIds.size,
|
|
202
|
+
cameraFramedRenderableCount: cameraResult.framed,
|
|
203
|
+
cameraInsideRenderableCount: cameraResult.inside,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Validate that Play-only content was removed and its authored sources stayed valid. */
|
|
209
|
+
export function runtimeCleanupReport(viewer: ViewerLike): RuntimeCleanupReport {
|
|
210
|
+
const scene = viewer.scene as SceneLike
|
|
211
|
+
if (!scene?.modelRoot) throw new Error('Runtime cleanup validation requires viewer.scene.modelRoot')
|
|
212
|
+
scene.updateMatrixWorld(true)
|
|
213
|
+
const issues = runtimeRelationshipIssues(scene, collectSources(scene.modelRoot))
|
|
214
|
+
const tracked = getTrackedRuntimeObjects(scene)
|
|
215
|
+
const leaked = new Set<IObject3D>()
|
|
216
|
+
let outsideRenderableCount = 0
|
|
217
|
+
|
|
218
|
+
for (const object of tracked) {
|
|
219
|
+
if (!hasRuntimeAncestor(object, leaked)) leaked.add(object)
|
|
220
|
+
}
|
|
221
|
+
scene.traverse((object) => {
|
|
222
|
+
if (object === scene || object === scene.modelRoot || isInside(object, scene.modelRoot) || isEditorObject(object, scene)) return
|
|
223
|
+
if (isRenderable(object)) outsideRenderableCount += 1
|
|
224
|
+
const runtimeRoot = nearestRuntimeRoot(object)
|
|
225
|
+
if (runtimeRoot) {
|
|
226
|
+
if (!hasRuntimeAncestor(runtimeRoot, leaked)) leaked.add(runtimeRoot)
|
|
227
|
+
} else if (isRenderable(object)) {
|
|
228
|
+
leaked.add(object)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
for (const object of leaked) {
|
|
232
|
+
pushIssue(issues, {
|
|
233
|
+
code: 'RUNTIME_OBJECT_AFTER_STOP',
|
|
234
|
+
severity: 'error',
|
|
235
|
+
message: getRuntimeObjectMetadata(object)
|
|
236
|
+
? 'A tracked runtime object remains after Stop.'
|
|
237
|
+
: 'An unmarked renderable remains outside modelRoot after Stop.',
|
|
238
|
+
object: objectEvidence(object),
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
const ok = !issues.some(({severity}) => severity === 'error')
|
|
242
|
+
return {
|
|
243
|
+
ok,
|
|
244
|
+
status: ok ? 'pass' : 'fail',
|
|
245
|
+
summary: ok ? 'Runtime cleanup passed.' : `${issues.filter(({severity}) => severity === 'error').length} runtime cleanup check(s) failed.`,
|
|
246
|
+
issues,
|
|
247
|
+
trackedObjectCount: tracked.length,
|
|
248
|
+
outsideRenderableCount,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Capture the supported saved semantics without transient engine identities. */
|
|
253
|
+
export function semanticSceneSnapshot(source: ViewerLike | SceneLike): SemanticSceneSnapshot {
|
|
254
|
+
const scene = 'scene' in source ? source.scene as SceneLike : source
|
|
255
|
+
if (!scene?.modelRoot) throw new Error('A semantic snapshot requires scene.modelRoot')
|
|
256
|
+
return {
|
|
257
|
+
schemaVersion: 1,
|
|
258
|
+
scene: scene.modelRoot.children
|
|
259
|
+
.filter((object) => object.userData?.excludeFromExport !== true)
|
|
260
|
+
.map((object) => serializeObject(object)),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Compare names, transforms, component state, and authored source relationships. */
|
|
265
|
+
export function persistenceReport(before: unknown, after: unknown): PersistenceReport {
|
|
266
|
+
const beforeValue = canonicalValue(toSemanticSnapshot(before))
|
|
267
|
+
const afterValue = canonicalValue(toSemanticSnapshot(after))
|
|
268
|
+
const beforeText = JSON.stringify(beforeValue)
|
|
269
|
+
const afterText = JSON.stringify(afterValue)
|
|
270
|
+
const changes: PersistenceChange[] = []
|
|
271
|
+
compareValues(beforeValue, afterValue, '', changes)
|
|
272
|
+
const issues: AuthoringValidationIssue[] = changes.map((change) => ({
|
|
273
|
+
code: 'PERSISTENCE_DRIFT',
|
|
274
|
+
severity: 'error',
|
|
275
|
+
message: `Saved authoring semantics changed at ${change.path || '/'}.`,
|
|
276
|
+
...change,
|
|
277
|
+
}))
|
|
278
|
+
const ok = changes.length === 0
|
|
279
|
+
return {
|
|
280
|
+
ok,
|
|
281
|
+
status: ok ? 'pass' : 'fail',
|
|
282
|
+
summary: ok ? 'Save/Reload semantic equivalence passed.' : `${changes.length} persisted semantic difference(s) found.`,
|
|
283
|
+
issues,
|
|
284
|
+
changes,
|
|
285
|
+
beforeBytes: beforeText.length,
|
|
286
|
+
afterBytes: afterText.length,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface GameValidationResult {
|
|
291
|
+
status: 'pass' | 'fail'
|
|
292
|
+
summary: string
|
|
293
|
+
checks?: Record<string, boolean | number | string>
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface GameValidationReport {
|
|
297
|
+
ok: boolean
|
|
298
|
+
status: 'pass' | 'fail'
|
|
299
|
+
summary: string
|
|
300
|
+
results: GameValidationResult[]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export type GameValidationFunction = () => GameValidationResult | boolean | void | Promise<GameValidationResult | boolean | void>
|
|
304
|
+
|
|
305
|
+
interface GameHookHost {
|
|
306
|
+
registerGameValidation(fn: GameValidationFunction): () => void
|
|
307
|
+
publishGameTelemetry(value: object): () => void
|
|
308
|
+
runGameValidation(): Promise<GameValidationReport>
|
|
309
|
+
dispose(): void
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let activeGameHooks: GameHookHost | undefined
|
|
313
|
+
|
|
314
|
+
/** Register a project-defined gameplay assertion for the currently starting game. */
|
|
315
|
+
export function registerGameValidation(fn: GameValidationFunction): () => void {
|
|
316
|
+
if (!activeGameHooks) throw new Error('registerGameValidation must be called while createGame is active')
|
|
317
|
+
return activeGameHooks.registerGameValidation(fn)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Publish an immutable telemetry snapshot on window.kite3dGame.telemetry. */
|
|
321
|
+
export function publishGameTelemetry(value: object): () => void {
|
|
322
|
+
if (!activeGameHooks) throw new Error('publishGameTelemetry must be called while createGame is active')
|
|
323
|
+
return activeGameHooks.publishGameTelemetry(value)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Install the per-createGame hook host before project modules are evaluated. */
|
|
327
|
+
export function installGameHooks(): GameHookHost {
|
|
328
|
+
const validations = new Set<GameValidationFunction>()
|
|
329
|
+
let telemetry: object | undefined
|
|
330
|
+
let disposed = false
|
|
331
|
+
const api = Object.freeze({
|
|
332
|
+
get telemetry() { return telemetry },
|
|
333
|
+
validate: () => host.runGameValidation(),
|
|
334
|
+
})
|
|
335
|
+
const host: GameHookHost = {
|
|
336
|
+
registerGameValidation(fn) {
|
|
337
|
+
if (disposed || typeof fn !== 'function') throw new Error('Game validation must be a function on an active game')
|
|
338
|
+
validations.add(fn)
|
|
339
|
+
return () => validations.delete(fn)
|
|
340
|
+
},
|
|
341
|
+
publishGameTelemetry(value) {
|
|
342
|
+
if (disposed || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
343
|
+
throw new Error('Game telemetry must be an object on an active game')
|
|
344
|
+
}
|
|
345
|
+
const published = immutableCopy(value)
|
|
346
|
+
telemetry = published
|
|
347
|
+
return () => {
|
|
348
|
+
if (telemetry === published) telemetry = undefined
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
async runGameValidation() {
|
|
352
|
+
const results: GameValidationResult[] = []
|
|
353
|
+
for (const validate of validations) {
|
|
354
|
+
try {
|
|
355
|
+
const result = await validate()
|
|
356
|
+
if (result === undefined || result === true) {
|
|
357
|
+
results.push({status: 'pass', summary: 'Project validation passed.'})
|
|
358
|
+
} else if (result === false) {
|
|
359
|
+
results.push({status: 'fail', summary: 'Project validation failed.'})
|
|
360
|
+
} else if (result && ['pass', 'fail'].includes(result.status) && typeof result.summary === 'string') {
|
|
361
|
+
results.push(result)
|
|
362
|
+
} else {
|
|
363
|
+
results.push({status: 'fail', summary: 'Project validation returned an invalid result.'})
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
results.push({status: 'fail', summary: `Project validation threw: ${errorMessage(error)}`})
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const failed = results.filter(({status}) => status === 'fail')
|
|
370
|
+
return {
|
|
371
|
+
ok: failed.length === 0,
|
|
372
|
+
status: failed.length ? 'fail' : 'pass',
|
|
373
|
+
summary: failed.length ? failed.map(({summary}) => summary).join(' ') : 'Project validations passed.',
|
|
374
|
+
results,
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
dispose() {
|
|
378
|
+
disposed = true
|
|
379
|
+
validations.clear()
|
|
380
|
+
telemetry = undefined
|
|
381
|
+
if (activeGameHooks === host) activeGameHooks = undefined
|
|
382
|
+
if (typeof window !== 'undefined' && window.kite3dGame === api) delete window.kite3dGame
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
activeGameHooks?.dispose()
|
|
386
|
+
activeGameHooks = host
|
|
387
|
+
if (typeof window !== 'undefined') window.kite3dGame = api
|
|
388
|
+
return host
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function collectSources(modelRoot: IObject3D): Map<string, SourceRecord> {
|
|
392
|
+
const sources = new Map<string, SourceRecord>()
|
|
393
|
+
modelRoot.traverse((object) => {
|
|
394
|
+
if (object === modelRoot) return
|
|
395
|
+
const metadata = getAuthoringMetadata(object)
|
|
396
|
+
if (metadata && !metadata.sourceId) sources.set(metadata.id, {object, metadata})
|
|
397
|
+
else if (!metadata) sources.set(object.uuid, {object, metadata: {role: 'direct', id: object.uuid}})
|
|
398
|
+
})
|
|
399
|
+
return sources
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function runtimeRelationshipIssues(scene: SceneLike, sources: Map<string, SourceRecord>): AuthoringValidationIssue[] {
|
|
403
|
+
const issues: AuthoringValidationIssue[] = []
|
|
404
|
+
scene.traverse((object) => {
|
|
405
|
+
const metadata = getAuthoringMetadata(object)
|
|
406
|
+
if (!metadata?.sourceId) return
|
|
407
|
+
const source = sources.get(metadata.sourceId)
|
|
408
|
+
if (!source) {
|
|
409
|
+
pushIssue(issues, {
|
|
410
|
+
code: 'MISSING_AUTHORING_SOURCE',
|
|
411
|
+
severity: 'error',
|
|
412
|
+
message: `Authored source "${metadata.sourceId}" is not present beneath modelRoot.`,
|
|
413
|
+
object: objectEvidence(object),
|
|
414
|
+
})
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const runtime = getRuntimeObjectMetadata(object)
|
|
418
|
+
if (!runtime || runtime.kind === 'effect' || source.metadata.role === 'generator') return
|
|
419
|
+
const overrides = new Set<RuntimeMutableProperty>(runtime.overrides || [])
|
|
420
|
+
if (JSON.stringify(runtimeSignature(source.object, overrides)) !== JSON.stringify(runtimeSignature(object, overrides))) {
|
|
421
|
+
pushIssue(issues, {
|
|
422
|
+
code: 'RUNTIME_SOURCE_DRIFT',
|
|
423
|
+
severity: 'error',
|
|
424
|
+
message: 'A runtime copy differs from its authored source in a non-mutable field.',
|
|
425
|
+
object: objectEvidence(object),
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
return issues
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validateCamera(
|
|
433
|
+
camera: IObject3D | undefined,
|
|
434
|
+
cameraSource: 'scene.defaultCamera' | 'scene.mainCamera',
|
|
435
|
+
renderables: Array<{object: IObject3D, bounds: Box3}>,
|
|
436
|
+
issues: AuthoringValidationIssue[],
|
|
437
|
+
): {useful: boolean, framed: number, inside: number} {
|
|
438
|
+
let framed = 0
|
|
439
|
+
let inside = 0
|
|
440
|
+
let facing = false
|
|
441
|
+
const cameraLabel = `${cameraSource}${camera?.name ? ` "${camera.name}"` : ''}`
|
|
442
|
+
let problem = `${cameraLabel} must be finite and face visible authored content.`
|
|
443
|
+
const projectionMatrix = (camera as IObject3D & {projectionMatrix?: Matrix4, matrixWorldInverse?: Matrix4})?.projectionMatrix
|
|
444
|
+
const matrixWorldInverse = (camera as IObject3D & {projectionMatrix?: Matrix4, matrixWorldInverse?: Matrix4})?.matrixWorldInverse
|
|
445
|
+
if (camera && finiteVector(camera.position) && projectionMatrix && matrixWorldInverse && renderables.length) {
|
|
446
|
+
camera.updateMatrixWorld(true)
|
|
447
|
+
;(camera as IObject3D & {updateProjectionMatrix?: () => void}).updateProjectionMatrix?.()
|
|
448
|
+
const cameraPosition = camera.getWorldPosition(new Vector3())
|
|
449
|
+
const totalBounds = new Box3().makeEmpty()
|
|
450
|
+
for (const {bounds} of renderables) totalBounds.union(bounds)
|
|
451
|
+
const center = totalBounds.getCenter(new Vector3())
|
|
452
|
+
const direction = (camera as IObject3D & {getWorldDirection?: (target: Vector3) => Vector3}).getWorldDirection?.(new Vector3())
|
|
453
|
+
const toward = center.clone().sub(cameraPosition)
|
|
454
|
+
facing = !direction || toward.lengthSq() === 0 || direction.dot(toward.normalize()) > 0
|
|
455
|
+
const frustum = new Frustum().setFromProjectionMatrix(new Matrix4().multiplyMatrices(projectionMatrix, matrixWorldInverse))
|
|
456
|
+
framed = renderables.filter(({bounds}) => frustum.intersectsBox(bounds)).length
|
|
457
|
+
|
|
458
|
+
for (const {object, bounds} of renderables) {
|
|
459
|
+
if (cameraInsideAllowed(object) || !containsInteriorPoint(bounds, cameraPosition)) continue
|
|
460
|
+
const mesh = object as IObject3D & {
|
|
461
|
+
isMesh?: boolean
|
|
462
|
+
isInstancedMesh?: boolean
|
|
463
|
+
isSkinnedMesh?: boolean
|
|
464
|
+
geometry?: {
|
|
465
|
+
type?: string
|
|
466
|
+
boundingBox?: Box3
|
|
467
|
+
morphAttributes?: {position?: unknown[]}
|
|
468
|
+
computeBoundingBox?(): void
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const geometry = mesh.geometry
|
|
472
|
+
const deformed = mesh.isInstancedMesh || mesh.isSkinnedMesh || geometry?.morphAttributes?.position?.length
|
|
473
|
+
if (!geometry || deformed || object.matrixWorld.determinant() === 0) {
|
|
474
|
+
addContainmentWarning(issues, object)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
geometry.computeBoundingBox?.()
|
|
478
|
+
const localPosition = object.worldToLocal(cameraPosition.clone())
|
|
479
|
+
if (!geometry.boundingBox || !containsInteriorPoint(geometry.boundingBox, localPosition)) continue
|
|
480
|
+
if (mesh.isMesh && geometry.type === 'BoxGeometry') inside += 1
|
|
481
|
+
else addContainmentWarning(issues, object)
|
|
482
|
+
}
|
|
483
|
+
if (inside) problem = `${cameraLabel} is inside ${inside} visible authored mesh(es).`
|
|
484
|
+
else if (!framed) problem = `No visible authored renderable intersects ${cameraLabel}'s frustum.`
|
|
485
|
+
else if (!facing) problem = `${cameraLabel} faces away from visible authored content.`
|
|
486
|
+
}
|
|
487
|
+
const useful = Boolean(camera && renderables.length && framed > 0 && inside === 0 && facing)
|
|
488
|
+
if (!useful) issues.push({code: 'CAMERA_NOT_USEFUL', severity: 'error', message: problem})
|
|
489
|
+
return {useful, framed, inside}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function containsInteriorPoint(bounds: Box3, point: Vector3): boolean {
|
|
493
|
+
return point.x > bounds.min.x + CAMERA_BOUNDS_EPSILON
|
|
494
|
+
&& point.x < bounds.max.x - CAMERA_BOUNDS_EPSILON
|
|
495
|
+
&& point.y > bounds.min.y + CAMERA_BOUNDS_EPSILON
|
|
496
|
+
&& point.y < bounds.max.y - CAMERA_BOUNDS_EPSILON
|
|
497
|
+
&& point.z > bounds.min.z + CAMERA_BOUNDS_EPSILON
|
|
498
|
+
&& point.z < bounds.max.z - CAMERA_BOUNDS_EPSILON
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function addContainmentWarning(issues: AuthoringValidationIssue[], object: IObject3D): void {
|
|
502
|
+
pushIssue(issues, {
|
|
503
|
+
code: 'CAMERA_CONTAINMENT_UNVERIFIED',
|
|
504
|
+
severity: 'warning',
|
|
505
|
+
message: 'The camera intersects local geometry bounds, but hollow or imported mesh containment is unverified.',
|
|
506
|
+
object: objectEvidence(object),
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function runtimeSignature(object: IObject3D, overrides: Set<RuntimeMutableProperty>, root = true): unknown {
|
|
511
|
+
const renderable = object as IObject3D & {geometry?: {parameters?: unknown}, material?: unknown | unknown[]}
|
|
512
|
+
const result: Record<string, unknown> = {
|
|
513
|
+
type: semanticType(object),
|
|
514
|
+
geometry: canonicalValue(renderable.geometry?.parameters),
|
|
515
|
+
children: object.children.map((child) => runtimeSignature(child, new Set(), false)),
|
|
516
|
+
}
|
|
517
|
+
if (!root || !overrides.has('visible')) result.visible = object.visible
|
|
518
|
+
if (!root || !overrides.has('position')) result.position = vectorValue(object.position)
|
|
519
|
+
if (!root || !overrides.has('rotation')) result.rotation = vectorValue(object.rotation)
|
|
520
|
+
if (!root || !overrides.has('scale')) result.scale = vectorValue(object.scale)
|
|
521
|
+
if (renderable.material && (!root || !overrides.has('material'))) {
|
|
522
|
+
result.material = Array.isArray(renderable.material)
|
|
523
|
+
? renderable.material.map(materialValue)
|
|
524
|
+
: materialValue(renderable.material)
|
|
525
|
+
}
|
|
526
|
+
return result
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function materialValue(value: unknown): unknown {
|
|
530
|
+
const material = value as {
|
|
531
|
+
type?: string
|
|
532
|
+
color?: {getHex?(): number}
|
|
533
|
+
emissive?: {getHex?(): number}
|
|
534
|
+
opacity?: number
|
|
535
|
+
transparent?: boolean
|
|
536
|
+
metalness?: number
|
|
537
|
+
roughness?: number
|
|
538
|
+
}
|
|
539
|
+
return canonicalValue({
|
|
540
|
+
type: material?.type,
|
|
541
|
+
color: material?.color?.getHex?.(),
|
|
542
|
+
emissive: material?.emissive?.getHex?.(),
|
|
543
|
+
opacity: material?.opacity,
|
|
544
|
+
transparent: material?.transparent,
|
|
545
|
+
metalness: material?.metalness,
|
|
546
|
+
roughness: material?.roughness,
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function serializeObject(object: IObject3D): unknown {
|
|
551
|
+
const metadata = getAuthoringMetadata(object)
|
|
552
|
+
return canonicalValue({
|
|
553
|
+
name: object.name,
|
|
554
|
+
type: semanticType(object),
|
|
555
|
+
visible: object.visible,
|
|
556
|
+
position: vectorValue(object.position),
|
|
557
|
+
rotation: vectorValue(object.rotation),
|
|
558
|
+
scale: vectorValue(object.scale),
|
|
559
|
+
components: componentValues(object),
|
|
560
|
+
...(metadata ? {authoring: metadata} : {}),
|
|
561
|
+
children: object.children
|
|
562
|
+
.filter((child) => child.userData?.excludeFromExport !== true)
|
|
563
|
+
.map((child) => serializeObject(child)),
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function componentValues(object: IObject3D): unknown[] {
|
|
568
|
+
const live = EntityComponentPlugin.ObjectToComponents.get(object)
|
|
569
|
+
if (live?.length) {
|
|
570
|
+
return live.map((component) => {
|
|
571
|
+
const definition = component.constructor as {ComponentType?: string, StateProperties?: Array<string | {name?: string, key?: string}>}
|
|
572
|
+
const keys = (definition.StateProperties || []).map((property) =>
|
|
573
|
+
typeof property === 'string' ? property : property.name || property.key).filter((key): key is string => Boolean(key))
|
|
574
|
+
return canonicalValue({
|
|
575
|
+
id: component.uuid,
|
|
576
|
+
type: definition.ComponentType || component.constructor.name,
|
|
577
|
+
state: Object.fromEntries(keys.map((key) => [key, semanticValue((component as unknown as Record<string, unknown>)[key])])),
|
|
578
|
+
})
|
|
579
|
+
}).sort(compareJson)
|
|
580
|
+
}
|
|
581
|
+
const saved = object.userData?.EntityComponentPlugin
|
|
582
|
+
if (!isRecord(saved)) return []
|
|
583
|
+
return Object.entries(saved).map(([id, value]) => {
|
|
584
|
+
const component = isRecord(value) ? value : {}
|
|
585
|
+
return canonicalValue({id, type: component.type, state: semanticValue(component.state)})
|
|
586
|
+
}).sort(compareJson)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function toSemanticSnapshot(value: unknown): SemanticSceneSnapshot {
|
|
590
|
+
if (typeof value === 'string') return toSemanticSnapshot(JSON.parse(value))
|
|
591
|
+
if (value instanceof Uint8Array) return toSemanticSnapshot(JSON.parse(new TextDecoder().decode(value)))
|
|
592
|
+
if (isRecord(value) && isRecord(value.document)) return gltfSemanticSnapshot(value.document)
|
|
593
|
+
if (isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.scene)) return value as unknown as SemanticSceneSnapshot
|
|
594
|
+
if (isRecord(value) && isRecord(value.asset)) return gltfSemanticSnapshot(value)
|
|
595
|
+
if (isRecord(value) && 'scene' in value) return semanticSceneSnapshot(value as unknown as ViewerLike)
|
|
596
|
+
if (isRecord(value) && 'modelRoot' in value) return semanticSceneSnapshot(value as unknown as SceneLike)
|
|
597
|
+
throw new Error('Persistence comparison requires a semantic snapshot, viewer, scene, or serialized glTF document')
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function gltfSemanticSnapshot(document: Record<string, unknown>): SemanticSceneSnapshot {
|
|
601
|
+
const nodes = Array.isArray(document.nodes) ? document.nodes : []
|
|
602
|
+
const scenes = Array.isArray(document.scenes) ? document.scenes : []
|
|
603
|
+
const sceneIndex = typeof document.scene === 'number' ? document.scene : 0
|
|
604
|
+
const root = isRecord(scenes[sceneIndex]) ? scenes[sceneIndex] : {}
|
|
605
|
+
const rootNodes = Array.isArray(root.nodes) ? root.nodes : []
|
|
606
|
+
const visit = (index: unknown): unknown => {
|
|
607
|
+
const node = typeof index === 'number' && isRecord(nodes[index]) ? nodes[index] : {}
|
|
608
|
+
const extras = isRecord(node.extras) ? node.extras : {}
|
|
609
|
+
const authoring = isRecord(extras[KITE3D_AUTHORING_METADATA_KEY]) ? extras[KITE3D_AUTHORING_METADATA_KEY] : undefined
|
|
610
|
+
const savedComponents = isRecord(extras.EntityComponentPlugin) ? extras.EntityComponentPlugin : {}
|
|
611
|
+
const components = Object.entries(savedComponents).map(([id, value]) => {
|
|
612
|
+
const component = isRecord(value) ? value : {}
|
|
613
|
+
return canonicalValue({id, type: component.type, state: semanticValue(component.state)})
|
|
614
|
+
}).sort(compareJson)
|
|
615
|
+
const translation = numericArray(node.translation, [0, 0, 0])
|
|
616
|
+
const rotation = numericArray(node.rotation, [0, 0, 0, 1])
|
|
617
|
+
const scale = numericArray(node.scale, [1, 1, 1])
|
|
618
|
+
return canonicalValue({
|
|
619
|
+
name: typeof node.name === 'string' ? node.name : '',
|
|
620
|
+
type: typeof node.mesh === 'number' ? 'Mesh' : typeof node.camera === 'number' ? 'Camera' : 'Group',
|
|
621
|
+
visible: extras.visible !== false,
|
|
622
|
+
position: {x: translation[0], y: translation[1], z: translation[2]},
|
|
623
|
+
quaternion: {x: rotation[0], y: rotation[1], z: rotation[2], w: rotation[3]},
|
|
624
|
+
scale: {x: scale[0], y: scale[1], z: scale[2]},
|
|
625
|
+
components,
|
|
626
|
+
...(authoring ? {authoring: semanticValue(authoring)} : {}),
|
|
627
|
+
children: (Array.isArray(node.children) ? node.children : []).map(visit),
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
return {schemaVersion: 1, scene: rootNodes.map(visit)}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function compareValues(left: unknown, right: unknown, path: string, changes: PersistenceChange[]): void {
|
|
634
|
+
if (changes.length >= 50 || Object.is(left, right) || left === right) return
|
|
635
|
+
if (!left || !right || typeof left !== 'object' || typeof right !== 'object'
|
|
636
|
+
|| Array.isArray(left) !== Array.isArray(right)) {
|
|
637
|
+
changes.push({path: path || '/', before: left, after: right})
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
641
|
+
const length = Math.max(left.length, right.length)
|
|
642
|
+
for (let index = 0; index < length; index += 1) {
|
|
643
|
+
const named = objectName(left[index]) || objectName(right[index])
|
|
644
|
+
compareValues(left[index], right[index], `${path}/${escapePointer(named || String(index))}`, changes)
|
|
645
|
+
}
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
const leftRecord = left as Record<string, unknown>
|
|
649
|
+
const rightRecord = right as Record<string, unknown>
|
|
650
|
+
const keys = [...new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)])].sort()
|
|
651
|
+
for (const key of keys) compareValues(leftRecord[key], rightRecord[key], `${path}/${escapePointer(key)}`, changes)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function objectName(value: unknown): string | undefined {
|
|
655
|
+
if (!isRecord(value) || typeof value.name !== 'string') return undefined
|
|
656
|
+
return value.name || undefined
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function semanticValue(value: unknown, seen = new WeakSet<object>(), depth = 0): unknown {
|
|
660
|
+
if (typeof value === 'number') return normalizeNumber(value)
|
|
661
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
662
|
+
if (value === undefined || typeof value === 'function') return undefined
|
|
663
|
+
if (Array.isArray(value)) return value.map((item) => semanticValue(item, seen, depth + 1))
|
|
664
|
+
if (typeof value !== 'object' || depth > 5) return String(value)
|
|
665
|
+
if (seen.has(value)) return '[Circular]'
|
|
666
|
+
seen.add(value)
|
|
667
|
+
const candidate = value as Record<string, unknown> & {isColor?: boolean, getHexString?(): string, toArray?(): unknown[]}
|
|
668
|
+
if (candidate.isColor && candidate.getHexString) return `#${candidate.getHexString()}`
|
|
669
|
+
if (candidate.toArray && /Vector|Euler|Quaternion/.test(value.constructor?.name || '')) {
|
|
670
|
+
return candidate.toArray().map((item) => semanticValue(item, seen, depth + 1))
|
|
671
|
+
}
|
|
672
|
+
const result: Record<string, unknown> = {}
|
|
673
|
+
for (const key of Object.keys(candidate).sort()) {
|
|
674
|
+
if (key.startsWith('_') || ['id', 'uuid', 'uiConfig', 'object', 'ctx', 'body', 'world'].includes(key)) continue
|
|
675
|
+
const normalized = semanticValue(candidate[key], seen, depth + 1)
|
|
676
|
+
if (normalized !== undefined) result[key] = normalized
|
|
677
|
+
}
|
|
678
|
+
return result
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function canonicalValue(value: unknown): unknown {
|
|
682
|
+
if (typeof value === 'number') return normalizeNumber(value)
|
|
683
|
+
if (Array.isArray(value)) return value.map(canonicalValue)
|
|
684
|
+
if (!isRecord(value)) return value
|
|
685
|
+
return Object.fromEntries(Object.keys(value).sort().flatMap((key) => {
|
|
686
|
+
const child = canonicalValue(value[key])
|
|
687
|
+
return child === undefined ? [] : [[key, child]]
|
|
688
|
+
}))
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function vectorValue(value: {x: number, y: number, z: number}): {x: number | null, y: number | null, z: number | null} {
|
|
692
|
+
return {x: normalizeNumber(value.x), y: normalizeNumber(value.y), z: normalizeNumber(value.z)}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function normalizeNumber(value: number): number | null {
|
|
696
|
+
if (!Number.isFinite(value)) return null
|
|
697
|
+
const rounded = Math.round(value * 1_000_000) / 1_000_000
|
|
698
|
+
return Object.is(rounded, -0) ? 0 : rounded
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function numericArray(value: unknown, fallback: number[]): number[] {
|
|
702
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'number')) return fallback
|
|
703
|
+
return value.map((item) => normalizeNumber(item) ?? 0)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function immutableCopy<T extends object>(value: T): T {
|
|
707
|
+
const copy = typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)) as T
|
|
708
|
+
const freeze = (candidate: unknown): void => {
|
|
709
|
+
if (!candidate || typeof candidate !== 'object' || Object.isFrozen(candidate)) return
|
|
710
|
+
for (const child of Object.values(candidate)) freeze(child)
|
|
711
|
+
Object.freeze(candidate)
|
|
712
|
+
}
|
|
713
|
+
freeze(copy)
|
|
714
|
+
return copy
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function pushIssue(issues: AuthoringValidationIssue[], issue: AuthoringValidationIssue): void {
|
|
718
|
+
if (issues.some((candidate) => candidate.code === issue.code && candidate.object?.uuid === issue.object?.uuid
|
|
719
|
+
&& candidate.path === issue.path)) return
|
|
720
|
+
issues.push(issue)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function objectEvidence(object: IObject3D): {uuid: string, name: string} {
|
|
724
|
+
return {uuid: object.uuid, name: object.name || '(unnamed)'}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function isRenderable(object: IObject3D): boolean {
|
|
728
|
+
const candidate = object as IObject3D & {isMesh?: boolean, isLine?: boolean, isPoints?: boolean, isSprite?: boolean}
|
|
729
|
+
return Boolean(candidate.isMesh || candidate.isLine || candidate.isPoints || candidate.isSprite)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function isSelectable(object: IObject3D): boolean {
|
|
733
|
+
return object.userData?.selectable !== false && !object.isWidget
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isEffectivelyVisible(object: IObject3D, modelRoot: IObject3D): boolean {
|
|
737
|
+
for (let current: IObject3D | null = object; current; current = current.parent as IObject3D | null) {
|
|
738
|
+
if (!current.visible) return false
|
|
739
|
+
if (current === modelRoot) return true
|
|
740
|
+
}
|
|
741
|
+
return false
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function isInside(object: IObject3D, ancestor: IObject3D): boolean {
|
|
745
|
+
for (let current: IObject3D | null = object; current; current = current.parent as IObject3D | null) {
|
|
746
|
+
if (current === ancestor) return true
|
|
747
|
+
}
|
|
748
|
+
return false
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isEditorObject(object: IObject3D, scene: SceneLike): boolean {
|
|
752
|
+
if (object === scene.mainCamera || object === scene.defaultCamera) return true
|
|
753
|
+
for (let current: IObject3D | null = object; current; current = current.parent as IObject3D | null) {
|
|
754
|
+
if (current.isWidget) return true
|
|
755
|
+
}
|
|
756
|
+
return false
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function nearestRuntimeRoot(object: IObject3D): IObject3D | undefined {
|
|
760
|
+
for (let current: IObject3D | null = object; current; current = current.parent as IObject3D | null) {
|
|
761
|
+
if (getRuntimeObjectMetadata(current)) return current
|
|
762
|
+
}
|
|
763
|
+
return undefined
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function hasRuntimeAncestor(object: IObject3D, candidates: Set<IObject3D>): boolean {
|
|
767
|
+
for (let current = object.parent as IObject3D | null; current; current = current.parent as IObject3D | null) {
|
|
768
|
+
if (candidates.has(current)) return true
|
|
769
|
+
}
|
|
770
|
+
return false
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function cameraInsideAllowed(object: IObject3D): boolean {
|
|
774
|
+
for (let current: IObject3D | null = object; current; current = current.parent as IObject3D | null) {
|
|
775
|
+
if (getAuthoringMetadata(current)?.allowCameraInside) return true
|
|
776
|
+
}
|
|
777
|
+
return false
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function finiteVector(value: {x: number, y: number, z: number}): boolean {
|
|
781
|
+
return Number.isFinite(value.x) && Number.isFinite(value.y) && Number.isFinite(value.z)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function semanticType(object: IObject3D): string {
|
|
785
|
+
const candidate = object as IObject3D & {isMesh?: boolean, isCamera?: boolean, isLight?: boolean}
|
|
786
|
+
if (candidate.isMesh) return 'Mesh'
|
|
787
|
+
if (candidate.isCamera) return 'Camera'
|
|
788
|
+
if (candidate.isLight) return 'Light'
|
|
789
|
+
return object.children.length ? 'Group' : object.type
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function compareJson(left: unknown, right: unknown): number {
|
|
793
|
+
return JSON.stringify(left).localeCompare(JSON.stringify(right))
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function escapePointer(value: string): string {
|
|
797
|
+
return value.replace(/~/g, '~0').replace(/\//g, '~1')
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function errorMessage(error: unknown): string {
|
|
801
|
+
return error instanceof Error ? error.message : String(error)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
805
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
declare global {
|
|
809
|
+
interface Window {
|
|
810
|
+
kite3dGame?: Readonly<{
|
|
811
|
+
readonly telemetry: object | undefined
|
|
812
|
+
validate(): Promise<GameValidationReport>
|
|
813
|
+
}>
|
|
814
|
+
}
|
|
815
|
+
}
|