@hraness/slopcamera 3.2.7 → 3.3.1

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 (188) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +65 -20
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +6 -9
  6. package/apps/desktop/application/default-registry.ts +26 -10
  7. package/apps/desktop/application/operation.ts +14 -5
  8. package/apps/desktop/application/operations/index.ts +4 -1
  9. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  10. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  11. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  12. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  13. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  14. package/apps/desktop/application/registry.ts +2 -2
  15. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  16. package/apps/desktop/application/spatial-assets.ts +160 -31
  17. package/apps/desktop/application/spatial-render.ts +217 -20
  18. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  19. package/apps/desktop/application/spatial-review.ts +361 -0
  20. package/apps/desktop/application/spatial-spz.ts +48 -3
  21. package/apps/desktop/application/spatial-world-import.ts +26 -5
  22. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  23. package/apps/desktop/cli/args.ts +528 -97
  24. package/apps/desktop/cli/capability-manifest.ts +374 -0
  25. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  26. package/apps/desktop/cli/command-host-resources.ts +31 -2
  27. package/apps/desktop/cli/commands.ts +1215 -70
  28. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  29. package/apps/desktop/cli/help.ts +183 -23
  30. package/apps/desktop/cli/html-scene.ts +139 -28
  31. package/apps/desktop/cli/io.ts +2 -1
  32. package/apps/desktop/cli/main.ts +26 -50
  33. package/apps/desktop/cli/menubar.ts +244 -0
  34. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  35. package/apps/desktop/cli/portable-surface.ts +18 -5
  36. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  37. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  38. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  39. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  40. package/apps/desktop/cli/workflow-code.ts +1 -1
  41. package/apps/desktop/code/application-node-planner.ts +0 -47
  42. package/apps/desktop/code/host-source-layout.ts +46 -0
  43. package/apps/desktop/code/public.ts +0 -1
  44. package/apps/desktop/code/runtime-identity.ts +50 -8
  45. package/apps/desktop/code/semantic-builder.ts +24 -43
  46. package/apps/desktop/code/source-bundle.ts +23 -6
  47. package/apps/desktop/code/source-typecheck.ts +131 -43
  48. package/apps/desktop/code/worker-client.ts +14 -2
  49. package/apps/desktop/contracts/cinema.ts +736 -0
  50. package/apps/desktop/contracts/index.ts +1 -0
  51. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  52. package/apps/desktop/contracts/spatial-world.ts +26 -3
  53. package/apps/desktop/core/cinema-plan.ts +1320 -0
  54. package/apps/desktop/core/index.ts +1 -0
  55. package/apps/desktop/core/music-analysis.ts +124 -1
  56. package/apps/desktop/core/storage.ts +75 -0
  57. package/apps/desktop/dist/cli/main.js +737 -291
  58. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  59. package/apps/desktop/html-overlay/index.ts +9 -0
  60. package/apps/desktop/html-overlay/runtime.ts +2 -0
  61. package/apps/desktop/html-overlay/scene.ts +29 -0
  62. package/apps/desktop/html-overlay/spatial.ts +619 -57
  63. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  64. package/apps/desktop/workflows/index.ts +6 -0
  65. package/dist/cli.js +32 -434
  66. package/dist/code/advanced.js +1 -269
  67. package/dist/code/index.js +16 -2239
  68. package/dist/generate.js +1 -23
  69. package/dist/host-resources.js +1 -25
  70. package/dist/index-0bbesn4m.js +35 -0
  71. package/dist/index-296qhpf2.js +3 -0
  72. package/dist/index-3cxf2c58.js +3 -0
  73. package/dist/index-77fjfg9f.js +3 -0
  74. package/dist/index-80a36bc0.js +3 -0
  75. package/dist/index-a3vvs3rf.js +3 -0
  76. package/dist/index-b3geqczn.js +5 -0
  77. package/dist/index-cgpkyjqa.js +3 -0
  78. package/dist/index-f0ptv8dm.js +7 -0
  79. package/dist/index-grk47tgs.js +3 -0
  80. package/dist/index-qry58nj2.js +10 -0
  81. package/dist/index.js +1 -240
  82. package/dist/operations.js +1 -28
  83. package/dist/skill-install-gx7afxsd.js +2 -0
  84. package/dist/vectorize/worker.js +1 -141
  85. package/dist/workflow.js +1 -15
  86. package/docs/README.md +5 -1
  87. package/docs/studio.md +1 -1
  88. package/package.json +11 -24
  89. package/skills/slopcamera/SKILL.md +14 -1
  90. package/skills/slopcamera/references/diagrams.md +5 -14
  91. package/skills/slopcamera/references/directed-scenes.md +22 -2
  92. package/skills/slopcamera/references/gateway-media.md +15 -1
  93. package/skills/slopcamera/references/image-galleries.md +166 -0
  94. package/skills/slopcamera/references/install.md +2 -2
  95. package/skills/slopcamera/references/music-video.md +23 -3
  96. package/skills/slopcamera/references/parametric-design.md +14 -0
  97. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  98. package/skills/slopcamera/references/scene-building.md +32 -0
  99. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  100. package/skills/slopcamera/references/support.md +25 -0
  101. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  102. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  103. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  104. package/src/capability-manifest.ts +361 -0
  105. package/src/cli.ts +250 -102
  106. package/src/code/canonical-json.ts +9 -0
  107. package/src/generate.ts +2 -2
  108. package/src/icon.ts +1111 -0
  109. package/src/image-gallery.ts +1049 -0
  110. package/src/index.ts +11 -23
  111. package/src/mcp/boundary.ts +11 -11
  112. package/src/mcp/index.ts +3 -0
  113. package/src/mcp/server.ts +2 -1
  114. package/src/mcp/tools.ts +1642 -130
  115. package/src/mcp/types.ts +19 -0
  116. package/src/operations.ts +412 -0
  117. package/src/portable-capability-manifest.ts +67 -0
  118. package/src/scene-gallery.ts +184 -0
  119. package/src/spatial-scene/asset-admission.ts +183 -0
  120. package/src/spatial-scene/audit-rendered.ts +692 -0
  121. package/src/spatial-scene/audit.ts +617 -0
  122. package/src/spatial-scene/behavior-audit.ts +252 -0
  123. package/src/spatial-scene/behavior-bake.ts +233 -0
  124. package/src/spatial-scene/behavior-fns.ts +585 -0
  125. package/src/spatial-scene/behavior-gallery.ts +109 -0
  126. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  127. package/src/spatial-scene/behavior-trace.ts +325 -0
  128. package/src/spatial-scene/behavior.ts +618 -0
  129. package/src/spatial-scene/build.ts +632 -0
  130. package/src/spatial-scene/camera-rig.ts +125 -0
  131. package/src/spatial-scene/camera-track.ts +4 -2
  132. package/src/spatial-scene/character.ts +124 -0
  133. package/src/spatial-scene/contracts.ts +75 -7
  134. package/src/spatial-scene/design-templates.ts +351 -0
  135. package/src/spatial-scene/design.ts +327 -0
  136. package/src/spatial-scene/direction-compile.ts +584 -0
  137. package/src/spatial-scene/direction.ts +122 -0
  138. package/src/spatial-scene/effects.ts +149 -0
  139. package/src/spatial-scene/evaluate.ts +109 -29
  140. package/src/spatial-scene/gallery.ts +277 -0
  141. package/src/spatial-scene/generate.ts +239 -0
  142. package/src/spatial-scene/geometry-native.ts +165 -0
  143. package/src/spatial-scene/geometry.ts +1216 -0
  144. package/src/spatial-scene/gltf.ts +600 -77
  145. package/src/spatial-scene/identity.ts +55 -2
  146. package/src/spatial-scene/index.ts +36 -0
  147. package/src/spatial-scene/inspect.ts +35 -6
  148. package/src/spatial-scene/material-lighting.ts +609 -0
  149. package/src/spatial-scene/math.ts +21 -4
  150. package/src/spatial-scene/motion-evidence.ts +88 -0
  151. package/src/spatial-scene/parametric.ts +765 -0
  152. package/src/spatial-scene/particle-preparation.ts +183 -0
  153. package/src/spatial-scene/particle.ts +195 -0
  154. package/src/spatial-scene/patch.ts +109 -15
  155. package/src/spatial-scene/performance.ts +1613 -0
  156. package/src/spatial-scene/probe.ts +137 -0
  157. package/src/spatial-scene/recipe-pack.ts +86 -0
  158. package/src/spatial-scene/render-effects.ts +240 -0
  159. package/src/spatial-scene/review.ts +639 -0
  160. package/src/spatial-scene/simulation-bake.ts +206 -0
  161. package/src/spatial-scene/simulation.ts +187 -0
  162. package/src/spatial-scene/solve.ts +366 -0
  163. package/src/spatial-scene/temporal-audit.ts +354 -0
  164. package/src/support-completion.ts +16 -0
  165. package/src/support.ts +42 -0
  166. package/src/version.ts +1 -1
  167. package/apps/desktop/application/operations/recording/index.ts +0 -5
  168. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  169. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  170. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  171. package/apps/desktop/application/operations/recording/start.ts +0 -169
  172. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  173. package/apps/desktop/capture/protocol.ts +0 -689
  174. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  175. package/apps/desktop/cli/recording-controller.ts +0 -1567
  176. package/apps/desktop/cli/recording-daemon.ts +0 -931
  177. package/dist/index-42zsesc1.js +0 -2257
  178. package/dist/index-7308egqr.js +0 -48
  179. package/dist/index-8txs6fkn.js +0 -712
  180. package/dist/index-mcy8z0br.js +0 -192
  181. package/dist/index-p63wavx0.js +0 -2029
  182. package/dist/index-r7gdhmsp.js +0 -424
  183. package/dist/index-rvghzt7b.js +0 -2013
  184. package/dist/index-sh6xbav6.js +0 -1219
  185. package/dist/index-z1w83f81.js +0 -4
  186. package/dist/index-z7239b4h.js +0 -2363
  187. package/dist/skill-install-dh0ntqba.js +0 -10
  188. package/src/desktop.ts +0 -253
