@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.
- package/README.md +29 -9
- package/apps/desktop/application/context.ts +6 -0
- package/apps/desktop/application/default-registry.ts +26 -0
- package/apps/desktop/application/operation.ts +14 -1
- package/apps/desktop/application/operations/index.ts +4 -0
- 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 +514 -11
- 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 +29 -1
- package/apps/desktop/cli/commands.ts +1188 -4
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +172 -6
- package/apps/desktop/cli/main.ts +3 -3
- package/apps/desktop/cli/portable-surface.ts +6 -1
- 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/semantic-builder.ts +24 -0
- 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/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +725 -273
- 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 +25 -429
- 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 -226
- 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/package.json +7 -5
- package/skills/slopcamera/SKILL.md +6 -1
- 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 +1 -1
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -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 +222 -4
- 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 -0
- 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/version.ts +1 -1
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7131pg1c.js +0 -1820
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-fava6pge.js +0 -192
- package/dist/index-h1k0fnjq.js +0 -2363
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-zfnddgay.js +0 -2029
- 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
|
+
}
|