@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.
Files changed (74) hide show
  1. package/dist/authoring.d.ts +54 -0
  2. package/dist/authoring.js +165 -0
  3. package/dist/authoring.js.map +1 -0
  4. package/dist/authoringValidation.d.ts +110 -0
  5. package/dist/authoringValidation.js +488 -0
  6. package/dist/authoringValidation.js.map +1 -0
  7. package/dist/defaults.d.ts +2 -0
  8. package/dist/fileTypes.d.ts +5 -0
  9. package/dist/fileTypes.js +51 -0
  10. package/dist/fileTypes.js.map +1 -0
  11. package/dist/importMap.d.ts +15 -0
  12. package/dist/importMap.js +62 -0
  13. package/dist/importMap.js.map +1 -0
  14. package/dist/index.d.ts +15 -0
  15. package/dist/index.js +2756 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/migrations.js +5 -0
  18. package/dist/migrations.js.map +1 -0
  19. package/dist/paths.d.ts +9 -0
  20. package/dist/paths.js +13 -0
  21. package/dist/paths.js.map +1 -0
  22. package/dist/plugins/GeneratorComponent.d.ts +40 -0
  23. package/dist/plugins/HtmlUiComponent.d.ts +52 -0
  24. package/dist/plugins/HtmlUiComponent.example.d.ts +0 -0
  25. package/dist/plugins/cannon/Cannon3DBodyComponent.d.ts +27 -0
  26. package/dist/plugins/cannon/Cannon3DShapeComponent.d.ts +57 -0
  27. package/dist/plugins/cannon/CannonPhysicsPlugin.d.ts +63 -0
  28. package/dist/plugins/cannon/CannonRagdollComponent.d.ts +148 -0
  29. package/dist/plugins/cannon/helper.d.ts +24 -0
  30. package/dist/plugins/cannon/threeToCannon.d.ts +63 -0
  31. package/dist/plugins/cannon/utils.d.ts +9 -0
  32. package/dist/projectFormat.js +108 -0
  33. package/dist/projectFormat.js.map +1 -0
  34. package/dist/runtime/createGame.d.ts +29 -0
  35. package/dist/runtime/index.d.ts +18 -0
  36. package/dist/runtime/migrations.d.ts +5 -0
  37. package/dist/runtime/nestedAssets.d.ts +26 -0
  38. package/dist/runtime/projectFormat.d.ts +83 -0
  39. package/dist/runtime/version.d.ts +1 -0
  40. package/dist/runtime.js +72835 -0
  41. package/dist/runtime.js.map +1 -0
  42. package/dist/sceneSerialization.d.ts +17 -0
  43. package/dist/sceneSerialization.js +174 -0
  44. package/dist/sceneSerialization.js.map +1 -0
  45. package/dist/scripts.d.ts +17 -0
  46. package/dist/version.js +7 -0
  47. package/dist/version.js.map +1 -0
  48. package/package.json +86 -0
  49. package/src/authoring.ts +293 -0
  50. package/src/authoringValidation.ts +815 -0
  51. package/src/defaults.ts +277 -0
  52. package/src/fileTypes.ts +53 -0
  53. package/src/importMap.ts +105 -0
  54. package/src/index.ts +15 -0
  55. package/src/paths.ts +9 -0
  56. package/src/plugins/GeneratorComponent.ts +275 -0
  57. package/src/plugins/HtmlUiComponent.example.ts +202 -0
  58. package/src/plugins/HtmlUiComponent.md +219 -0
  59. package/src/plugins/HtmlUiComponent.ts +320 -0
  60. package/src/plugins/cannon/Cannon3DBodyComponent.ts +227 -0
  61. package/src/plugins/cannon/Cannon3DShapeComponent.ts +258 -0
  62. package/src/plugins/cannon/CannonPhysicsPlugin.ts +409 -0
  63. package/src/plugins/cannon/CannonRagdollComponent.ts +1170 -0
  64. package/src/plugins/cannon/helper.ts +255 -0
  65. package/src/plugins/cannon/threeToCannon.ts +441 -0
  66. package/src/plugins/cannon/utils.ts +185 -0
  67. package/src/runtime/createGame.ts +337 -0
  68. package/src/runtime/index.ts +19 -0
  69. package/src/runtime/migrations.ts +6 -0
  70. package/src/runtime/nestedAssets.ts +226 -0
  71. package/src/runtime/projectFormat.ts +233 -0
  72. package/src/runtime/version.ts +3 -0
  73. package/src/sceneSerialization.ts +289 -0
  74. 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
+ }