@hraness/slopcamera 3.2.8 → 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 (150) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-cgpkyjqa.js +3 -0
  59. package/dist/index-f0ptv8dm.js +7 -0
  60. package/dist/index-grk47tgs.js +3 -0
  61. package/dist/index-qry58nj2.js +10 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/package.json +7 -5
  69. package/skills/slopcamera/SKILL.md +6 -1
  70. package/skills/slopcamera/references/directed-scenes.md +22 -2
  71. package/skills/slopcamera/references/gateway-media.md +15 -1
  72. package/skills/slopcamera/references/image-galleries.md +166 -0
  73. package/skills/slopcamera/references/install.md +1 -1
  74. package/skills/slopcamera/references/parametric-design.md +14 -0
  75. package/skills/slopcamera/references/scene-building.md +32 -0
  76. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  77. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  78. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  79. package/src/capability-manifest.ts +361 -0
  80. package/src/cli.ts +222 -4
  81. package/src/code/canonical-json.ts +9 -0
  82. package/src/generate.ts +2 -2
  83. package/src/icon.ts +1111 -0
  84. package/src/image-gallery.ts +1049 -0
  85. package/src/index.ts +11 -0
  86. package/src/mcp/boundary.ts +11 -11
  87. package/src/mcp/index.ts +3 -0
  88. package/src/mcp/server.ts +2 -1
  89. package/src/mcp/tools.ts +1642 -130
  90. package/src/mcp/types.ts +19 -0
  91. package/src/operations.ts +412 -0
  92. package/src/portable-capability-manifest.ts +67 -0
  93. package/src/scene-gallery.ts +184 -0
  94. package/src/spatial-scene/asset-admission.ts +183 -0
  95. package/src/spatial-scene/audit-rendered.ts +692 -0
  96. package/src/spatial-scene/audit.ts +617 -0
  97. package/src/spatial-scene/behavior-audit.ts +252 -0
  98. package/src/spatial-scene/behavior-bake.ts +233 -0
  99. package/src/spatial-scene/behavior-fns.ts +585 -0
  100. package/src/spatial-scene/behavior-gallery.ts +109 -0
  101. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  102. package/src/spatial-scene/behavior-trace.ts +325 -0
  103. package/src/spatial-scene/behavior.ts +618 -0
  104. package/src/spatial-scene/build.ts +632 -0
  105. package/src/spatial-scene/camera-rig.ts +125 -0
  106. package/src/spatial-scene/camera-track.ts +4 -2
  107. package/src/spatial-scene/character.ts +124 -0
  108. package/src/spatial-scene/contracts.ts +75 -7
  109. package/src/spatial-scene/design-templates.ts +351 -0
  110. package/src/spatial-scene/design.ts +327 -0
  111. package/src/spatial-scene/direction-compile.ts +584 -0
  112. package/src/spatial-scene/direction.ts +122 -0
  113. package/src/spatial-scene/effects.ts +149 -0
  114. package/src/spatial-scene/evaluate.ts +109 -29
  115. package/src/spatial-scene/gallery.ts +277 -0
  116. package/src/spatial-scene/generate.ts +239 -0
  117. package/src/spatial-scene/geometry-native.ts +165 -0
  118. package/src/spatial-scene/geometry.ts +1216 -0
  119. package/src/spatial-scene/gltf.ts +600 -77
  120. package/src/spatial-scene/identity.ts +55 -2
  121. package/src/spatial-scene/index.ts +36 -0
  122. package/src/spatial-scene/inspect.ts +35 -6
  123. package/src/spatial-scene/material-lighting.ts +609 -0
  124. package/src/spatial-scene/math.ts +21 -4
  125. package/src/spatial-scene/motion-evidence.ts +88 -0
  126. package/src/spatial-scene/parametric.ts +765 -0
  127. package/src/spatial-scene/particle-preparation.ts +183 -0
  128. package/src/spatial-scene/particle.ts +195 -0
  129. package/src/spatial-scene/patch.ts +109 -15
  130. package/src/spatial-scene/performance.ts +1613 -0
  131. package/src/spatial-scene/probe.ts +137 -0
  132. package/src/spatial-scene/recipe-pack.ts +86 -0
  133. package/src/spatial-scene/render-effects.ts +240 -0
  134. package/src/spatial-scene/review.ts +639 -0
  135. package/src/spatial-scene/simulation-bake.ts +206 -0
  136. package/src/spatial-scene/simulation.ts +187 -0
  137. package/src/spatial-scene/solve.ts +366 -0
  138. package/src/spatial-scene/temporal-audit.ts +354 -0
  139. package/src/version.ts +1 -1
  140. package/dist/index-42zsesc1.js +0 -2257
  141. package/dist/index-7131pg1c.js +0 -1820
  142. package/dist/index-7308egqr.js +0 -48
  143. package/dist/index-8txs6fkn.js +0 -712
  144. package/dist/index-fava6pge.js +0 -192
  145. package/dist/index-h1k0fnjq.js +0 -2363
  146. package/dist/index-r7gdhmsp.js +0 -424
  147. package/dist/index-sh6xbav6.js +0 -1219
  148. package/dist/index-z1w83f81.js +0 -4
  149. package/dist/index-zfnddgay.js +0 -2029
  150. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -0,0 +1,632 @@