@@ -0,0 +1,617 @@
1
+ import { z } from "zod"
2
+ import { canonicalJson } from "../code/canonical-json.js"
3
+ import { createBoundedJsonValueSnapshot, deepFreezeJson } from "../code/json-snapshot.js"
4
+ import {
5
+ SpatialAssetAdmissionV1Schema,
6
+ SpatialAssetFactsV1Schema,
7
+ type SpatialAssetFactsV1,
8
+ } from "./asset-admission.js"
9
+ import {
10
+ SPATIAL_SCENE_LIMITS, SpatialAssetIdSchema, SpatialAssetManifestSchema, SpatialCameraIdSchema, SpatialDigestSchema,
11
+ SpatialEntityIdSchema, SpatialPlacementSchema, SpatialSceneIdSchema, SpatialTimeUsSchema,
12
+ type SpatialAssetManifest, type SpatialEntity,
13
+ } from "./contracts.js"
14
+ import {
15
+ createSpatialEvaluationContext, evaluateSpatialSceneInContext, type SpatialEvaluationContext,
16
+ } from "./evaluate.js"
17
+ import { parseSpatialValue, sortSpatialBy, SpatialSceneError } from "./identity.js"
18
+ import {
19
+ cameraMathView, composeTransform, multiplyTransforms, prepareCameraView, projectPreparedPoint, transformBounds,
20
+ type Bounds, type PreparedCameraView, type Vec3,
21
+ } from "./math.js"
22
+
23
+ export const SPATIAL_AUDIT_LIMITS = Object.freeze({
24
+ /** Caller-selected or default sample times per audit. */
25
+ samples: 64,
26
+ /** Default evenly spaced sample count covering [0, durationUs]. */
27
+ defaultSamples: 9,
28
+ /** Retained findings; any excess is counted in `omittedFindings`. */
29
+ findings: 1_024,
30
+ /** Joint bound on entities × samples so reports stay bounded. */
31
+ entitySamples: 65_536,
32
+ /** Joint bound on Σ instance counts × samples; instancing multiplies enclosure work. */
33
+ instanceSamples: 1_048_576,
34
+ /** Canonical report byte bound checked before return. */
35
+ reportBytes: 33_554_432,
36
+ })
37
+
38
+ const ENTITY_KINDS = ["group", "mesh", "image", "diagram", "video", "text", "light", "splat", "environment"] as const
39
+ const BOUNDS_UNKNOWN_REASONS = ["requires-asset-decoding", "requires-text-layout", "no-surface"] as const
40
+ const CONTAINED = ["full", "partial", "outside", "behind-camera", "clipped"] as const
41
+ const FINDING_KINDS = ["never-visible", "off-camera", "empty-scene-region", "bounds-unknown", "behind-camera-all-samples", "shadows-disabled"] as const
42
+ // Fixed histogram order for deterministic finding details.
43
+ const CONTAINED_HISTOGRAM_ORDER = ["full", "partial", "outside", "clipped", "behind-camera"] as const
44
+
45
+ const auditVector = z.tuple([
46
+ z.number().finite().min(-1e12).max(1e12),
47
+ z.number().finite().min(-1e12).max(1e12),
48
+ z.number().finite().min(-1e12).max(1e12),
49
+ ])
50
+ export const SpatialAuditBoundsSchema = z.strictObject({ min: auditVector, max: auditVector })
51
+ .refine(bounds => bounds.min.every((value, index) => value <= bounds.max[index]!), "Bounds min must not exceed max.")
52
+
53
+ export const SpatialAuditOptionsSchema = z.strictObject({
54
+ cameraId: SpatialCameraIdSchema,
55
+ timesUs: z.array(SpatialTimeUsSchema).min(1).max(SPATIAL_AUDIT_LIMITS.samples).optional(),
56
+ assetBounds: z.record(SpatialAssetIdSchema, SpatialAuditBoundsSchema).optional(),
57
+ })
58
+ export interface SpatialAuditOptions {
59
+ readonly cameraId: string
60
+ readonly timesUs?: readonly number[]
61
+ readonly assetBounds?: Readonly<Record<string, Bounds>>
62
+ }
63
+
64
+ export const SpatialAuditFrustumSchema = z.strictObject({
65
+ contained: z.enum(CONTAINED),
66
+ /** Estimated covered image area in px²: corner-pixel bbox ∩ image, rounded to 3 decimals. */
67
+ pixelFootprint: z.number().finite().min(0).max(1e15),
68
+ })
69
+ export const SpatialAuditSampleSchema = z.strictObject({
70
+ timeUs: SpatialTimeUsSchema,
71
+ /** Effective visibility under the audited camera at this sample. */
72
+ visible: z.boolean(),
73
+ /** Placement-domain enclosure: world meters for world entities, view units for view entities. */
74
+ bounds: SpatialAuditBoundsSchema.optional(),
75
+ frustum: SpatialAuditFrustumSchema.optional(),
76
+ note: z.enum(["out-of-range", "other-camera"]).optional(),
77
+ })
78
+ export const SpatialAuditEntitySchema = z.strictObject({
79
+ entityId: SpatialEntityIdSchema,
80
+ name: z.string().min(1).max(256),
81
+ kind: z.enum(ENTITY_KINDS),
82
+ placement: SpatialPlacementSchema,
83
+ enclosure: z.discriminatedUnion("status", [
84
+ z.strictObject({ status: z.literal("bounded") }),
85
+ z.strictObject({ status: z.literal("unknown"), reason: z.enum(BOUNDS_UNKNOWN_REASONS) }),
86
+ ]),
87
+ samples: z.array(SpatialAuditSampleSchema).max(SPATIAL_AUDIT_LIMITS.samples),
88
+ /** Present on mesh entities only when local-space instances are declared. */
89
+ instances: z.number().int().min(1).max(SPATIAL_SCENE_LIMITS.entities).optional(),
90
+ })
91
+ export const SpatialAuditFindingSchema = z.strictObject({
92
+ severity: z.enum(["info", "warning"]),
93
+ kind: z.enum(FINDING_KINDS),
94
+ entityId: SpatialEntityIdSchema.optional(),
95
+ timeUs: SpatialTimeUsSchema.optional(),
96
+ detail: z.string().min(1).max(1_024),
97
+ })
98
+ const entityKindCounts = z.strictObject({
99
+ group: z.number().int().min(0), mesh: z.number().int().min(0), image: z.number().int().min(0),
100
+ diagram: z.number().int().min(0), video: z.number().int().min(0), text: z.number().int().min(0),
101
+ light: z.number().int().min(0), splat: z.number().int().min(0), environment: z.number().int().min(0),
102
+ })
103
+ export const SpatialAuditReportSchema = z.strictObject({
104
+ kind: z.literal("slopcamera.spatial-audit"),
105
+ schemaVersion: z.literal(1),
106
+ sceneId: SpatialSceneIdSchema,
107
+ sceneSha256: SpatialDigestSchema,
108
+ cameraId: SpatialCameraIdSchema,
109
+ durationUs: SpatialTimeUsSchema,
110
+ timesUs: z.array(SpatialTimeUsSchema).min(1).max(SPATIAL_AUDIT_LIMITS.samples),
111
+ summary: z.strictObject({
112
+ entities: z.strictObject({
113
+ total: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.entities),
114
+ bounded: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.entities),
115
+ unknownBounds: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.entities),
116
+ byKind: entityKindCounts,
117
+ /** Total declared instance transforms across all instanced meshes. */
118
+ instances: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.entities * SPATIAL_SCENE_LIMITS.entities),
119
+ }),
120
+ animations: z.strictObject({
121
+ channels: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.channels),
122
+ targets: z.number().int().min(0).max(SPATIAL_SCENE_LIMITS.entities + SPATIAL_SCENE_LIMITS.cameras),
123
+ properties: z.strictObject({
124
+ position: z.number().int().min(0), rotation: z.number().int().min(0),
125
+ scale: z.number().int().min(0), opacity: z.number().int().min(0),
126
+ }),
127
+ }),
128
+ cameras: z.array(SpatialCameraIdSchema).max(SPATIAL_SCENE_LIMITS.cameras),
129
+ /** Entities whose effective visibility is false at every sample. */
130
+ entitiesNeverVisible: z.array(SpatialEntityIdSchema).max(SPATIAL_SCENE_LIMITS.entities),
131
+ /** Bounded entities with at least one visible sample but no full/partial containment. */
132
+ entitiesNeverInFrustum: z.array(SpatialEntityIdSchema).max(SPATIAL_SCENE_LIMITS.entities),
133
+ }),
134
+ entities: z.array(SpatialAuditEntitySchema).max(SPATIAL_SCENE_LIMITS.entities),
135
+ findings: z.array(SpatialAuditFindingSchema).max(SPATIAL_AUDIT_LIMITS.findings),
136
+ omittedFindings: z.number().int().min(0),
137
+ })
138
+
139
+ /**
140
+ * Structural report types mirror the schemas above rather than inferring from
141
+ * them: recursive readonly mappers over the strict-object graph measurably
142
+ * slow every authored-source typecheck that reaches this module through the
143
+ * `./code` surface. `auditSpatialScene` returns schema-parsed output, so any
144
+ * divergence between a schema and its declared type fails compilation at the
145
+ * return site below.
146
+ */
147
+ export interface SpatialAuditBounds {
148
+ readonly min: readonly [number, number, number]
149
+ readonly max: readonly [number, number, number]
150
+ }
151
+ export interface SpatialAuditFrustum {
152
+ readonly contained: (typeof CONTAINED)[number]
153
+ readonly pixelFootprint: number
154
+ }
155
+ export interface SpatialAuditSample {
156
+ readonly timeUs: number
157
+ readonly visible: boolean
158
+ readonly bounds?: SpatialAuditBounds | undefined
159
+ readonly frustum?: SpatialAuditFrustum | undefined
160
+ readonly note?: "out-of-range" | "other-camera" | undefined
161
+ }
162
+ export interface SpatialAuditEntity {
163
+ readonly entityId: string
164
+ readonly name: string
165
+ readonly kind: (typeof ENTITY_KINDS)[number]
166
+ readonly placement: SpatialEntity["placement"]
167
+ readonly enclosure:
168
+ | { readonly status: "bounded" }
169
+ | { readonly status: "unknown"; readonly reason: (typeof BOUNDS_UNKNOWN_REASONS)[number] }
170
+ readonly samples: readonly SpatialAuditSample[]
171
+ readonly instances?: number | undefined
172
+ }
173
+ export interface SpatialAuditFinding {
174
+ readonly severity: "info" | "warning"
175
+ readonly kind: (typeof FINDING_KINDS)[number]
176
+ readonly entityId?: string | undefined
177
+ readonly timeUs?: number | undefined
178
+ readonly detail: string
179
+ }
180
+ export interface SpatialAuditReport {
181
+ readonly kind: "slopcamera.spatial-audit"
182
+ readonly schemaVersion: 1
183
+ readonly sceneId: string
184
+ readonly sceneSha256: string
185
+ readonly cameraId: string
186
+ readonly durationUs: number
187
+ readonly timesUs: readonly number[]
188
+ readonly summary: {
189
+ readonly entities: {
190
+ readonly total: number
191
+ readonly bounded: number
192
+ readonly unknownBounds: number
193
+ readonly byKind: Readonly<Record<(typeof ENTITY_KINDS)[number], number>>
194
+ readonly instances: number
195
+ }
196
+ readonly animations: {
197
+ readonly channels: number
198
+ readonly targets: number
199
+ readonly properties: Readonly<Record<"position" | "rotation" | "scale" | "opacity", number>>
200
+ }
201
+ readonly cameras: readonly string[]
202
+ readonly entitiesNeverVisible: readonly string[]
203
+ readonly entitiesNeverInFrustum: readonly string[]
204
+ }
205
+ readonly entities: readonly SpatialAuditEntity[]
206
+ readonly findings: readonly SpatialAuditFinding[]
207
+ readonly omittedFindings: number
208
+ }
209
+
210
+ export type SpatialEntityEnclosure =
211
+ | { readonly status: "bounded"; readonly bounds: Bounds }
212
+ | { readonly status: "unknown"; readonly reason: (typeof BOUNDS_UNKNOWN_REASONS)[number] }
213
+
214
+ /** Authored enclosures or caller-decoded asset bounds; never invented. */
215
+ export function spatialEntityLocalBounds(entity: SpatialEntity, assetBounds: Readonly<Record<string, Bounds>>): SpatialEntityEnclosure {
216
+ const supplied = (assetId: string): SpatialEntityEnclosure =>
217
+ assetBounds[assetId] === undefined
218
+ ? { status: "unknown", reason: "requires-asset-decoding" }
219
+ : { status: "bounded", bounds: assetBounds[assetId]! }
220
+ let half: Vec3
221
+ if (entity.kind === "mesh") {
222
+ switch (entity.geometry.kind) {
223
+ case "asset": return supplied(entity.geometry.assetId)
224
+ case "box": half = entity.geometry.size.map(value => value / 2) as unknown as Vec3; break
225
+ case "plane": half = [entity.geometry.width / 2, entity.geometry.height / 2, 0]; break
226
+ case "sphere": half = [entity.geometry.radius, entity.geometry.radius, entity.geometry.radius]; break
227
+ case "cylinder": half = [entity.geometry.radius, entity.geometry.height / 2, entity.geometry.radius]; break
228
+ }
229
+ } else if (entity.kind === "image" || entity.kind === "diagram" || entity.kind === "video") {
230
+ half = [entity.width / 2, entity.height / 2, 0]
231
+ } else if (entity.kind === "splat") return supplied(entity.assetId)
232
+ else return { status: "unknown", reason: entity.kind === "text" ? "requires-text-layout" : "no-surface" }
233
+ return { status: "bounded", bounds: { min: [-half[0], -half[1], -half[2]], max: half } }
234
+ }
235
+
236
+ const round3 = (value: number): number => Math.round(value * 1_000) / 1_000
237
+
238
+ function boundsCorners(bounds: Bounds): readonly Vec3[] {
239
+ const corners: Vec3[] = []
240
+ for (let mask = 0; mask < 8; mask++) {
241
+ corners.push([
242
+ mask & 1 ? bounds.max[0] : bounds.min[0],
243
+ mask & 2 ? bounds.max[1] : bounds.min[1],
244
+ mask & 4 ? bounds.max[2] : bounds.min[2],
245
+ ])
246
+ }
247
+ return corners
248
+ }
249
+
250
+ /**
251
+ * Corner-projection containment. "full" requires every corner inside image and
252
+ * clip range; "partial" covers any visible corner or a camera-plane crossing;
253
+ * "outside" is an image miss inside the depth range; "clipped" is an all-corner
254
+ * near/far miss; "behind-camera" is all corners at non-positive depth. The
255
+ * footprint is the projected-corner bbox intersected with the image, which may
256
+ * still cover the frame when all corners fall outside it.
257
+ */
258
+ function classifyWorldFrustum(view: PreparedCameraView, bounds: Bounds): SpatialAuditFrustum {
259
+ const { width, height } = view.projection
260
+ let behind = 0, inside = 0, inClip = 0
261
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
262
+ for (const corner of boundsCorners(bounds)) {
263
+ const projected = projectPreparedPoint(view, corner)
264
+ if (projected === null) { behind++; continue }
265
+ minX = Math.min(minX, projected.pixel[0]); maxX = Math.max(maxX, projected.pixel[0])
266
+ minY = Math.min(minY, projected.pixel[1]); maxY = Math.max(maxY, projected.pixel[1])
267
+ if (projected.insideClip) inClip++
268
+ if (projected.insideImage && projected.insideClip) inside++
269
+ }
270
+ const contained = behind === 8 ? "behind-camera"
271
+ : inside === 8 ? "full"
272
+ : inside > 0 || behind > 0 ? "partial"
273
+ : inClip > 0 ? "outside"
274
+ : "clipped"
275
+ const pixelFootprint = behind === 8 ? 0 : round3(
276
+ Math.max(0, Math.min(maxX, width) - Math.max(minX, 0))
277
+ * Math.max(0, Math.min(maxY, height) - Math.max(minY, 0)),
278
+ )
279
+ return { contained, pixelFootprint }
280
+ }
281
+
282
+ /**
283
+ * View overlays render in a camera-bound orthographic domain (X right, Y down;
284
+ * normalized units scaled by output dimensions, pixel units verbatim). Depth
285
+ * ordering is declared by placement order, so only image containment applies.
286
+ */
287
+ function classifyViewOverlay(
288
+ bounds: Bounds, units: "pixels" | "normalized", width: number, height: number,
289
+ ): SpatialAuditFrustum {
290
+ const scaleX = units === "normalized" ? width : 1
291
+ const scaleY = units === "normalized" ? height : 1
292
+ const minX = bounds.min[0] * scaleX, maxX = bounds.max[0] * scaleX
293
+ const minY = bounds.min[1] * scaleY, maxY = bounds.max[1] * scaleY
294
+ const pixelFootprint = round3(
295
+ Math.max(0, Math.min(maxX, width) - Math.max(minX, 0))
296
+ * Math.max(0, Math.min(maxY, height) - Math.max(minY, 0)),
297
+ )
298
+ const contained = minX >= 0 && minY >= 0 && maxX <= width && maxY <= height ? "full"
299
+ : pixelFootprint > 0 ? "partial"
300
+ : "outside"
301
+ return { contained, pixelFootprint }
302
+ }
303
+
304
+ /** Evenly spaced default samples over [0, durationUs], endpoints included. Shared by the rendered audit. */
305
+ export function spatialAuditDefaultTimesUs(durationUs: number): readonly number[] {
306
+ const count = SPATIAL_AUDIT_LIMITS.defaultSamples
307
+ return Array.from({ length: count }, (_, index) => Math.round(index * durationUs / (count - 1)))
308
+ }
309
+
310
+ type Finding = SpatialAuditFinding
311
+ const findingOrder = (finding: Finding): string =>
312
+ `${finding.kind}:${finding.entityId ?? ""}:${String(finding.timeUs ?? -1).padStart(12, "0")}`
313
+
314
+ /** Intermediate draft shapes; the report schema owns the emitted value types. */
315
+ interface SampleDraft {
316
+ readonly timeUs: number
317
+ readonly visible: boolean
318
+ readonly bounds?: Bounds
319
+ readonly frustum?: SpatialAuditFrustum
320
+ readonly note?: "out-of-range" | "other-camera"
321
+ }
322
+ interface EntityDraft {
323
+ readonly entityId: string
324
+ readonly name: string
325
+ readonly kind: SpatialEntity["kind"]
326
+ readonly placement: SpatialEntity["placement"]
327
+ readonly enclosure: { readonly status: "bounded" } | { readonly status: "unknown"; readonly reason: (typeof BOUNDS_UNKNOWN_REASONS)[number] }
328
+ readonly samples: SampleDraft[]
329
+ readonly instances?: number
330
+ }
331
+
332
+ function unionBounds(items: readonly Bounds[]): Bounds {
333
+ return items.reduce((union, next) => ({
334
+ min: union.min.map((value, axis) => Math.min(value, next.min[axis]!)) as unknown as Vec3,
335
+ max: union.max.map((value, axis) => Math.max(value, next.max[axis]!)) as unknown as Vec3,
336
+ }))
337
+ }
338
+
339
+ /** Instance transforms live inside the entity's local frame: world × instance × local bounds. */
340
+ function instanceDomain(worldMatrix: Parameters<typeof transformBounds>[0], entity: SpatialEntity, local: Bounds): Bounds {
341
+ if (entity.kind !== "mesh" || entity.instances === undefined) return transformBounds(worldMatrix, local)
342
+ return unionBounds(entity.instances.map(instance => transformBounds(multiplyTransforms(worldMatrix, composeTransform(instance)), local)))
343
+ }
344
+
345
+ /**
346
+ * Samples a scene against one camera and reports per-entity geometric coverage.
347
+ * Pure and deterministic: no IO, decoding or renderer; caller-decoded asset
348
+ * bounds arrive through options and absent bounds are reported, not invented.
349
+ */
350
+ export function auditSpatialScene(sceneInput: unknown, options: SpatialAuditOptions): SpatialAuditReport {
351
+ return auditSpatialSceneInContext(createSpatialEvaluationContext(sceneInput), options)
352
+ }
353
+
354
+ /**
355
+ * The audit hot path against a shared evaluation context: one scene parse and
356
+ * index pass covers every sample instead of re-deriving them per sample, and
357
+ * each sample's camera inverse is prepared once for all corner projections.
358
+ */
359
+ export function auditSpatialSceneInContext(context: SpatialEvaluationContext, options: SpatialAuditOptions): SpatialAuditReport {
360
+ const scene = context.scene
361
+ const captured = parseSpatialValue(SpatialAuditOptionsSchema, options, "audit options")
362
+ const cameraId = captured.cameraId
363
+ if (!context.camerasById.has(cameraId)) {
364
+ throw new SpatialSceneError("not-found", `Unknown camera ${cameraId}.`, "cameraId")
365
+ }
366
+ const assetIds = new Set(scene.assets.map(asset => asset.assetId))
367
+ const assetBounds: Record<string, Bounds> = Object.create(null) as Record<string, Bounds>
368
+ for (const [assetId, bounds] of Object.entries(captured.assetBounds ?? {})) {
369
+ if (!assetIds.has(assetId)) throw new SpatialSceneError("invalid-data", `Asset bounds reference unknown ${assetId}.`, "assetBounds")
370
+ assetBounds[assetId] = Object.freeze({ min: Object.freeze([...bounds.min]), max: Object.freeze([...bounds.max]) }) as Bounds
371
+ }
372
+ const timesUs = [...new Set(captured.timesUs ?? spatialAuditDefaultTimesUs(scene.durationUs))].sort((a, b) => a - b)
373
+ for (const timeUs of timesUs) {
374
+ if (timeUs > scene.durationUs) throw new SpatialSceneError("invalid-data", "Audit sample time exceeds scene duration.", "timesUs")
375
+ }
376
+ if (scene.entities.length * timesUs.length > SPATIAL_AUDIT_LIMITS.entitySamples) {
377
+ throw new SpatialSceneError("invalid-data", "Audit entity-sample budget exceeded; pass fewer timesUs samples.", "timesUs")
378
+ }
379
+ const instanceTotal = scene.entities.reduce((total, entity) => total + (entity.kind === "mesh" && entity.instances !== undefined ? entity.instances.length : 1), 0)
380
+ if (instanceTotal * timesUs.length > SPATIAL_AUDIT_LIMITS.instanceSamples) {
381
+ throw new SpatialSceneError("invalid-data", "Audit instance-sample budget exceeded; pass fewer timesUs samples.", "timesUs")
382
+ }
383
+
384
+ const enclosures = new Map(scene.entities.map(entity => [entity.entityId, spatialEntityLocalBounds(entity, assetBounds)]))
385
+ const samplesByEntity = new Map(scene.entities.map(entity => [entity.entityId, [] as SampleDraft[]]))
386
+ for (const timeUs of timesUs) {
387
+ const snapshot = evaluateSpatialSceneInContext(context, { timeUs, cameraId })
388
+ // The evaluated camera pose may move per sample, but within one sample its
389
+ // validation and world-to-camera inverse are shared by every corner test.
390
+ const view = prepareCameraView(cameraMathView(snapshot.camera))
391
+ const { width, height } = snapshot.camera.projection
392
+ for (const entry of snapshot.entities) {
393
+ const entity = entry.entity
394
+ const samples = samplesByEntity.get(entity.entityId)!
395
+ const placement = entity.placement
396
+ if (placement.kind === "view" && placement.cameraId !== cameraId) {
397
+ // A view overlay bound to another camera has no domain under this one.
398
+ samples.push({ timeUs, visible: entry.visible, note: "other-camera" })
399
+ continue
400
+ }
401
+ const enclosure = enclosures.get(entity.entityId)!
402
+ if (enclosure.status === "unknown") {
403
+ samples.push({ timeUs, visible: entry.visible })
404
+ continue
405
+ }
406
+ try {
407
+ const domain = instanceDomain(entry.worldMatrix, entity, enclosure.bounds)
408
+ const frustum = placement.kind === "view"
409
+ ? classifyViewOverlay(domain, placement.units, width, height)
410
+ : classifyWorldFrustum(view, domain)
411
+ samples.push({ timeUs, visible: entry.visible, bounds: domain, frustum })
412
+ } catch (error) {
413
+ if (!(error instanceof RangeError)) throw error
414
+ samples.push({ timeUs, visible: entry.visible, note: "out-of-range" })
415
+ }
416
+ }
417
+ }
418
+
419
+ const findings: Finding[] = []
420
+ const neverVisible: string[] = []
421
+ const neverInFrustum: string[] = []
422
+ const auditedEntities: EntityDraft[] = []
423
+ for (const entity of scene.entities) {
424
+ const enclosure = enclosures.get(entity.entityId)!
425
+ const samples = samplesByEntity.get(entity.entityId)!
426
+ auditedEntities.push({
427
+ entityId: entity.entityId, name: entity.name, kind: entity.kind, placement: entity.placement,
428
+ enclosure: enclosure.status === "bounded" ? { status: "bounded" } : { status: "unknown", reason: enclosure.reason },
429
+ samples,
430
+ ...(entity.kind === "mesh" && entity.instances !== undefined ? { instances: entity.instances.length } : {}),
431
+ })
432
+ const applicable = samples.filter(sample => sample.note !== "other-camera")
433
+ const visible = applicable.filter(sample => sample.visible)
434
+ if (visible.length === 0) {
435
+ neverVisible.push(entity.entityId)
436
+ findings.push({
437
+ severity: "info", kind: "never-visible", entityId: entity.entityId,
438
+ detail: entity.placement.kind === "view" && entity.placement.cameraId !== cameraId
439
+ ? `View-bound to ${entity.placement.cameraId}; not evaluated under ${cameraId}.`
440
+ : "Effective visibility is false at every sampled time.",
441
+ })
442
+ continue
443
+ }
444
+ if (enclosure.status === "unknown") {
445
+ if (enclosure.reason !== "no-surface") {
446
+ findings.push({
447
+ severity: "info", kind: "bounds-unknown", entityId: entity.entityId,
448
+ detail: enclosure.reason === "requires-asset-decoding"
449
+ ? "Bounds require decoded asset data; supply assetBounds to audit this entity."
450
+ : "Text bounds require font layout; audited for visibility only.",
451
+ })
452
+ }
453
+ continue
454
+ }
455
+ const outOfRange = visible.filter(sample => sample.note === "out-of-range").length
456
+ if (outOfRange > 0) {
457
+ findings.push({
458
+ severity: "info", kind: "bounds-unknown", entityId: entity.entityId,
459
+ detail: `World bounds exceed numeric limits at ${String(outOfRange)} visible sample${outOfRange === 1 ? "" : "s"}.`,
460
+ })
461
+ }
462
+ const statuses = visible.filter(sample => sample.frustum !== undefined).map(sample => sample.frustum!.contained)
463
+ if (statuses.length === 0) continue
464
+ if (!statuses.some(status => status === "full" || status === "partial")) {
465
+ neverInFrustum.push(entity.entityId)
466
+ if (statuses.every(status => status === "behind-camera")) {
467
+ findings.push({
468
+ severity: "warning", kind: "behind-camera-all-samples", entityId: entity.entityId,
469
+ detail: `Every visible sample is behind the camera plane (${String(statuses.length)} sample${statuses.length === 1 ? "" : "s"}).`,
470
+ })
471
+ } else {
472
+ const histogram = CONTAINED_HISTOGRAM_ORDER
473
+ .map(status => [status, statuses.filter(value => value === status).length] as const)
474
+ .filter(([, count]) => count > 0)
475
+ .map(([status, count]) => `${status} ×${String(count)}`)
476
+ .join(", ")
477
+ findings.push({
478
+ severity: "warning", kind: "off-camera", entityId: entity.entityId,
479
+ detail: `Never inside the camera frustum: ${histogram} across ${String(statuses.length)} visible samples.`,
480
+ })
481
+ }
482
+ }
483
+ }
484
+
485
+ // Shadow declarations are honored only when an evaluated light, a caster and a receiver all participate.
486
+ const visibleAtSomeSample = new Set(scene.entities
487
+ .filter(entity => samplesByEntity.get(entity.entityId)!.some(sample => sample.visible && sample.note !== "other-camera"))
488
+ .map(entity => entity.entityId))
489
+ const shadowLights = scene.entities.filter((entity): entity is Extract<SpatialEntity, { kind: "light" }> => entity.kind === "light" && entity.shadow === true && visibleAtSomeSample.has(entity.entityId))
490
+ const shadowMeshes = scene.entities.filter((entity): entity is Extract<SpatialEntity, { kind: "mesh" }> => entity.kind === "mesh" && (entity.castShadow === true || entity.receiveShadow === true))
491
+ if (shadowLights.length === 0) {
492
+ for (const entity of shadowMeshes) {
493
+ findings.push({
494
+ severity: "warning", kind: "shadows-disabled", entityId: entity.entityId,
495
+ detail: "Declares shadow participation, but no evaluated light enables shadow casting; the renderer leaves shadow maps disabled.",
496
+ })
497
+ }
498
+ } else {
499
+ const casters = shadowMeshes.filter(entity => entity.castShadow === true)
500
+ const receivers = shadowMeshes.filter(entity => entity.receiveShadow === true)
501
+ for (const light of shadowLights) {
502
+ if (casters.length === 0 || receivers.length === 0) {
503
+ findings.push({
504
+ severity: "warning", kind: "shadows-disabled", entityId: light.entityId,
505
+ detail: casters.length === 0 && receivers.length === 0
506
+ ? "Enables shadow casting, but no mesh declares castShadow or receiveShadow; the shadow map renders no geometry."
507
+ : casters.length === 0
508
+ ? "Enables shadow casting, but no mesh declares castShadow; nothing writes into the shadow map."
509
+ : "Enables shadow casting, but no mesh declares receiveShadow; shadows have no receiving surface.",
510
+ })
511
+ }
512
+ }
513
+ }
514
+
515
+ const boundedVisible = scene.entities.filter(entity => enclosures.get(entity.entityId)!.status === "bounded"
516
+ && samplesByEntity.get(entity.entityId)!.some(sample => sample.visible && sample.note !== "other-camera")).length
517
+ const everInFrustum = scene.entities.some(entity =>
518
+ samplesByEntity.get(entity.entityId)!.some(sample =>
519
+ sample.visible && (sample.frustum?.contained === "full" || sample.frustum?.contained === "partial")))
520
+ if (!everInFrustum) {
521
+ const unknownCount = scene.entities.length - [...enclosures.values()].filter(item => item.status === "bounded").length
522
+ findings.push({
523
+ severity: "warning", kind: "empty-scene-region",
524
+ detail: `No visible bounded entity intersects the camera frustum at any sampled time (${String(boundedVisible)} bounded visible, ${String(unknownCount)} with unknown bounds).`,
525
+ })
526
+ }
527
+
528
+ const sortedFindings = sortSpatialBy(findings, findingOrder)
529
+ const retainedFindings = sortedFindings.slice(0, SPATIAL_AUDIT_LIMITS.findings)
530
+ const byKind = Object.fromEntries(ENTITY_KINDS.map(kind => [kind, 0])) as Record<(typeof ENTITY_KINDS)[number], number>
531
+ for (const entity of scene.entities) byKind[entity.kind]++
532
+ const properties = { position: 0, rotation: 0, scale: 0, opacity: 0 }
533
+ for (const channel of scene.animations) properties[channel.property]++
534
+ const report = {
535
+ kind: "slopcamera.spatial-audit" as const, schemaVersion: 1 as const,
536
+ sceneId: scene.sceneId, sceneSha256: context.sceneSha256, cameraId,
537
+ durationUs: scene.durationUs, timesUs,
538
+ summary: {
539
+ entities: {
540
+ total: scene.entities.length,
541
+ bounded: [...enclosures.values()].filter(item => item.status === "bounded").length,
542
+ unknownBounds: [...enclosures.values()].filter(item => item.status === "unknown").length,
543
+ byKind,
544
+ instances: scene.entities.reduce((total, entity) => total + (entity.kind === "mesh" && entity.instances !== undefined ? entity.instances.length : 0), 0),
545
+ },
546
+ animations: {
547
+ channels: scene.animations.length,
548
+ targets: new Set(scene.animations.map(channel => channel.targetId)).size,
549
+ properties,
550
+ },
551
+ cameras: scene.cameras.map(camera => camera.cameraId),
552
+ entitiesNeverVisible: sortSpatialBy(neverVisible, id => id),
553
+ entitiesNeverInFrustum: sortSpatialBy(neverInFrustum, id => id),
554
+ },
555
+ entities: auditedEntities,
556
+ findings: retainedFindings,
557
+ omittedFindings: sortedFindings.length - retainedFindings.length,
558
+ }
559
+ const parsed: SpatialAuditReport = SpatialAuditReportSchema.parse(report)
560
+ try {
561
+ createBoundedJsonValueSnapshot(parsed, SPATIAL_AUDIT_LIMITS.reportBytes, "audit report", {
562
+ maximumDepth: SPATIAL_SCENE_LIMITS.sourceDepth + 8,
563
+ maximumValues: SPATIAL_SCENE_LIMITS.sourceValues + SPATIAL_AUDIT_LIMITS.entitySamples * 16,
564
+ })
565
+ } catch (error) {
566
+ throw new SpatialSceneError("invalid-data", error instanceof Error ? error.message : "Audit report exceeds its bounded size.", "audit")
567
+ }
568
+ return deepFreezeJson(parsed)
569
+ }
570
+
571
+ const SpatialAuditAssetBoundsMapSchema = z.record(SpatialAssetIdSchema, SpatialAuditBoundsSchema)
572
+ /** A facts payload carries no assetId; the subject manifest supplies it. */
573
+ const SpatialAuditAssetFactsPairSchema = z.strictObject({ manifest: SpatialAssetManifestSchema, facts: SpatialAssetFactsV1Schema })
574
+
575
+ function auditSubjectBounds(manifest: SpatialAssetManifest, facts: SpatialAssetFactsV1): Record<string, unknown> {
576
+ if (facts.subject.sha256 !== manifest.payload.sha256 || facts.subject.bytes !== manifest.payload.bytes) {
577
+ throw new SpatialSceneError("invalid-data", `Asset facts for ${manifest.assetId} do not describe the manifest payload.`, "assetBounds")
578
+ }
579
+ return { [manifest.assetId]: facts.bounds.sceneSpace }
580
+ }
581
+
582
+ /** One document contributes assetId → scene-space bounds; model-space bounds never substitute. */
583
+ function auditBoundsRecord(document: unknown): Record<string, unknown> {
584
+ if (typeof document === "object" && document !== null && !Array.isArray(document)) {
585
+ const kind = (document as { readonly kind?: unknown }).kind
586
+ if (kind === "slopcamera.spatial-asset-admission") {
587
+ const admission = parseSpatialValue(SpatialAssetAdmissionV1Schema, document, "asset admission")
588
+ return auditSubjectBounds(admission.manifest, admission.facts)
589
+ }
590
+ if (kind === "slopcamera.spatial-asset-facts") {
591
+ throw new SpatialSceneError("invalid-data", "A slopcamera.spatial-asset-facts payload carries no assetId; pass its slopcamera.spatial-asset-admission document or a {manifest, facts} pair.", "assetBounds")
592
+ }
593
+ const pair = SpatialAuditAssetFactsPairSchema.safeParse(document)
594
+ if (pair.success) return auditSubjectBounds(pair.data.manifest, pair.data.facts)
595
+ if (SpatialAuditAssetBoundsMapSchema.safeParse(document).success) return document as Record<string, unknown>
596
+ }
597
+ throw new SpatialSceneError("invalid-data", "Asset bounds input accepts a Record<assetId, {min, max}> bounds map, a slopcamera.spatial-asset-admission document, a {manifest, facts} pair, or an array of those documents.", "assetBounds")
598
+ }
599
+
600
+ /**
601
+ * Audit `assetBounds` accepts the raw bounds map or the documents produced by
602
+ * `scene asset admit`; each contributes scene-space bounds keyed by the
603
+ * subject assetId. Identical repeats dedupe; differing bounds conflict.
604
+ */
605
+ export function normalizeSpatialAuditAssetBounds(input: unknown): Record<string, Bounds> {
606
+ const merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>
607
+ for (const document of Array.isArray(input) ? input : [input]) {
608
+ for (const [assetId, bounds] of Object.entries(auditBoundsRecord(document))) {
609
+ const existing = merged[assetId]
610
+ if (existing !== undefined && canonicalJson(existing) !== canonicalJson(bounds)) {
611
+ throw new SpatialSceneError("conflict", `Asset bounds input supplies conflicting scene-space bounds for ${assetId}.`, "assetBounds")
612
+ }
613
+ merged[assetId] = bounds
614
+ }
615
+ }
616
+ return parseSpatialValue(SpatialAuditAssetBoundsMapSchema, merged, "asset bounds")
617
+ }