@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.
- package/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +65 -20
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +6 -9
- package/apps/desktop/application/default-registry.ts +26 -10
- package/apps/desktop/application/operation.ts +14 -5
- package/apps/desktop/application/operations/index.ts +4 -1
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +528 -97
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +31 -2
- package/apps/desktop/cli/commands.ts +1215 -70
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +183 -23
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +26 -50
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +18 -5
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +24 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +737 -291
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +32 -434
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -240
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +1 -1
- package/package.json +11 -24
- package/skills/slopcamera/SKILL.md +14 -1
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +250 -102
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -23
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-mcy8z0br.js +0 -192
- package/dist/index-p63wavx0.js +0 -2029
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-rvghzt7b.js +0 -2013
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-z7239b4h.js +0 -2363
- package/dist/skill-install-dh0ntqba.js +0 -10
- 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
|
+
}
|