@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,366 @@
1
+ import { z } from "zod"
2
+ import { deepFreezeJson } from "../code/json-snapshot.js"
3
+ import { align, facing, groundSnap, nextTo, onTopOf, type SpatialAxis, type SpatialLayoutEntry } from "./build.js"
4
+ import {
5
+ SpatialEntityIdSchema, SpatialQuaternionSchema, SpatialScenePatchV1Schema, SpatialTransformSchema,
6
+ SpatialVec3Schema, type SpatialScenePatchV1, type SpatialTransform,
7
+ } from "./contracts.js"
8
+ import { parseSpatialValue, SpatialSceneError } from "./identity.js"
9
+ import { composeTransform, transformBounds, type Bounds, type Quaternion, type Transform, type Vec3 } from "./math.js"
10
+
11
+ /**
12
+ * Declarative relation goals solved into concrete transforms plus a
13
+ * ready-to-apply patch document. The solver composes the phase-1 placement
14
+ * primitives (`onTopOf`, `nextTo`, `facing`, `align`, `groundSnap`) over a
15
+ * dependency DAG: a goal whose relations name another goal entity runs after
16
+ * it, so targets see solved transforms. Within one goal, relations apply in
17
+ * declared order and each sees earlier results.
18
+ *
19
+ * Relation contract:
20
+ * - `onTopOf`/`nextTo`/`align`/`groundSnap` need local bounds on every
21
+ * participating entity; a missing enclosure fails `bounds-unknown` naming
22
+ * that entity. `align` moves the goal entity's `edge` (min | center | max)
23
+ * on `axis` onto the target's same edge; it is expressed through `align`
24
+ * on the pair, transferring the anchor's would-be delta back to the mover
25
+ * so the named target never moves.
26
+ * - `facing` rotates the goal entity so local -Z looks at the target's
27
+ * transform position (camera poses arrive as bases the same way). `at`
28
+ * assigns absolute transform components. Neither needs bounds.
29
+ *
30
+ * Determinism: identical input yields byte-identical output. The emitted
31
+ * patch carries `SPATIAL_SOLVE_PENDING_SCENE_SHA256` in place of the scene
32
+ * digest; the caller fills it (e.g. from `scene inspect`) before `scene
33
+ * patch`, keeping solve output stable across scene revisions.
34
+ */
35
+
36
+ export const SPATIAL_SOLVE_LIMITS = Object.freeze({
37
+ goals: 64,
38
+ relationsPerGoal: 8,
39
+ bases: 1_024,
40
+ })
41
+
42
+ /** Emitted patch digest slot: parses as a digest and conflicts honestly on apply until the caller fills it. */
43
+ export const SPATIAL_SOLVE_PENDING_SCENE_SHA256 = "0".repeat(64)
44
+
45
+ export type SpatialSolveErrorCode =
46
+ | "invalid-data"
47
+ | "unknown-entity"
48
+ | "bounds-unknown"
49
+ | "relation-cycle"
50
+ | "duplicate-goal"
51
+ | "relation-failed"
52
+
53
+ /** Typed solver failures; `path` addresses the offending goal, relation or base entry. */
54
+ export class SpatialSolveError extends Error {
55
+ readonly code: SpatialSolveErrorCode
56
+ readonly path: string
57
+ constructor(code: SpatialSolveErrorCode, message: string, path = "solve") {
58
+ super(message)
59
+ this.name = "SpatialSolveError"
60
+ this.code = code
61
+ this.path = path
62
+ }
63
+ }
64
+
65
+ /** Goal entities must be scene entity IDs so emitted `set-transform` operations stay schema-valid. */
66
+ export const SpatialSolveEntityKeySchema = SpatialEntityIdSchema
67
+ /** Anchor keys also cover cameras and caller-declared virtual bases, so only the shared id charset applies. */
68
+ export const SpatialSolveAnchorKeySchema = z.string().min(1).max(128)
69
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/u, "Solve keys start alphanumeric and may contain _ or -.")
70
+
71
+ const axisSchema = z.enum(["x", "y", "z"])
72
+ const boundedNumber = z.number().finite().min(-1_000_000).max(1_000_000)
73
+ const positiveDimension = z.number().finite().min(0.000001).max(1_000_000)
74
+ const scaleTuple = z.tuple([positiveDimension, positiveDimension, positiveDimension])
75
+
76
+ export const SpatialSolveBoundsSchema = z.strictObject({ min: SpatialVec3Schema, max: SpatialVec3Schema })
77
+ .refine(bounds => bounds.min.every((value, index) => value <= bounds.max[index]!), "Bounds min must not exceed max.")
78
+
79
+ export const SpatialSolveRelationSchema = z.discriminatedUnion("kind", [
80
+ z.strictObject({ kind: z.literal("onTopOf"), target: SpatialSolveAnchorKeySchema }),
81
+ z.strictObject({
82
+ kind: z.literal("nextTo"), target: SpatialSolveAnchorKeySchema,
83
+ axis: axisSchema.optional(), side: z.enum(["before", "after"]).optional(), gap: boundedNumber.optional(),
84
+ }),
85
+ z.strictObject({ kind: z.literal("facing"), target: SpatialSolveAnchorKeySchema, up: SpatialVec3Schema.optional() }),
86
+ z.strictObject({
87
+ kind: z.literal("align"), target: SpatialSolveAnchorKeySchema,
88
+ axis: axisSchema, edge: z.enum(["min", "center", "max"]).optional(),
89
+ }),
90
+ z.strictObject({
91
+ kind: z.literal("at"),
92
+ position: SpatialVec3Schema.optional(), rotation: SpatialQuaternionSchema.optional(), scale: scaleTuple.optional(),
93
+ }).refine(
94
+ relation => relation.position !== undefined || relation.rotation !== undefined || relation.scale !== undefined,
95
+ "An at relation sets at least one of position, rotation, or scale.",
96
+ ),
97
+ z.strictObject({ kind: z.literal("groundSnap"), floorY: boundedNumber.optional() }),
98
+ ])
99
+
100
+ export const SpatialSolveGoalSchema = z.strictObject({
101
+ entityKey: SpatialSolveEntityKeySchema,
102
+ relations: z.array(SpatialSolveRelationSchema).min(1).max(SPATIAL_SOLVE_LIMITS.relationsPerGoal),
103
+ })
104
+
105
+ export const SpatialSolveBaseSchema = z.strictObject({
106
+ transform: SpatialTransformSchema,
107
+ bounds: SpatialSolveBoundsSchema.optional(),
108
+ })
109
+
110
+ export const SpatialSolveRequestSchema = z.strictObject({
111
+ goals: z.array(SpatialSolveGoalSchema).min(1).max(SPATIAL_SOLVE_LIMITS.goals),
112
+ bases: z.record(SpatialSolveAnchorKeySchema, SpatialSolveBaseSchema),
113
+ })
114
+
115
+ /** Goals-file variant: base entries may supply only the fields the scene cannot derive. */
116
+ export const SpatialSolveBasePatchSchema = z.strictObject({
117
+ transform: SpatialTransformSchema.optional(),
118
+ bounds: SpatialSolveBoundsSchema.optional(),
119
+ })
120
+ export const SpatialSolveGoalsFileSchema = z.strictObject({
121
+ goals: z.array(SpatialSolveGoalSchema).min(1).max(SPATIAL_SOLVE_LIMITS.goals),
122
+ bases: z.record(SpatialSolveAnchorKeySchema, SpatialSolveBasePatchSchema).optional(),
123
+ })
124
+
125
+ /**
126
+ * Structural interfaces mirror the schemas rather than inferring from them —
127
+ * recursive readonly mappers over the nested strict-object graph measurably
128
+ * slow authored-source typechecks. `solveSpatialRelations` parses foreign
129
+ * input through the schemas, so any drift fails compilation at the parse site.
130
+ */
131
+ export type SpatialSolveRelation =
132
+ | { readonly kind: "onTopOf"; readonly target: string }
133
+ | {
134
+ readonly kind: "nextTo"; readonly target: string
135
+ readonly axis?: SpatialAxis | undefined; readonly side?: "before" | "after" | undefined; readonly gap?: number | undefined
136
+ }
137
+ | { readonly kind: "facing"; readonly target: string; readonly up?: Vec3 | undefined }
138
+ | { readonly kind: "align"; readonly target: string; readonly axis: SpatialAxis; readonly edge?: "min" | "center" | "max" | undefined }
139
+ | { readonly kind: "at"; readonly position?: Vec3 | undefined; readonly rotation?: Quaternion | undefined; readonly scale?: Vec3 | undefined }
140
+ | { readonly kind: "groundSnap"; readonly floorY?: number | undefined }
141
+
142
+ export interface SpatialSolveGoal {
143
+ readonly entityKey: string
144
+ readonly relations: readonly SpatialSolveRelation[]
145
+ }
146
+ export interface SpatialSolveBase {
147
+ readonly transform: Transform
148
+ readonly bounds?: Bounds | undefined
149
+ }
150
+ export interface SpatialSolveRequest {
151
+ readonly goals: readonly SpatialSolveGoal[]
152
+ readonly bases: Readonly<Record<string, SpatialSolveBase>>
153
+ }
154
+ export interface SpatialSolveBasePatch {
155
+ readonly transform?: Transform | undefined
156
+ readonly bounds?: Bounds | undefined
157
+ }
158
+ export interface SpatialSolveGoalsFile {
159
+ readonly goals: readonly SpatialSolveGoal[]
160
+ readonly bases?: Readonly<Record<string, SpatialSolveBasePatch>> | undefined
161
+ }
162
+ export interface SpatialSolveResult {
163
+ /** Solved transforms keyed by goal entity, in declared goal order. */
164
+ readonly transforms: Readonly<Record<string, SpatialTransform>>
165
+ readonly patch: SpatialScenePatchV1
166
+ }
167
+
168
+ interface SolveState {
169
+ transform: Transform
170
+ readonly bounds: Bounds | undefined
171
+ }
172
+
173
+ function parseSolveRequest(input: unknown): SpatialSolveRequest {
174
+ let parsed: z.infer<typeof SpatialSolveRequestSchema>
175
+ try {
176
+ parsed = parseSpatialValue(SpatialSolveRequestSchema, input, "solve request")
177
+ } catch (error) {
178
+ if (error instanceof SpatialSceneError) throw new SpatialSolveError("invalid-data", error.message, error.path)
179
+ throw error
180
+ }
181
+ if (Object.keys(parsed.bases).length > SPATIAL_SOLVE_LIMITS.bases) {
182
+ throw new SpatialSolveError("invalid-data", `Solve bases are bounded to ${SPATIAL_SOLVE_LIMITS.bases} entries.`, "bases")
183
+ }
184
+ const request: SpatialSolveRequest = parsed
185
+ return request
186
+ }
187
+
188
+ /**
189
+ * Deterministic topological order preferring declared goal order. Edges point
190
+ * from a goal to each goal entity its relations target; bases are static
191
+ * anchors. Leftover goals form cycles — one concrete cycle is named.
192
+ */
193
+ function orderGoals(goals: readonly SpatialSolveGoal[], dependencies: ReadonlyMap<string, ReadonlySet<string>>): readonly SpatialSolveGoal[] {
194
+ const remaining = new Map(goals.map(goal => [goal.entityKey, goal]))
195
+ const ordered: SpatialSolveGoal[] = []
196
+ for (;;) {
197
+ const ready = goals.find(goal => {
198
+ if (!remaining.has(goal.entityKey)) return false
199
+ for (const dependency of dependencies.get(goal.entityKey)!) if (remaining.has(dependency)) return false
200
+ return true
201
+ })
202
+ if (ready === undefined) break
203
+ remaining.delete(ready.entityKey)
204
+ ordered.push(ready)
205
+ }
206
+ if (remaining.size === 0) return ordered
207
+ // Extract one concrete cycle over the leftover dependency subgraph.
208
+ const stack: string[] = []
209
+ const seen = new Set<string>()
210
+ let current: string | undefined = goals.find(goal => remaining.has(goal.entityKey))!.entityKey
211
+ while (current !== undefined && remaining.has(current) && !seen.has(current)) {
212
+ seen.add(current)
213
+ stack.push(current)
214
+ current = [...dependencies.get(current)!].find(dependency => remaining.has(dependency))
215
+ }
216
+ const from = current === undefined ? 0 : stack.indexOf(current)
217
+ const cycle = stack.slice(from === -1 ? 0 : from)
218
+ if (current !== undefined) cycle.push(current)
219
+ throw new SpatialSolveError("relation-cycle", `Relation goals form a cycle: ${cycle.join(" → ")}.`, "goals")
220
+ }
221
+
222
+ function requireState(states: ReadonlyMap<string, SolveState>, key: string, path: string): SolveState {
223
+ const state = states.get(key)
224
+ if (state === undefined) {
225
+ throw new SpatialSolveError("unknown-entity", `Relation target ${key} has no base and is not a solve goal.`, path)
226
+ }
227
+ return state
228
+ }
229
+
230
+ function requireBounds(state: SolveState, key: string, kind: string, path: string): Bounds {
231
+ if (state.bounds === undefined) {
232
+ throw new SpatialSolveError("bounds-unknown", `Entity ${key} has no bounds; ${kind} needs local bounds on every participant.`, path)
233
+ }
234
+ return state.bounds
235
+ }
236
+
237
+ function emitTransform(transform: Transform, path: string): SpatialTransform {
238
+ try {
239
+ return parseSpatialValue(SpatialTransformSchema, {
240
+ position: [...transform.position], rotation: [...transform.rotation], scale: [...transform.scale],
241
+ }, "solved transform")
242
+ } catch (error) {
243
+ if (error instanceof SpatialSceneError) throw new SpatialSolveError("relation-failed", error.message, path)
244
+ throw error
245
+ }
246
+ }
247
+
248
+ function applyRelation(
249
+ relation: SpatialSolveRelation, entityKey: string, mover: SolveState,
250
+ states: ReadonlyMap<string, SolveState>, path: string,
251
+ ): Transform {
252
+ try {
253
+ switch (relation.kind) {
254
+ case "onTopOf": {
255
+ const target = requireState(states, relation.target, path)
256
+ return onTopOf(
257
+ requireBounds(mover, entityKey, relation.kind, path), mover.transform,
258
+ requireBounds(target, relation.target, relation.kind, path), target.transform,
259
+ )
260
+ }
261
+ case "nextTo": {
262
+ const target = requireState(states, relation.target, path)
263
+ return nextTo(
264
+ requireBounds(mover, entityKey, relation.kind, path), mover.transform,
265
+ requireBounds(target, relation.target, relation.kind, path), target.transform,
266
+ {
267
+ ...(relation.axis === undefined ? {} : { axis: relation.axis }),
268
+ ...(relation.side === undefined ? {} : { side: relation.side }),
269
+ ...(relation.gap === undefined ? {} : { gap: relation.gap }),
270
+ },
271
+ )
272
+ }
273
+ case "facing": {
274
+ const target = requireState(states, relation.target, path)
275
+ return facing(mover.transform, target.transform.position, relation.up ?? [0, 1, 0])
276
+ }
277
+ case "align": {
278
+ const target = requireState(states, relation.target, path)
279
+ const moverBounds = requireBounds(mover, entityKey, relation.kind, path)
280
+ const targetBounds = requireBounds(target, relation.target, relation.kind, path)
281
+ const entries: SpatialLayoutEntry[] = [
282
+ { entityId: entityKey, transform: mover.transform, bounds: moverBounds },
283
+ { entityId: relation.target, transform: target.transform, bounds: targetBounds },
284
+ ]
285
+ const [moved, anchorMoved] = align(entries, relation.axis, relation.edge ?? "center")
286
+ // Transfer the anchor's would-be delta back to the mover: the result
287
+ // places the mover's edge exactly on the anchor's edge while the named
288
+ // target never moves. Off-axis components of the correction are zero.
289
+ const correction = target.transform.position.map((value, index) => value - anchorMoved!.position[index]!)
290
+ return emitTransform({
291
+ position: moved!.position.map((value, index) => value + correction[index]!) as unknown as Vec3,
292
+ rotation: moved!.rotation, scale: moved!.scale,
293
+ }, path)
294
+ }
295
+ case "at":
296
+ return emitTransform({
297
+ position: relation.position ?? mover.transform.position,
298
+ rotation: relation.rotation ?? mover.transform.rotation,
299
+ scale: relation.scale ?? mover.transform.scale,
300
+ }, path)
301
+ case "groundSnap": {
302
+ const bounds = requireBounds(mover, entityKey, relation.kind, path)
303
+ const world = transformBounds(composeTransform(mover.transform), bounds)
304
+ return groundSnap(mover.transform, mover.transform.position[1] - world.min[1], relation.floorY ?? 0)
305
+ }
306
+ }
307
+ } catch (error) {
308
+ if (error instanceof SpatialSolveError) throw error
309
+ const message = error instanceof Error ? error.message : String(error)
310
+ throw new SpatialSolveError("relation-failed", `${relation.kind} failed: ${message}`, path)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Resolves declarative relation goals into transforms and a patch document.
316
+ * The patch's `expectedSceneSha256` is `SPATIAL_SOLVE_PENDING_SCENE_SHA256`;
317
+ * the caller replaces it with the scene's actual digest before `scene patch`.
318
+ */
319
+ export function solveSpatialRelations(input: unknown): SpatialSolveResult {
320
+ const request = parseSolveRequest(input)
321
+ const goalKeys = new Set<string>()
322
+ for (const [index, goal] of request.goals.entries()) {
323
+ if (goalKeys.has(goal.entityKey)) {
324
+ throw new SpatialSolveError("duplicate-goal", `Duplicate solve goal for ${goal.entityKey}.`, `goals[${index}]`)
325
+ }
326
+ goalKeys.add(goal.entityKey)
327
+ }
328
+ const states = new Map<string, SolveState>()
329
+ for (const [key, base] of Object.entries(request.bases)) {
330
+ states.set(key, { transform: base.transform, bounds: base.bounds })
331
+ }
332
+ const dependencies = new Map<string, ReadonlySet<string>>()
333
+ for (const [index, goal] of request.goals.entries()) {
334
+ if (!states.has(goal.entityKey)) {
335
+ throw new SpatialSolveError("unknown-entity", `Goal entity ${goal.entityKey} has no base transform; add bases.${goal.entityKey}.`, `goals[${index}]`)
336
+ }
337
+ const required = new Set<string>()
338
+ for (const [relationIndex, relation] of goal.relations.entries()) {
339
+ if (!("target" in relation)) continue
340
+ const path = `goals[${index}].relations[${relationIndex}].target`
341
+ if (!states.has(relation.target) && !goalKeys.has(relation.target)) {
342
+ throw new SpatialSolveError("unknown-entity", `Relation target ${relation.target} has no base and is not a solve goal.`, path)
343
+ }
344
+ if (goalKeys.has(relation.target)) required.add(relation.target)
345
+ }
346
+ dependencies.set(goal.entityKey, required)
347
+ }
348
+ for (const goal of orderGoals(request.goals, dependencies)) {
349
+ const state = states.get(goal.entityKey)!
350
+ const index = request.goals.indexOf(goal)
351
+ for (const [relationIndex, relation] of goal.relations.entries()) {
352
+ state.transform = applyRelation(relation, goal.entityKey, state, states, `goals[${index}].relations[${relationIndex}]`)
353
+ }
354
+ }
355
+ const transforms: Record<string, SpatialTransform> = {}
356
+ for (const goal of request.goals) {
357
+ transforms[goal.entityKey] = emitTransform(states.get(goal.entityKey)!.transform, `transforms.${goal.entityKey}`)
358
+ }
359
+ const patch = parseSpatialValue(SpatialScenePatchV1Schema, {
360
+ kind: "slopcamera.spatial-scene-patch",
361
+ schemaVersion: 1,
362
+ expectedSceneSha256: SPATIAL_SOLVE_PENDING_SCENE_SHA256,
363
+ operations: request.goals.map(goal => ({ kind: "set-transform", entityId: goal.entityKey, transform: transforms[goal.entityKey] })),
364
+ }, "solve patch")
365
+ return deepFreezeJson({ transforms, patch })
366
+ }