1
+ import { z } from "zod"
2
+ import { deepFreezeJson } from "../code/json-snapshot.js"
3
+ import {
4
+ SPATIAL_SCENE_LIMITS, SpatialAnimationSchema, SpatialEntitySchema, SpatialPoseSchema, SpatialProjectionSchema, SpatialQuaternionSchema,
5
+ SpatialTimeUsSchema, SpatialTransformSchema, SpatialVec3Schema,
6
+ type SpatialAnimation, type SpatialEntity, type SpatialGeometry, type SpatialMaterial, type SpatialPlacement, type SpatialPose, type SpatialProjection, type SpatialTransform,
7
+ } from "./contracts.js"
8
+ import { parseSpatialValue } from "./identity.js"
9
+ import {
10
+ composeTransform, normalizeQuaternion, slerpQuaternion, transformBounds,
11
+ type Bounds, type Quaternion, type Transform, type Vec3,
12
+ } from "./math.js"
13
+
14
+ /**
15
+ * Pure spatial authoring helpers. Every emitted value is parsed through the v1
16
+ * contract schemas before it is returned; nothing here reads files, executes
17
+ * authored source, or touches a renderer. Conventions: right-handed Y-up meters,
18
+ * camera-local -Z forward, XYZW unit quaternions, integer-microsecond times.
19
+ * `perspectiveFromFov` treats fovDeg as the HORIZONTAL field of view and derives
20
+ * square-pixel fx = fy = (width/2) / tan(fov/2); the vertical field of view
21
+ * follows from the aspect ratio. `easeKeys` defaults to ~6 segments per second
22
+ * clamped to [16, 64] segments (25 baked keys on a 4 s span). Measured maximum
23
+ * eased-parameter error vs the true curve on a 4 s ease-in-out/ease-out span:
24
+ * 16 keys 0.32%, 25 keys 0.13%, 33 keys 0.07%, 65 keys 0.02%; 9 keys already
25
+ * fails the ~1% bar at 1.10%, so 25 keys is the smallest in-band default.
26
+ */
27
+
28
+ export type SpatialAxis = "x" | "y" | "z"
29
+ export type SpatialEasing = "linear" | "ease-in" | "ease-out" | "ease-in-out"
30
+ export interface SpatialBakedKey<Value> { readonly timeUs: number; readonly value: Value }
31
+ export interface SpatialLayoutEntry {
32
+ readonly entityId: string
33
+ readonly transform: Transform
34
+ readonly bounds?: Bounds
35
+ }
36
+
37
+ const AXIS_INDEX: Record<SpatialAxis, 0 | 1 | 2> = { x: 0, y: 1, z: 2 }
38
+ const WORLD_UP: Vec3 = [0, 1, 0]
39
+ const DEG = Math.PI / 180
40
+ const SCATTER_MAX_ATTEMPTS = 128
41
+
42
+ const scalarKeySchema = z.strictObject({ timeUs: SpatialTimeUsSchema, value: z.number().finite().min(0).max(1) })
43
+ const vec3KeySchema = z.strictObject({ timeUs: SpatialTimeUsSchema, value: SpatialVec3Schema })
44
+ const quaternionKeySchema = z.strictObject({ timeUs: SpatialTimeUsSchema, value: SpatialQuaternionSchema })
45
+
46
+ function finite(value: number, label: string): number {
47
+ if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`)
48
+ return value
49
+ }
50
+
51
+ function vec3(value: Vec3, label: string): Vec3 {
52
+ if (!Array.isArray(value) || value.length !== 3) throw new RangeError(`${label} must have 3 components`)
53
+ for (let index = 0; index < 3; index++) finite(value[index]!, `${label}[${index}]`)
54
+ return value
55
+ }
56
+
57
+ function quaternion(value: Quaternion, label: string): Quaternion {
58
+ if (!Array.isArray(value) || value.length !== 4) throw new RangeError(`${label} must have 4 components`)
59
+ for (let index = 0; index < 4; index++) finite(value[index]!, `${label}[${index}]`)
60
+ return value
61
+ }
62
+
63
+ function timeUs(value: number, label: string): number {
64
+ if (!Number.isSafeInteger(value) || value < 0 || value > SPATIAL_SCENE_LIMITS.durationUs) {
65
+ throw new RangeError(`${label} must be an integer microsecond within [0, ${SPATIAL_SCENE_LIMITS.durationUs}]`)
66
+ }
67
+ return value
68
+ }
69
+
70
+ function segments(value: number | undefined, durationUs: number): number {
71
+ if (value === undefined) return Math.min(64, Math.max(16, Math.ceil(durationUs / 166_667)))
72
+ if (!Number.isSafeInteger(value) || value < 1 || value > SPATIAL_SCENE_LIMITS.keysPerChannel - 1) {
73
+ throw new RangeError(`segments must be an integer within [1, ${SPATIAL_SCENE_LIMITS.keysPerChannel - 1}]`)
74
+ }
75
+ return value
76
+ }
77
+
78
+ function bounds(value: Bounds, label: string): Bounds {
79
+ vec3(value.min, `${label}.min`); vec3(value.max, `${label}.max`)
80
+ if (value.min.some((part, index) => part > value.max[index]!)) throw new RangeError(`${label} requires min <= max`)
81
+ return value
82
+ }
83
+
84
+ function transform(value: Transform, label: string): Transform {
85
+ vec3(value.position, `${label}.position`); quaternion(value.rotation, `${label}.rotation`); vec3(value.scale, `${label}.scale`)
86
+ return value
87
+ }
88
+
89
+ /** Normalize, then clamp components into [-1, 1] for the contract's per-component bound. */
90
+ function unitQuaternion(value: Quaternion): Quaternion {
91
+ return normalizeQuaternion(value).map(component => Math.min(1, Math.max(-1, component))) as unknown as Quaternion
92
+ }
93
+
94
+ function emitTransform(input: Transform): SpatialTransform {
95
+ return deepFreezeJson(parseSpatialValue(SpatialTransformSchema, {
96
+ position: [...input.position], rotation: unitQuaternion(input.rotation), scale: [...input.scale],
97
+ }, "transform"))
98
+ }
99
+
100
+ function emitPose(position: Vec3, rotation: Quaternion): SpatialPose {
101
+ return deepFreezeJson(parseSpatialValue(SpatialPoseSchema, { position: [...position], rotation }, "pose"))
102
+ }
103
+
104
+ function subtract(a: Vec3, b: Vec3): Vec3 {
105
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
106
+ }
107
+
108
+ function cross(a: Vec3, b: Vec3): Vec3 {
109
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
110
+ }
111
+
112
+ /** Orthonormal basis (columns x, y, z) to a contract-safe unit quaternion. */
113
+ function quaternionFromBasis(xAxis: Vec3, yAxis: Vec3, zAxis: Vec3): Quaternion {
114
+ const [m00, m01, m02] = [xAxis[0], yAxis[0], zAxis[0]]
115
+ const [m10, m11, m12] = [xAxis[1], yAxis[1], zAxis[1]]
116
+ const [m20, m21, m22] = [xAxis[2], yAxis[2], zAxis[2]]
117
+ const trace = m00 + m11 + m22
118
+ let q: number[]
119
+ if (trace > 0) {
120
+ const s = Math.sqrt(trace + 1) * 2
121
+ q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4]
122
+ } else if (m00 > m11 && m00 > m22) {
123
+ const s = Math.sqrt(1 + m00 - m11 - m22) * 2
124
+ q = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s]
125
+ } else if (m11 > m22) {
126
+ const s = Math.sqrt(1 + m11 - m00 - m22) * 2
127
+ q = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s]
128
+ } else {
129
+ const s = Math.sqrt(1 + m22 - m00 - m11) * 2
130
+ q = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s]
131
+ }
132
+ return unitQuaternion(q as unknown as Quaternion)
133
+ }
134
+
135
+ /** Rotation whose local -Z points from position toward target; local +Y stays near `up`. */
136
+ function lookRotation(position: Vec3, target: Vec3, up: Vec3): Quaternion {
137
+ const back = subtract(position, target)
138
+ const backLength = Math.hypot(...back)
139
+ if (backLength === 0) throw new RangeError("position and target must differ")
140
+ const zAxis = back.map(component => component / backLength) as unknown as Vec3
141
+ const right = cross(up, zAxis)
142
+ const rightLength = Math.hypot(...right)
143
+ let xAxis: Vec3
144
+ if (rightLength < 1e-12) {
145
+ const candidates: Vec3[] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
146
+ const fallback = candidates.reduce((best, axis) =>
147
+ Math.abs(axis[0] * zAxis[0] + axis[1] * zAxis[1] + axis[2] * zAxis[2])
148
+ < Math.abs(best[0] * zAxis[0] + best[1] * zAxis[1] + best[2] * zAxis[2]) ? axis : best)
149
+ const retry = cross(fallback, zAxis)
150
+ const retryLength = Math.hypot(...retry)
151
+ xAxis = retry.map(component => component / retryLength) as unknown as Vec3
152
+ } else {
153
+ xAxis = right.map(component => component / rightLength) as unknown as Vec3
154
+ }
155
+ return quaternionFromBasis(xAxis, cross(zAxis, xAxis), zAxis)
156
+ }
157
+
158
+ const EASINGS: Record<SpatialEasing, (t: number) => number> = {
159
+ "linear": t => t,
160
+ "ease-in": t => t * t * t,
161
+ "ease-out": t => 1 - (1 - t) * (1 - t) * (1 - t),
162
+ "ease-in-out": t => t * t * (3 - 2 * t),
163
+ }
164
+
165
+ /** [time, t] pairs: t stays the exact span fraction even when rounded times collapse. */
166
+ function keyTimes(startUs: number, durationUs: number, spans: number): [number, number][] {
167
+ const times: [number, number][] = []
168
+ for (let index = 0; index <= spans; index++) {
169
+ const time = Math.round(startUs + (index / spans) * durationUs)
170
+ if (times.length === 0 || time > times[times.length - 1]![0]) times.push([time, index / spans])
171
+ }
172
+ return times
173
+ }
174
+
175
+ export function perspectiveFromFov(input: {
176
+ readonly fovDeg: number
177
+ readonly width: number
178
+ readonly height: number
179
+ readonly near: number
180
+ readonly far: number
181
+ readonly cx?: number
182
+ readonly cy?: number
183
+ }): SpatialProjection {
184
+ const fov = finite(input.fovDeg, "fovDeg")
185
+ if (fov <= 0 || fov >= 180) throw new RangeError("fovDeg must lie within (0, 180)")
186
+ finite(input.near, "near"); finite(input.far, "far")
187
+ const cx = input.cx ?? input.width / 2
188
+ const cy = input.cy ?? input.height / 2
189
+ const focal = input.width / 2 / Math.tan(fov / 2 * DEG)
190
+ return deepFreezeJson(parseSpatialValue(SpatialProjectionSchema, {
191
+ kind: "perspective", width: input.width, height: input.height,
192
+ near: input.near, far: input.far, fx: focal, fy: focal, cx, cy,
193
+ }, "projection"))
194
+ }
195
+
196
+ /** Camera pose looking down local -Z at target; default up is world +Y. */
197
+ export function lookAtPose(position: Vec3, target: Vec3, up: Vec3 = WORLD_UP): SpatialPose {
198
+ vec3(position, "position"); vec3(target, "target"); vec3(up, "up")
199
+ if (Math.hypot(...up) === 0) throw new RangeError("up must be nonzero")
200
+ return emitPose(position, lookRotation(position, target, up))
201
+ }
202
+
203
+ export interface SpatialEaseInput<Value> {
204
+ readonly from: Value
205
+ readonly to: Value
206
+ readonly durationUs: number
207
+ readonly startUs?: number
208
+ readonly easing: SpatialEasing
209
+ readonly segments?: number
210
+ }
211
+
212
+ export function easeKeys(input: SpatialEaseInput<number>): readonly SpatialBakedKey<number>[]
213
+ export function easeKeys(input: SpatialEaseInput<Vec3>): readonly SpatialBakedKey<Vec3>[]
214
+ export function easeKeys(input: SpatialEaseInput<Quaternion>): readonly SpatialBakedKey<Quaternion>[]
215
+ export function easeKeys(input: SpatialEaseInput<number | Vec3 | Quaternion>): readonly SpatialBakedKey<number | Vec3 | Quaternion>[] {
216
+ return bakeKeys(input)
217
+ }
218
+
219
+ function bakeKeys(input: SpatialEaseInput<number | Vec3 | Quaternion>): readonly SpatialBakedKey<number | Vec3 | Quaternion>[] {
220
+ const startUs = timeUs(input.startUs ?? 0, "startUs")
221
+ const duration = timeUs(input.durationUs, "durationUs")
222
+ if (duration === 0) throw new RangeError("durationUs must be positive")
223
+ timeUs(startUs + duration, "startUs + durationUs")
224
+ const easing = EASINGS[input.easing]
225
+ if (easing === undefined) throw new RangeError(`unsupported easing ${String(input.easing)}`)
226
+ const spans = segments(input.segments, duration)
227
+ const times = keyTimes(startUs, duration, spans)
228
+ const from = input.from, to = input.to
229
+ if (typeof from === "number" && typeof to === "number") {
230
+ finite(from, "from"); finite(to, "to")
231
+ return deepFreezeJson(times.map(([time, t]) => parseSpatialValue(scalarKeySchema, {
232
+ timeUs: time, value: t === 0 ? from : t === 1 ? to : from + (to - from) * easing(t),
233
+ }, "opacity key")))
234
+ }
235
+ if (Array.isArray(from) && Array.isArray(to) && from.length === 3 && to.length === 3) {
236
+ const a = vec3(from as Vec3, "from"), b = vec3(to as Vec3, "to")
237
+ return deepFreezeJson(times.map(([time, t]) => {
238
+ const eased = easing(t)
239
+ return parseSpatialValue(vec3KeySchema, {
240
+ timeUs: time,
241
+ value: t === 0 ? [...a] : t === 1 ? [...b]
242
+ : [a[0] + (b[0] - a[0]) * eased, a[1] + (b[1] - a[1]) * eased, a[2] + (b[2] - a[2]) * eased],
243
+ }, "vec3 key")
244
+ }))
245
+ }
246
+ if (Array.isArray(from) && Array.isArray(to) && from.length === 4 && to.length === 4) {
247
+ const a = quaternion(from as Quaternion, "from"), b = quaternion(to as Quaternion, "to")
248
+ return deepFreezeJson(times.map(([time, t]) => parseSpatialValue(quaternionKeySchema, {
249
+ timeUs: time,
250
+ value: t === 0 ? unitQuaternion(a) : t === 1 ? unitQuaternion(b) : unitQuaternion(slerpQuaternion(a, b, easing(t))),
251
+ }, "rotation key")))
252
+ }
253
+ throw new RangeError("from and to must share one shape: scalar opacity, vec3, or quaternion")
254
+ }
255
+
256
+ export type SpatialEaseChannelInput = {
257
+ readonly channelId: string
258
+ readonly targetId: string
259
+ readonly interpolation?: "step" | "linear" | "slerp"
260
+ readonly durationUs: number
261
+ readonly startUs?: number
262
+ readonly easing: SpatialEasing
263
+ readonly segments?: number
264
+ } & (
265
+ | { readonly property: "position" | "scale"; readonly from: Vec3; readonly to: Vec3 }
266
+ | { readonly property: "rotation"; readonly from: Quaternion; readonly to: Quaternion }
267
+ | { readonly property: "opacity"; readonly from: number; readonly to: number }
268
+ )
269
+
270
+ /** Bakes eased keys directly into a validated animation channel. */
271
+ export function easeChannel(input: SpatialEaseChannelInput): SpatialAnimation {
272
+ const keys = bakeKeys(input)
273
+ const interpolation = input.interpolation ?? (input.property === "rotation" ? "slerp" : "linear")
274
+ return deepFreezeJson(parseSpatialValue(SpatialAnimationSchema, {
275
+ channelId: input.channelId, targetId: input.targetId,
276
+ property: input.property, interpolation, keys,
277
+ }, "animation channel"))
278
+ }
279
+
280
+ function worldBounds(entry: SpatialLayoutEntry): Bounds {
281
+ if (entry.bounds === undefined) {
282
+ const p = entry.transform.position
283
+ return { min: [p[0], p[1], p[2]], max: [p[0], p[1], p[2]] }
284
+ }
285
+ return transformBounds(composeTransform(entry.transform), entry.bounds)
286
+ }
287
+
288
+ function moved(transformValue: Transform, axis: 0 | 1 | 2, position: number): SpatialTransform {
289
+ const next = [transformValue.position[0], transformValue.position[1], transformValue.position[2]]
290
+ next[axis] = position
291
+ return emitTransform({ position: next as unknown as Vec3, rotation: transformValue.rotation, scale: transformValue.scale })
292
+ }
293
+
294
+ function shifted(transformValue: Transform, delta: Vec3): SpatialTransform {
295
+ const p = transformValue.position
296
+ return emitTransform({ position: [p[0] + delta[0], p[1] + delta[1], p[2] + delta[2]], rotation: transformValue.rotation, scale: transformValue.scale })
297
+ }
298
+
299
+ /**
300
+ * Aligns each entry's world bounds edge on `axis` to a shared reference: the
301
+ * minimum min-edge ("min"), the maximum max-edge ("max"), or the midpoint of
302
+ * the aggregate span ("center"). Entries without bounds align by position.
303
+ */
304
+ export function align(items: readonly SpatialLayoutEntry[], axis: SpatialAxis, edge: "min" | "center" | "max"): readonly SpatialTransform[] {
305
+ const index = AXIS_INDEX[axis]
306
+ if (items.length === 0) return deepFreezeJson([])
307
+ for (const entry of items) transform(entry.transform, "transform")
308
+ const enclosed = items.map(worldBounds)
309
+ const lows = enclosed.map(item => item.min[index]!)
310
+ const highs = enclosed.map(item => item.max[index]!)
311
+ const target = edge === "min" ? Math.min(...lows) : edge === "max" ? Math.max(...highs)
312
+ : (Math.min(...lows) + Math.max(...highs)) / 2
313
+ return deepFreezeJson(items.map((item, itemIndex) => {
314
+ const current = edge === "min" ? lows[itemIndex]! : edge === "max" ? highs[itemIndex]!
315
+ : (lows[itemIndex]! + highs[itemIndex]!) / 2
316
+ return moved(item.transform, index, item.transform.position[index]! + target - current)
317
+ }))
318
+ }
319
+
320
+ /**
321
+ * Evenly spaces entries along `axis` in their current order. `{gap}` anchors the
322
+ * first entry and places each next bounds edge exactly `gap` meters past the
323
+ * previous max edge (position delta for boundless entries). `{span}` keeps the
324
+ * first entry and stretches the aggregate min-to-max extent to `span` meters.
325
+ */
326
+ export function distribute(
327
+ items: readonly SpatialLayoutEntry[],
328
+ axis: SpatialAxis,
329
+ mode: { readonly gap: number } | { readonly span: number },
330
+ ): readonly SpatialTransform[] {
331
+ const index = AXIS_INDEX[axis]
332
+ if (items.length < 2) return deepFreezeJson(items.map(item => emitTransform(item.transform)))
333
+ for (const entry of items) transform(entry.transform, "transform")
334
+ const enclosed = items.map(worldBounds)
335
+ const order = items.map((_, itemIndex) => itemIndex)
336
+ .sort((a, b) => (items[a]!.transform.position[index]! - items[b]!.transform.position[index]!) || a - b)
337
+ const positions = new Array<number>(items.length)
338
+ const first = order[0]!
339
+ positions[first] = items[first]!.transform.position[index]!
340
+ let edge = enclosed[first]!.max[index]!
341
+ const gap = "gap" in mode ? finite(mode.gap, "gap")
342
+ : (finite(mode.span, "span") - order.reduce((total, itemIndex) => total + enclosed[itemIndex]!.max[index]! - enclosed[itemIndex]!.min[index]!, 0)) / (items.length - 1)
343
+ for (let place = 1; place < order.length; place++) {
344
+ const itemIndex = order[place]!
345
+ const offset = enclosed[itemIndex]!.min[index]! - items[itemIndex]!.transform.position[index]!
346
+ positions[itemIndex] = edge + gap - offset
347
+ edge = edge + gap + enclosed[itemIndex]!.max[index]! - enclosed[itemIndex]!.min[index]!
348
+ }
349
+ return deepFreezeJson(items.map((item, itemIndex) => moved(item.transform, index, positions[itemIndex]!)))
350
+ }
351
+
352
+ export function row(items: readonly SpatialLayoutEntry[], mode: { readonly gap: number } | { readonly span: number }): readonly SpatialTransform[] {
353
+ return distribute(items, "x", mode)
354
+ }
355
+ export function column(items: readonly SpatialLayoutEntry[], mode: { readonly gap: number } | { readonly span: number }): readonly SpatialTransform[] {
356
+ return distribute(items, "y", mode)
357
+ }
358
+ export function stack(items: readonly SpatialLayoutEntry[], mode: { readonly gap: number } | { readonly span: number }): readonly SpatialTransform[] {
359
+ return distribute(items, "z", mode)
360
+ }
361
+
362
+ /**
363
+ * Row-major positions on the ground plane: `origin` is the first cell, columns
364
+ * advance +X and rows advance +Z by `cellSize` (scalar or [x, z] spacing).
365
+ */
366
+ export function grid(input: {
367
+ readonly rows: number
368
+ readonly columns: number
369
+ readonly cellSize: number | readonly [number, number]
370
+ readonly origin?: Vec3
371
+ }): readonly Vec3[] {
372
+ if (!Number.isSafeInteger(input.rows) || input.rows < 1) throw new RangeError("rows must be a positive integer")
373
+ if (!Number.isSafeInteger(input.columns) || input.columns < 1) throw new RangeError("columns must be a positive integer")
374
+ if (input.rows * input.columns > SPATIAL_SCENE_LIMITS.entities) throw new RangeError("grid exceeds the entity limit")
375
+ const spacing = typeof input.cellSize === "number" ? [input.cellSize, input.cellSize] : input.cellSize
376
+ if (!Array.isArray(spacing) || spacing.length !== 2) throw new RangeError("cellSize must be a scalar or [x, z] pair")
377
+ const [sx, sz] = [finite(spacing[0]!, "cellSize[0]"), finite(spacing[1]!, "cellSize[1]")]
378
+ const origin = input.origin === undefined ? [0, 0, 0] : vec3(input.origin, "origin")
379
+ return deepFreezeJson(parseSpatialValue(z.array(SpatialVec3Schema),
380
+ Array.from({ length: input.rows * input.columns }, (_, cell) =>
381
+ [origin[0] + (cell % input.columns) * sx, origin[1], origin[2] + Math.floor(cell / input.columns) * sz]),
382
+ "grid"))
383
+ }
384
+
385
+ /** Places the transform so its underside (halfHeight below the origin) rests on floorY. */
386
+ export function groundSnap(transformValue: Transform, halfHeight: number, floorY = 0): SpatialTransform {
387
+ finite(halfHeight, "halfHeight"); finite(floorY, "floorY")
388
+ if (halfHeight < 0) throw new RangeError("halfHeight must be nonnegative")
389
+ transform(transformValue, "transform")
390
+ return emitTransform({
391
+ position: [transformValue.position[0], floorY + halfHeight, transformValue.position[2]],
392
+ rotation: transformValue.rotation, scale: transformValue.scale,
393
+ })
394
+ }
395
+
396
+ /** mulberry32: checked-in deterministic RNG; identical seeds produce identical streams. */
397
+ export function mulberry32(seed: number): () => number {
398
+ let state = seed >>> 0
399
+ return () => {
400
+ state = (state + 0x6d2b79f5) >>> 0
401
+ let value = state
402
+ value = Math.imul(value ^ (value >>> 15), value | 1)
403
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
404
+ return ((value ^ (value >>> 14)) >>> 0) / 0x100000000
405
+ }
406
+ }
407
+
408
+ interface ScatterInput {
409
+ readonly seed: number
410
+ readonly count: number
411
+ readonly region: { readonly minX: number; readonly maxX: number; readonly minZ: number; readonly maxZ: number }
412
+ readonly minSpacing?: number
413
+ }
414
+
415
+ function scatterPositions(input: ScatterInput): readonly Vec3[] {
416
+ if (!Number.isSafeInteger(input.seed) || input.seed < 0 || input.seed > 0xffff_ffff) {
417
+ throw new RangeError("seed must be an integer within [0, 2^32 - 1]")
418
+ }
419
+ if (!Number.isSafeInteger(input.count) || input.count < 0 || input.count > SPATIAL_SCENE_LIMITS.entities) {
420
+ throw new RangeError(`count must be an integer within [0, ${SPATIAL_SCENE_LIMITS.entities}]`)
421
+ }
422
+ const region = input.region
423
+ for (const [value, label] of [[region.minX, "minX"], [region.maxX, "maxX"], [region.minZ, "minZ"], [region.maxZ, "maxZ"]] as const) finite(value, `region.${label}`)
424
+ if (region.minX > region.maxX || region.minZ > region.maxZ) throw new RangeError("region requires min <= max")
425
+ const spacing = input.minSpacing === undefined ? 0 : finite(input.minSpacing, "minSpacing")
426
+ if (spacing < 0) throw new RangeError("minSpacing must be nonnegative")
427
+ const random = mulberry32(input.seed)
428
+ const accepted: number[][] = []
429
+ for (let placed = 0; placed < input.count; placed++) {
430
+ let done = false
431
+ for (let attempt = 0; attempt < SCATTER_MAX_ATTEMPTS && !done; attempt++) {
432
+ const x = region.minX + random() * (region.maxX - region.minX)
433
+ const z = region.minZ + random() * (region.maxZ - region.minZ)
434
+ if (spacing === 0 || accepted.every(([px, , pz]) => (px! - x) * (px! - x) + (pz! - z) * (pz! - z) >= spacing * spacing)) {
435
+ accepted.push([x, 0, z])
436
+ done = true
437
+ }
438
+ }
439
+ if (!done) throw new RangeError("scatter could not satisfy minSpacing within the region")
440
+ }
441
+ return deepFreezeJson(parseSpatialValue(z.array(SpatialVec3Schema), accepted, "scatter"))
442
+ }
443
+
444
+ /**
445
+ * Deterministic XZ-plane positions at y = 0. With `minSpacing`, each point keeps
446
+ * that Euclidean distance from every earlier accepted point; failure to place a
447
+ * point within bounded attempts raises rather than silently relaxing spacing.
448
+ */
449
+ export function scatter(input: ScatterInput): readonly Vec3[]
450
+
451
+ /**
452
+ * `instanced: true` emits ONE mesh entity whose local-space `instances` carry
453
+ * the scattered placements (identity rotation and scale at each position).
454
+ * `entity` supplies the mesh wrapper; its transform parents the whole set.
455
+ */
456
+ export function scatter(input: ScatterInput & {
457
+ readonly instanced: true
458
+ readonly entity: {
459
+ readonly entityId: string
460
+ readonly name: string
461
+ readonly geometry: SpatialGeometry
462
+ readonly material: SpatialMaterial
463
+ readonly transform?: Transform
464
+ readonly parentId?: string | null
465
+ readonly placement?: SpatialPlacement
466
+ readonly visible?: boolean
467
+ readonly castShadow?: boolean
468
+ readonly receiveShadow?: boolean
469
+ }
470
+ }): SpatialEntity
471
+
472
+ export function scatter(input: ScatterInput & {
473
+ readonly instanced?: boolean
474
+ readonly entity?: {
475
+ readonly entityId: string
476
+ readonly name: string
477
+ readonly geometry: SpatialGeometry
478
+ readonly material: SpatialMaterial
479
+ readonly transform?: Transform
480
+ readonly parentId?: string | null
481
+ readonly placement?: SpatialPlacement
482
+ readonly visible?: boolean
483
+ readonly castShadow?: boolean
484
+ readonly receiveShadow?: boolean
485
+ }
486
+ }): readonly Vec3[] | SpatialEntity {
487
+ const positions = scatterPositions(input)
488
+ if (input.instanced !== true) {
489
+ if (input.entity !== undefined) throw new RangeError("entity applies only with instanced: true")
490
+ return positions
491
+ }
492
+ const template = input.entity
493
+ if (template === undefined) throw new RangeError("instanced scatter requires an entity template")
494
+ if (positions.length === 0) throw new RangeError("instanced scatter requires count >= 1")
495
+ const entity = template
496
+ return deepFreezeJson(parseSpatialValue(SpatialEntitySchema, {
497
+ kind: "mesh", entityId: entity.entityId, name: entity.name,
498
+ parentId: entity.parentId ?? null,
499
+ transform: entity.transform ?? { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] },
500
+ placement: entity.placement ?? { kind: "world" },
501
+ origin: { kind: "authored" }, visible: entity.visible ?? true,
502
+ geometry: entity.geometry, material: entity.material,
503
+ ...(entity.castShadow === undefined ? {} : { castShadow: entity.castShadow }),
504
+ ...(entity.receiveShadow === undefined ? {} : { receiveShadow: entity.receiveShadow }),
505
+ instances: positions.map(([x, , z]) => ({ position: [x, 0, z], rotation: [0, 0, 0, 1], scale: [1, 1, 1] })),
506
+ }, "instanced scatter entity"))
507
+ }
508
+
509
+ /** Recenters the mover horizontally on the target and rests its min-Y on the target's max-Y. */
510
+ export function onTopOf(moverBounds: Bounds, moverTransform: Transform, targetBounds: Bounds, targetTransform: Transform): SpatialTransform {
511
+ const mover = transformBounds(composeTransform(transform(moverTransform, "moverTransform")), bounds(moverBounds, "moverBounds"))
512
+ const target = transformBounds(composeTransform(transform(targetTransform, "targetTransform")), bounds(targetBounds, "targetBounds"))
513
+ return shifted(moverTransform, [
514
+ (target.min[0] + target.max[0]) / 2 - (mover.min[0] + mover.max[0]) / 2,
515
+ target.max[1] - mover.min[1],
516
+ (target.min[2] + target.max[2]) / 2 - (mover.min[2] + mover.max[2]) / 2,
517
+ ])
518
+ }
519
+
520
+ /**
521
+ * Places the mover adjacent to the target along `axis` ("after" puts the mover's
522
+ * min edge `gap` past the target's max edge; "before" mirrors it) and
523
+ * center-aligns the mover on the other two axes.
524
+ */
525
+ export function nextTo(
526
+ moverBounds: Bounds,
527
+ moverTransform: Transform,
528
+ targetBounds: Bounds,
529
+ targetTransform: Transform,
530
+ options?: { readonly axis?: SpatialAxis; readonly side?: "before" | "after"; readonly gap?: number },
531
+ ): SpatialTransform {
532
+ const index = AXIS_INDEX[options?.axis ?? "x"]
533
+ const side = options?.side ?? "after"
534
+ const gap = finite(options?.gap ?? 0, "gap")
535
+ const mover = transformBounds(composeTransform(transform(moverTransform, "moverTransform")), bounds(moverBounds, "moverBounds"))
536
+ const target = transformBounds(composeTransform(transform(targetTransform, "targetTransform")), bounds(targetBounds, "targetBounds"))
537
+ const delta: number[] = [0, 0, 0]
538
+ delta[index] = side === "after"
539
+ ? target.max[index]! + gap - mover.min[index]!
540
+ : target.min[index]! - gap - mover.max[index]!
541
+ for (const other of [0, 1, 2] as const) {
542
+ if (other === index) continue
543
+ delta[other] = (target.min[other]! + target.max[other]!) / 2 - (mover.min[other]! + mover.max[other]!) / 2
544
+ }
545
+ return shifted(moverTransform, delta as unknown as Vec3)
546
+ }
547
+
548
+ /** Rotates the transform in place so local -Z faces `target`; position and scale are kept. */
549
+ export function facing(transformValue: Transform, target: Vec3, up: Vec3 = WORLD_UP): SpatialTransform {
550
+ transform(transformValue, "transform"); vec3(target, "target"); vec3(up, "up")
551
+ if (Math.hypot(...up) === 0) throw new RangeError("up must be nonzero")
552
+ return emitTransform({
553
+ position: transformValue.position,
554
+ rotation: lookRotation(transformValue.position, target, up),
555
+ scale: transformValue.scale,
556
+ })
557
+ }
558
+
559
+ /**
560
+ * Bakes a horizontal orbit: key i sits at angle 2*pi*revolutions*i/segments
561
+ * measured from +X in the XZ plane, at world height `height` (default center's
562
+ * y), always looking at `center`. Position and rotation keys share key times.
563
+ */
564
+ export function orbitKeys(input: {
565
+ readonly center: Vec3
566
+ readonly radius: number
567
+ readonly height?: number
568
+ readonly durationUs: number
569
+ readonly revolutions?: number
570
+ readonly startUs?: number
571
+ readonly segments?: number
572
+ readonly up?: Vec3
573
+ }): { readonly position: readonly SpatialBakedKey<Vec3>[]; readonly rotation: readonly SpatialBakedKey<Quaternion>[] } {
574
+ const center = vec3(input.center, "center")
575
+ const radius = finite(input.radius, "radius")
576
+ if (radius < 0) throw new RangeError("radius must be nonnegative")
577
+ const height = input.height === undefined ? center[1] : finite(input.height, "height")
578
+ const revolutions = finite(input.revolutions ?? 1, "revolutions")
579
+ const up = input.up === undefined ? WORLD_UP : vec3(input.up, "up")
580
+ if (Math.hypot(...up) === 0) throw new RangeError("up must be nonzero")
581
+ const startUs = timeUs(input.startUs ?? 0, "startUs")
582
+ const duration = timeUs(input.durationUs, "durationUs")
583
+ if (duration === 0) throw new RangeError("durationUs must be positive")
584
+ timeUs(startUs + duration, "startUs + durationUs")
585
+ const times = keyTimes(startUs, duration, segments(input.segments, duration))
586
+ const position: SpatialBakedKey<Vec3>[] = []
587
+ const rotation: SpatialBakedKey<Quaternion>[] = []
588
+ for (const [time, t] of times) {
589
+ const angle = (2 * Math.PI * revolutions * t) % (2 * Math.PI)
590
+ const at: Vec3 = [center[0] + radius * Math.cos(angle), height, center[2] + radius * Math.sin(angle)]
591
+ position.push(parseSpatialValue(vec3KeySchema, { timeUs: time, value: at }, "orbit position key"))
592
+ rotation.push(parseSpatialValue(quaternionKeySchema, { timeUs: time, value: lookRotation(at, center, up) }, "orbit rotation key"))
593
+ }
594
+ return deepFreezeJson({ position, rotation })
595
+ }
596
+
597
+ /**
598
+ * Pose on the world +Z side of the bounds center, looking down -Z with world +Y
599
+ * up. `margin` (fraction in [0, 1), default 0.1) is the border kept free on
600
+ * every side. Perspective distance grows until every bounds corner projects
601
+ * inside the shrunken image; orthographic projections cannot zoom, so bounds
602
+ * that exceed the shrunken extents raise instead.
603
+ */
604
+ export function frameFitPose(boundsInput: Bounds, projectionInput: SpatialProjection, marginInput = 0.1): SpatialPose {
605
+ const box = bounds(boundsInput, "bounds")
606
+ const projection = parseSpatialValue(SpatialProjectionSchema, projectionInput, "projection")
607
+ const margin = finite(marginInput, "margin")
608
+ if (margin < 0 || margin >= 1) throw new RangeError("margin must lie within [0, 1)")
609
+ const center: Vec3 = [(box.min[0] + box.max[0]) / 2, (box.min[1] + box.max[1]) / 2, (box.min[2] + box.max[2]) / 2]
610
+ const half: Vec3 = [(box.max[0] - box.min[0]) / 2, (box.max[1] - box.min[1]) / 2, (box.max[2] - box.min[2]) / 2]
611
+ const pad = Math.max(1e-9, projection.near * 1e-9)
612
+ if (projection.kind === "perspective") {
613
+ const roomX = Math.min(projection.cx, projection.width - projection.cx) * (1 - margin)
614
+ const roomY = Math.min(projection.cy, projection.height - projection.cy) * (1 - margin)
615
+ if (roomX <= 0 || roomY <= 0) throw new RangeError("projection has no image room around its principal point")
616
+ const distance = Math.max(
617
+ projection.fx * half[0] / roomX + half[2],
618
+ projection.fy * half[1] / roomY + half[2],
619
+ projection.near + half[2],
620
+ ) + pad
621
+ if (distance + half[2] > projection.far) throw new RangeError("bounds exceed the projection's far clipping distance")
622
+ return emitPose([center[0], center[1], center[2] + distance], [0, 0, 0, 1])
623
+ }
624
+ const roomX = (projection.right - projection.left) / 2 * (1 - margin)
625
+ const roomY = (projection.top - projection.bottom) / 2 * (1 - margin)
626
+ if (half[0] > roomX || half[1] > roomY) throw new RangeError("bounds exceed the shrunken orthographic extents")
627
+ return emitPose([
628
+ center[0] - (projection.left + projection.right) / 2,
629
+ center[1] - (projection.top + projection.bottom) / 2,
630
+ center[2] + projection.near + half[2] + pad,
631
+ ], [0, 0, 0, 1])
632
+ }