@hraness/slopcamera 3.2.7 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +65 -20
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +6 -9
- package/apps/desktop/application/default-registry.ts +26 -10
- package/apps/desktop/application/operation.ts +14 -5
- package/apps/desktop/application/operations/index.ts +4 -1
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +528 -97
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +31 -2
- package/apps/desktop/cli/commands.ts +1215 -70
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +183 -23
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +26 -50
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +18 -5
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +24 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +737 -291
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +32 -434
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -240
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +1 -1
- package/package.json +11 -24
- package/skills/slopcamera/SKILL.md +14 -1
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +250 -102
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -23
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-mcy8z0br.js +0 -192
- package/dist/index-p63wavx0.js +0 -2029
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-rvghzt7b.js +0 -2013
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-z7239b4h.js +0 -2363
- package/dist/skill-install-dh0ntqba.js +0 -10
- package/src/desktop.ts +0 -253
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
import { createSha256HexHasher } from "../code/sha256.js"
|
|
4
|
+
import { SpatialAssetIdSchema, SpatialTimeUsSchema } from "./contracts.js"
|
|
5
|
+
import { parseSpatialValue } from "./identity.js"
|
|
6
|
+
import { parseSpatialParticleSystem, spatialParticleSystemSha256, type SpatialParticleSystem } from "./particle.js"
|
|
7
|
+
|
|
8
|
+
export const SPATIAL_PARTICLE_INSTANCE_STRIDE_BYTES = 64
|
|
9
|
+
export const SPATIAL_PARTICLE_PREPARATION_LIMITS = Object.freeze({ surfaceSets: 64, trianglesPerSurface: 100_000 })
|
|
10
|
+
|
|
11
|
+
const coordinate = z.number().finite().min(-1_000_000).max(1_000_000)
|
|
12
|
+
const vec3 = z.tuple([coordinate, coordinate, coordinate])
|
|
13
|
+
const surfaceSchema = z.strictObject({
|
|
14
|
+
assetId: SpatialAssetIdSchema,
|
|
15
|
+
triangles: z.array(z.tuple([vec3, vec3, vec3])).min(1).max(SPATIAL_PARTICLE_PREPARATION_LIMITS.trianglesPerSurface),
|
|
16
|
+
})
|
|
17
|
+
const inputSchema = z.strictObject({
|
|
18
|
+
sampleTimeUs: SpatialTimeUsSchema,
|
|
19
|
+
surfaces: z.array(surfaceSchema).max(SPATIAL_PARTICLE_PREPARATION_LIMITS.surfaceSets).default([]),
|
|
20
|
+
system: z.unknown(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export interface SpatialPreparedParticleInstances {
|
|
24
|
+
readonly bytes: Uint8Array
|
|
25
|
+
readonly byteLength: number
|
|
26
|
+
readonly instanceCount: number
|
|
27
|
+
readonly sha256: string
|
|
28
|
+
readonly strideBytes: typeof SPATIAL_PARTICLE_INSTANCE_STRIDE_BYTES
|
|
29
|
+
readonly systemSha256: string
|
|
30
|
+
readonly sampleTimeUs: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Vec3 = readonly [number, number, number]
|
|
34
|
+
type Random = () => number
|
|
35
|
+
|
|
36
|
+
function randomGenerator(seed: number): Random {
|
|
37
|
+
let state = seed >>> 0 || 0x6d2b79f5
|
|
38
|
+
return () => {
|
|
39
|
+
state = Math.imul(state ^ state >>> 15, state | 1)
|
|
40
|
+
state ^= state + Math.imul(state ^ state >>> 7, state | 61)
|
|
41
|
+
return ((state ^ state >>> 14) >>> 0) / 4_294_967_296
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function particleSeed(seed: number, index: number): number {
|
|
46
|
+
let value = (seed ^ Math.imul(index + 1, 0x9e3779b1)) >>> 0
|
|
47
|
+
value = Math.imul(value ^ value >>> 16, 0x85ebca6b)
|
|
48
|
+
value = Math.imul(value ^ value >>> 13, 0xc2b2ae35)
|
|
49
|
+
return (value ^ value >>> 16) >>> 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function interpolate(keys: readonly { readonly t: number; readonly value: number }[], t: number): number {
|
|
53
|
+
const upper = keys.findIndex(key => key.t >= t)
|
|
54
|
+
if (upper <= 0) return keys[0]!.value
|
|
55
|
+
const left = keys[upper - 1]!, right = keys[upper]!
|
|
56
|
+
const amount = (t - left.t) / (right.t - left.t)
|
|
57
|
+
return left.value + (right.value - left.value) * amount
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function interpolateColor(keys: readonly { readonly t: number; readonly color: readonly [number, number, number, number] }[], t: number): readonly [number, number, number, number] {
|
|
61
|
+
const upper = keys.findIndex(key => key.t >= t)
|
|
62
|
+
if (upper <= 0) return keys[0]!.color
|
|
63
|
+
const left = keys[upper - 1]!, right = keys[upper]!
|
|
64
|
+
const amount = (t - left.t) / (right.t - left.t)
|
|
65
|
+
return [0, 1, 2, 3].map(index => left.color[index]! + (right.color[index]! - left.color[index]!) * amount) as [number, number, number, number]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalize(value: Vec3): Vec3 {
|
|
69
|
+
const length = Math.hypot(...value)
|
|
70
|
+
return length === 0 ? [0, 0, 0] : [value[0] / length, value[1] / length, value[2] / length]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cross(left: Vec3, right: Vec3): Vec3 {
|
|
74
|
+
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function addScaled(target: [number, number, number], value: Vec3, scale = 1): void {
|
|
78
|
+
target[0] += value[0] * scale
|
|
79
|
+
target[1] += value[1] * scale
|
|
80
|
+
target[2] += value[2] * scale
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sampleSphere(random: Random, radius: number, volume: boolean): Vec3 {
|
|
84
|
+
const z = random() * 2 - 1
|
|
85
|
+
const angle = random() * Math.PI * 2
|
|
86
|
+
const radial = radius * (volume ? Math.cbrt(random()) : 1)
|
|
87
|
+
const planar = Math.sqrt(1 - z * z)
|
|
88
|
+
return [radial * planar * Math.cos(angle), radial * z, radial * planar * Math.sin(angle)]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function trianglePoint(triangle: readonly [Vec3, Vec3, Vec3], random: Random): Vec3 {
|
|
92
|
+
const first = Math.sqrt(random()), second = random()
|
|
93
|
+
const a = 1 - first, b = first * (1 - second), c = first * second
|
|
94
|
+
return [triangle[0][0] * a + triangle[1][0] * b + triangle[2][0] * c, triangle[0][1] * a + triangle[1][1] * b + triangle[2][1] * c, triangle[0][2] * a + triangle[1][2] * b + triangle[2][2] * c]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function shapePosition(shape: SpatialParticleSystem["emitters"][number]["shape"], random: Random, surfaces: ReadonlyMap<string, readonly (readonly [Vec3, Vec3, Vec3])[]>): Vec3 {
|
|
98
|
+
if (shape.kind === "point") return [0, 0, 0]
|
|
99
|
+
if (shape.kind === "sphere") return sampleSphere(random, shape.radius, shape.volume)
|
|
100
|
+
if (shape.kind === "box") {
|
|
101
|
+
const point: Vec3 = [(random() - 0.5) * shape.size[0], (random() - 0.5) * shape.size[1], (random() - 0.5) * shape.size[2]]
|
|
102
|
+
if (shape.volume) return point
|
|
103
|
+
const axis = Math.floor(random() * 3), sign = random() < 0.5 ? -0.5 : 0.5
|
|
104
|
+
return point.map((value, index) => index === axis ? shape.size[index]! * sign : value) as [number, number, number]
|
|
105
|
+
}
|
|
106
|
+
if (shape.kind === "disc") {
|
|
107
|
+
const angle = random() * Math.PI * 2, radius = Math.sqrt(random()) * shape.radius
|
|
108
|
+
return [Math.cos(angle) * radius, 0, Math.sin(angle) * radius]
|
|
109
|
+
}
|
|
110
|
+
if (shape.kind === "spline") {
|
|
111
|
+
const scaled = random() * (shape.controlPoints.length - 1), index = Math.min(shape.controlPoints.length - 2, Math.floor(scaled)), amount = scaled - index
|
|
112
|
+
const left = shape.controlPoints[index]!, right = shape.controlPoints[index + 1]!
|
|
113
|
+
return [left[0] + (right[0] - left[0]) * amount, left[1] + (right[1] - left[1]) * amount, left[2] + (right[2] - left[2]) * amount]
|
|
114
|
+
}
|
|
115
|
+
const triangles = surfaces.get(shape.assetId)
|
|
116
|
+
if (triangles === undefined) throw new RangeError(`Particle surface ${shape.assetId} has no prepared triangle set.`)
|
|
117
|
+
return trianglePoint(triangles[Math.floor(random() * triangles.length)]!, random)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function inside(position: Vec3, volume: SpatialParticleSystem["killVolumes"][number]): boolean {
|
|
121
|
+
if (volume.kind === "box") return position.every((value, index) => value >= volume.min[index]! && value <= volume.max[index]!)
|
|
122
|
+
return Math.hypot(position[0] - volume.center[0], position[1] - volume.center[1], position[2] - volume.center[2]) <= volume.radius
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function prepareSpatialParticleInstances(input: unknown): SpatialPreparedParticleInstances {
|
|
126
|
+
const parsed = parseSpatialValue(inputSchema, input, "particle preparation")
|
|
127
|
+
const system = parseSpatialParticleSystem(parsed.system)
|
|
128
|
+
const surfaces = new Map<string, readonly (readonly [Vec3, Vec3, Vec3])[]>()
|
|
129
|
+
for (const surface of parsed.surfaces) {
|
|
130
|
+
if (surfaces.has(surface.assetId)) throw new RangeError(`Duplicate particle surface ${surface.assetId}.`)
|
|
131
|
+
surfaces.set(surface.assetId, surface.triangles)
|
|
132
|
+
}
|
|
133
|
+
const capacity = new Uint8Array(system.maxCount * SPATIAL_PARTICLE_INSTANCE_STRIDE_BYTES)
|
|
134
|
+
const view = new DataView(capacity.buffer)
|
|
135
|
+
let instanceCount = 0
|
|
136
|
+
const sampleSeconds = parsed.sampleTimeUs / 1_000_000
|
|
137
|
+
for (const [emitterIndex, emitter] of system.emitters.entries()) {
|
|
138
|
+
if (!emitter.enabled) continue
|
|
139
|
+
const maximumLifetimeSeconds = emitter.lifetimeUs[1] / 1_000_000
|
|
140
|
+
const firstContinuous = Math.max(0, Math.floor((sampleSeconds - maximumLifetimeSeconds) * emitter.rate))
|
|
141
|
+
const lastContinuous = Math.floor(sampleSeconds * emitter.rate)
|
|
142
|
+
const births: { index: number; time: number }[] = []
|
|
143
|
+
for (let index = firstContinuous; index < lastContinuous; index += 1) births.push({ index, time: index / emitter.rate })
|
|
144
|
+
for (let index = 0; index < (emitter.burst ?? 0); index += 1) births.push({ index: -(index + 1), time: 0 })
|
|
145
|
+
for (const birth of births) {
|
|
146
|
+
const random = randomGenerator(particleSeed(emitter.seed, birth.index))
|
|
147
|
+
const lifetimeSeconds = (emitter.lifetimeUs[0] + random() * (emitter.lifetimeUs[1] - emitter.lifetimeUs[0])) / 1_000_000
|
|
148
|
+
const ageSeconds = sampleSeconds - birth.time
|
|
149
|
+
if (ageSeconds < 0 || ageSeconds >= lifetimeSeconds) continue
|
|
150
|
+
const base = shapePosition(emitter.shape, random, surfaces)
|
|
151
|
+
const initialVelocity: Vec3 = emitter.velocity.map((value, index) => value + (random() * 2 - 1) * emitter.velocitySpread[index]!) as [number, number, number]
|
|
152
|
+
const acceleration: [number, number, number] = [0, 0, 0]
|
|
153
|
+
let drag = 0
|
|
154
|
+
for (const force of system.forces) {
|
|
155
|
+
if (force.kind === "gravity") addScaled(acceleration, force.acceleration)
|
|
156
|
+
else if (force.kind === "drag") drag += force.coefficient
|
|
157
|
+
else if (force.kind === "vortex") addScaled(acceleration, normalize(cross(normalize(force.axis), base)), force.strength)
|
|
158
|
+
else {
|
|
159
|
+
const turbulence = randomGenerator(particleSeed(force.seed, birth.index + Math.round(force.scale * 1000)))
|
|
160
|
+
addScaled(acceleration, normalize([turbulence() * 2 - 1, turbulence() * 2 - 1, turbulence() * 2 - 1]), force.strength)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const damping = drag === 0 ? 1 : Math.exp(-drag * ageSeconds)
|
|
164
|
+
const position = base.map((value, index) => value + initialVelocity[index]! * ageSeconds * damping + 0.5 * acceleration[index]! * ageSeconds * ageSeconds) as [number, number, number]
|
|
165
|
+
if (system.killVolumes.some(volume => inside(position, volume))) continue
|
|
166
|
+
const velocity = initialVelocity.map((value, index) => value * damping + acceleration[index]! * ageSeconds) as [number, number, number]
|
|
167
|
+
const normalizedAge = ageSeconds / lifetimeSeconds
|
|
168
|
+
const color = interpolateColor(emitter.colorOverLife, normalizedAge)
|
|
169
|
+
if (instanceCount >= system.maxCount) throw new RangeError(`Prepared particle count exceeds maxCount ${system.maxCount}.`)
|
|
170
|
+
const row = [
|
|
171
|
+
...position, ageSeconds,
|
|
172
|
+
...velocity, lifetimeSeconds,
|
|
173
|
+
interpolate(emitter.sizeOverLife.keys, normalizedAge), interpolate(emitter.opacityOverLife.keys, normalizedAge),
|
|
174
|
+
...color, random(), emitterIndex,
|
|
175
|
+
]
|
|
176
|
+
row.forEach((value, column) => view.setFloat32(instanceCount * SPATIAL_PARTICLE_INSTANCE_STRIDE_BYTES + column * 4, value, true))
|
|
177
|
+
instanceCount += 1
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const bytes = capacity.slice(0, instanceCount * SPATIAL_PARTICLE_INSTANCE_STRIDE_BYTES)
|
|
181
|
+
const hasher = createSha256HexHasher(); hasher.update(bytes)
|
|
182
|
+
return Object.freeze({ bytes, byteLength: bytes.length, instanceCount, sha256: hasher.digestHex(), strideBytes: SPATIAL_PARTICLE_INSTANCE_STRIDE_BYTES, systemSha256: spatialParticleSystemSha256(system), sampleTimeUs: parsed.sampleTimeUs })
|
|
183
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
import { deepFreezeJson } from "../code/json-snapshot.js"
|
|
4
|
+
import { SpatialAssetIdSchema, SpatialEntityIdSchema, SpatialTimeUsSchema } from "./contracts.js"
|
|
5
|
+
import { parseSpatialValue, SpatialSceneError, spatialValueSha256 } from "./identity.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bounded deterministic particle systems for Phase 7.
|
|
9
|
+
* All randomness is seeded; emitters, forces, collision/kill volumes, and
|
|
10
|
+
* count tiers are validated before any renderer or native bake admission.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const SPATIAL_PARTICLE_LIMITS = Object.freeze({
|
|
14
|
+
collisionVolumes: 16,
|
|
15
|
+
curveKeys: 16,
|
|
16
|
+
durationUs: 3_600_000_000,
|
|
17
|
+
emitters: 64,
|
|
18
|
+
final: 1_000_000,
|
|
19
|
+
forces: 16,
|
|
20
|
+
killVolumes: 16,
|
|
21
|
+
preview: 100_000,
|
|
22
|
+
splinePoints: 64,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const finiteCoordinate = z.number().finite().min(-1_000_000).max(1_000_000)
|
|
26
|
+
const positiveDimension = z.number().finite().positive().max(1_000_000)
|
|
27
|
+
const unit = z.number().finite().min(0).max(1)
|
|
28
|
+
const vec3 = z.tuple([finiteCoordinate, finiteCoordinate, finiteCoordinate])
|
|
29
|
+
const rgba = z.tuple([unit, unit, unit, unit])
|
|
30
|
+
const particleId = z.string().min(1).max(64).regex(/^[a-z][a-z0-9_-]*$/u)
|
|
31
|
+
|
|
32
|
+
const scalarCurveKey = z.strictObject({ t: unit, value: unit })
|
|
33
|
+
const scalarCurveKeys = z.array(scalarCurveKey).min(2).max(SPATIAL_PARTICLE_LIMITS.curveKeys).superRefine((keys, context) => {
|
|
34
|
+
if (keys[0]?.t !== 0 || keys[keys.length - 1]?.t !== 1) {
|
|
35
|
+
context.addIssue({ code: "custom", message: "Particle curves must cover normalized time from 0 through 1." })
|
|
36
|
+
}
|
|
37
|
+
for (let index = 1; index < keys.length; index += 1) {
|
|
38
|
+
if (keys[index]!.t <= keys[index - 1]!.t) {
|
|
39
|
+
context.addIssue({ code: "custom", path: [index, "t"], message: "Particle curve keys must have strictly increasing time." })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const colorCurveKey = z.strictObject({ t: unit, color: rgba })
|
|
45
|
+
const keyedColorCurve = z.array(colorCurveKey).min(2).max(SPATIAL_PARTICLE_LIMITS.curveKeys).superRefine((keys, context) => {
|
|
46
|
+
if (keys[0]?.t !== 0 || keys[keys.length - 1]?.t !== 1) {
|
|
47
|
+
context.addIssue({ code: "custom", message: "Particle color curves must cover normalized time from 0 through 1." })
|
|
48
|
+
}
|
|
49
|
+
for (let index = 1; index < keys.length; index += 1) {
|
|
50
|
+
if (keys[index]!.t <= keys[index - 1]!.t) {
|
|
51
|
+
context.addIssue({ code: "custom", path: [index, "t"], message: "Particle color keys must have strictly increasing time." })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
const legacyColorCurve = z.array(rgba).min(2).max(SPATIAL_PARTICLE_LIMITS.curveKeys).transform(colors => (
|
|
56
|
+
colors.map((color, index) => ({ t: index / (colors.length - 1), color }))
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
export const SpatialParticleEmitterShapeSchema = z.discriminatedUnion("kind", [
|
|
60
|
+
z.strictObject({ kind: z.literal("point") }),
|
|
61
|
+
z.strictObject({ kind: z.literal("sphere"), radius: positiveDimension, volume: z.boolean().default(true) }),
|
|
62
|
+
z.strictObject({ kind: z.literal("box"), size: z.tuple([positiveDimension, positiveDimension, positiveDimension]), volume: z.boolean().default(true) }),
|
|
63
|
+
z.strictObject({ kind: z.literal("disc"), radius: positiveDimension }),
|
|
64
|
+
z.strictObject({ kind: z.literal("surface"), assetId: SpatialAssetIdSchema }),
|
|
65
|
+
z.strictObject({ kind: z.literal("spline"), controlPoints: z.array(vec3).min(2).max(SPATIAL_PARTICLE_LIMITS.splinePoints) }),
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
export const SpatialParticleForceSchema = z.discriminatedUnion("kind", [
|
|
69
|
+
z.strictObject({ kind: z.literal("gravity"), acceleration: vec3 }),
|
|
70
|
+
z.strictObject({ kind: z.literal("drag"), coefficient: unit }),
|
|
71
|
+
z.strictObject({ kind: z.literal("vortex"), axis: vec3, strength: z.number().finite().min(-1_000_000).max(1_000_000) }),
|
|
72
|
+
z.strictObject({ kind: z.literal("turbulence"), seed: z.number().int().min(0).max(2_147_483_647), scale: positiveDimension, strength: positiveDimension }),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const boxVolumeShape = {
|
|
76
|
+
kind: z.literal("box"),
|
|
77
|
+
max: vec3,
|
|
78
|
+
min: vec3,
|
|
79
|
+
}
|
|
80
|
+
const sphereVolumeShape = {
|
|
81
|
+
center: vec3,
|
|
82
|
+
kind: z.literal("sphere"),
|
|
83
|
+
radius: positiveDimension,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const SpatialParticleKillVolumeSchema = z.discriminatedUnion("kind", [
|
|
87
|
+
z.strictObject(boxVolumeShape),
|
|
88
|
+
z.strictObject(sphereVolumeShape),
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
export const SpatialParticleCollisionVolumeSchema = z.discriminatedUnion("kind", [
|
|
92
|
+
z.strictObject({ ...boxVolumeShape, response: z.enum(["bounce", "slide"]), restitution: unit }),
|
|
93
|
+
z.strictObject({ ...sphereVolumeShape, response: z.enum(["bounce", "slide"]), restitution: unit }),
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
export const SpatialParticleCurveSchema = z.strictObject({ keys: scalarCurveKeys })
|
|
97
|
+
|
|
98
|
+
export const SpatialParticleEmitterSchema = z.strictObject({
|
|
99
|
+
burst: z.number().int().min(0).max(SPATIAL_PARTICLE_LIMITS.final).optional(),
|
|
100
|
+
colorOverLife: z.union([keyedColorCurve, legacyColorCurve]),
|
|
101
|
+
enabled: z.boolean().default(true),
|
|
102
|
+
id: particleId,
|
|
103
|
+
lifetimeUs: z.tuple([
|
|
104
|
+
SpatialTimeUsSchema.min(1).max(SPATIAL_PARTICLE_LIMITS.durationUs),
|
|
105
|
+
SpatialTimeUsSchema.min(1).max(SPATIAL_PARTICLE_LIMITS.durationUs),
|
|
106
|
+
]),
|
|
107
|
+
opacityOverLife: SpatialParticleCurveSchema,
|
|
108
|
+
rate: z.number().int().min(0).max(SPATIAL_PARTICLE_LIMITS.final),
|
|
109
|
+
seed: z.number().int().min(0).max(2_147_483_647),
|
|
110
|
+
shape: SpatialParticleEmitterShapeSchema,
|
|
111
|
+
sizeOverLife: SpatialParticleCurveSchema,
|
|
112
|
+
velocity: vec3,
|
|
113
|
+
velocitySpread: z.tuple([unit, unit, unit]),
|
|
114
|
+
}).superRefine((emitter, context) => {
|
|
115
|
+
if (emitter.lifetimeUs[1] < emitter.lifetimeUs[0]) {
|
|
116
|
+
context.addIssue({ code: "custom", path: ["lifetimeUs", 1], message: "Maximum particle lifetime must not precede minimum lifetime." })
|
|
117
|
+
}
|
|
118
|
+
if (emitter.rate === 0 && (emitter.burst ?? 0) === 0) {
|
|
119
|
+
context.addIssue({ code: "custom", message: "An emitter must declare a positive rate or burst." })
|
|
120
|
+
}
|
|
121
|
+
if (emitter.shape.kind === "spline") {
|
|
122
|
+
const distinct = new Set(emitter.shape.controlPoints.map(point => point.join(",")))
|
|
123
|
+
if (distinct.size < 2) context.addIssue({ code: "custom", path: ["shape", "controlPoints"], message: "A spline emitter needs at least two distinct control points." })
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
export const SpatialParticleRendererSchema = z.discriminatedUnion("kind", [
|
|
128
|
+
z.strictObject({ kind: z.literal("sprite"), assetId: SpatialAssetIdSchema.optional(), billboard: z.boolean().default(true) }),
|
|
129
|
+
z.strictObject({ kind: z.literal("instanced-mesh"), assetId: SpatialAssetIdSchema }),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
export const SpatialParticleSystemSchema = z.strictObject({
|
|
133
|
+
collisionVolumes: z.array(SpatialParticleCollisionVolumeSchema).max(SPATIAL_PARTICLE_LIMITS.collisionVolumes).default([]),
|
|
134
|
+
countTier: z.enum(["preview", "final"]),
|
|
135
|
+
emitters: z.array(SpatialParticleEmitterSchema).min(1).max(SPATIAL_PARTICLE_LIMITS.emitters),
|
|
136
|
+
entityId: SpatialEntityIdSchema,
|
|
137
|
+
forces: z.array(SpatialParticleForceSchema).max(SPATIAL_PARTICLE_LIMITS.forces),
|
|
138
|
+
killVolumes: z.array(SpatialParticleKillVolumeSchema).max(SPATIAL_PARTICLE_LIMITS.killVolumes),
|
|
139
|
+
kind: z.literal("slopcamera.spatial-particle-system"),
|
|
140
|
+
maxCount: z.number().int().min(1).max(SPATIAL_PARTICLE_LIMITS.final),
|
|
141
|
+
preBake: z.boolean().default(false),
|
|
142
|
+
renderer: SpatialParticleRendererSchema.default({ kind: "sprite", billboard: true }),
|
|
143
|
+
schemaVersion: z.literal(1),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
export type SpatialParticleSystem = Readonly<z.infer<typeof SpatialParticleSystemSchema>>
|
|
147
|
+
export type SpatialParticleEmitter = Readonly<z.infer<typeof SpatialParticleEmitterSchema>>
|
|
148
|
+
|
|
149
|
+
function assertBoxVolume(volume: { readonly kind: string; readonly min?: readonly number[]; readonly max?: readonly number[] }, label: string): void {
|
|
150
|
+
if (volume.kind !== "box" || volume.min === undefined || volume.max === undefined) return
|
|
151
|
+
if (volume.min.some((value, index) => value >= volume.max![index]!)) {
|
|
152
|
+
throw new SpatialSceneError("invalid-data", `${label} box minimum coordinates must be below maximum coordinates.`, "particle-system")
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function parseSpatialParticleSystem(input: unknown): SpatialParticleSystem {
|
|
157
|
+
const system = parseSpatialValue(SpatialParticleSystemSchema, input, "particle system")
|
|
158
|
+
const tierLimit = system.countTier === "preview" ? SPATIAL_PARTICLE_LIMITS.preview : SPATIAL_PARTICLE_LIMITS.final
|
|
159
|
+
if (system.maxCount > tierLimit) {
|
|
160
|
+
throw new SpatialSceneError("invalid-data", `Particle system ${system.entityId} requests ${system.maxCount} particles, exceeding the ${system.countTier} tier limit of ${tierLimit}.`, "particle-system")
|
|
161
|
+
}
|
|
162
|
+
const emitterIds = system.emitters.map(emitter => emitter.id)
|
|
163
|
+
if (new Set(emitterIds).size !== emitterIds.length) {
|
|
164
|
+
throw new SpatialSceneError("invalid-data", `Particle system ${system.entityId} has duplicate emitter IDs.`, "particle-system")
|
|
165
|
+
}
|
|
166
|
+
const simultaneousBound = system.emitters.reduce((sum, emitter) => (
|
|
167
|
+
emitter.enabled
|
|
168
|
+
? sum + (emitter.burst ?? 0) + Math.ceil(emitter.rate * emitter.lifetimeUs[1] / 1_000_000)
|
|
169
|
+
: sum
|
|
170
|
+
), 0)
|
|
171
|
+
if (simultaneousBound > system.maxCount) {
|
|
172
|
+
throw new SpatialSceneError("invalid-data", `Particle system ${system.entityId} simultaneous bound (${simultaneousBound}) exceeds maxCount (${system.maxCount}).`, "particle-system")
|
|
173
|
+
}
|
|
174
|
+
for (const volume of system.killVolumes) assertBoxVolume(volume, "Kill volume")
|
|
175
|
+
for (const volume of system.collisionVolumes) assertBoxVolume(volume, "Collision volume")
|
|
176
|
+
for (const force of system.forces) {
|
|
177
|
+
if (force.kind === "vortex" && force.axis.every(value => value === 0)) {
|
|
178
|
+
throw new SpatialSceneError("invalid-data", "Vortex axes must be nonzero.", "particle-system")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return deepFreezeJson(system)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function spatialParticleAssetIds(system: SpatialParticleSystem): readonly string[] {
|
|
185
|
+
const ids = new Set<string>()
|
|
186
|
+
if ("assetId" in system.renderer && system.renderer.assetId !== undefined) ids.add(system.renderer.assetId)
|
|
187
|
+
for (const emitter of system.emitters) {
|
|
188
|
+
if (emitter.shape.kind === "surface") ids.add(emitter.shape.assetId)
|
|
189
|
+
}
|
|
190
|
+
return Object.freeze([...ids].sort())
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function spatialParticleSystemSha256(system: SpatialParticleSystem): string {
|
|
194
|
+
return spatialValueSha256(system)
|
|
195
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { deepFreezeJson } from "../code/json-snapshot.js"
|
|
2
2
|
import { SpatialScenePatchV1Schema, type SpatialEntity, type SpatialSceneV1 } from "./contracts.js"
|
|
3
3
|
import { applySpatialEntityOverride } from "./evaluate.js"
|
|
4
|
-
import { parseSpatialScene, parseSpatialValue, SpatialSceneError, spatialAssetClosureDigests, spatialValueSha256 } from "./identity.js"
|
|
4
|
+
import { parseSpatialScene, parseSpatialValue, SpatialSceneError, spatialAssetClosureDigests, spatialPropertySupported, spatialValueSha256 } from "./identity.js"
|
|
5
|
+
import { pbrMaterialMapAssetIds } from "./material-lighting.js"
|
|
5
6
|
|
|
6
7
|
export interface SpatialSceneDiffEntry {
|
|
7
8
|
readonly kind: "added" | "removed" | "changed"
|
|
@@ -33,6 +34,20 @@ function diffCollection<T extends object>(collection: SpatialSceneDiffEntry["col
|
|
|
33
34
|
return result
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Structural comparison of two parsed scenes, in canonical collection order. */
|
|
38
|
+
export function diffSpatialScenes(beforeInput: unknown, afterInput: unknown): readonly SpatialSceneDiffEntry[] {
|
|
39
|
+
const before = parseSpatialScene(beforeInput)
|
|
40
|
+
const after = parseSpatialScene(afterInput)
|
|
41
|
+
return deepFreezeJson([
|
|
42
|
+
...diffCollection("assets", before.assets, after.assets, asset => asset.assetId),
|
|
43
|
+
...diffCollection("entities", before.entities, after.entities, entity => entity.entityId),
|
|
44
|
+
...diffCollection("cameras", before.cameras, after.cameras, camera => camera.cameraId),
|
|
45
|
+
...diffCollection("animations", before.animations, after.animations, channel => channel.channelId),
|
|
46
|
+
...diffCollection("generators", before.generators, after.generators, generator => generator.generatorId),
|
|
47
|
+
...diffCollection("overrides", before.overrides, after.overrides, override => `${override.entityId}:${override.property}`),
|
|
48
|
+
])
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
/** Atomic in-memory transaction. Final closure is checked once, before publishing any result. */
|
|
37
52
|
export function applySpatialScenePatch(sceneInput: unknown, patchInput: unknown): SpatialScenePatchResult {
|
|
38
53
|
const original = parseSpatialScene(sceneInput)
|
|
@@ -55,10 +70,15 @@ export function applySpatialScenePatch(sceneInput: unknown, patchInput: unknown)
|
|
|
55
70
|
switch (operation.kind) {
|
|
56
71
|
case "add-asset":
|
|
57
72
|
if (assets.has(operation.asset.assetId)) throw new SpatialSceneError("conflict", `Asset ${operation.asset.assetId} already exists.`)
|
|
73
|
+
if (operation.asset.provenance.source === "generated") throw new SpatialSceneError("conflict", "Generated assets enter only through retained generator output replacement.")
|
|
58
74
|
assets.set(operation.asset.assetId, operation.asset)
|
|
59
75
|
break
|
|
60
76
|
case "replace-asset":
|
|
61
77
|
if (!assets.has(operation.asset.assetId)) throw new SpatialSceneError("not-found", `Asset ${operation.asset.assetId} does not exist.`)
|
|
78
|
+
if (operation.asset.provenance.source === "generated") throw new SpatialSceneError("conflict", "Generated assets change only through retained generator output replacement.")
|
|
79
|
+
if (original.generators.some(generator => generator.assets?.includes(operation.asset.assetId))) {
|
|
80
|
+
throw new SpatialSceneError("conflict", `Asset ${operation.asset.assetId} is owned by a generator; replace its retained output.`)
|
|
81
|
+
}
|
|
62
82
|
assets.set(operation.asset.assetId, operation.asset)
|
|
63
83
|
break
|
|
64
84
|
case "set-mesh-geometry": {
|
|
@@ -68,11 +88,63 @@ export function applySpatialScenePatch(sceneInput: unknown, patchInput: unknown)
|
|
|
68
88
|
addressedGeometry.add(operation.entityId)
|
|
69
89
|
break
|
|
70
90
|
}
|
|
91
|
+
case "set-material": {
|
|
92
|
+
const entity = authored(operation.entityId)
|
|
93
|
+
if (entity.kind !== "mesh") throw new SpatialSceneError("conflict", "Material replacement requires an authored mesh entity.")
|
|
94
|
+
if (entity.geometry.kind === "asset" && entity.geometry.materialMode === "source") throw new SpatialSceneError("conflict", "Source-material meshes consume source materials only; the entity material is inert.")
|
|
95
|
+
entities.set(operation.entityId, { ...entity, material: operation.material })
|
|
96
|
+
break
|
|
97
|
+
}
|
|
71
98
|
case "rename-entity": entities.set(operation.entityId, { ...authored(operation.entityId), name: operation.name }); break
|
|
72
99
|
case "reparent-entity": entities.set(operation.entityId, { ...authored(operation.entityId), parentId: operation.parentId }); break
|
|
73
100
|
case "set-transform": entities.set(operation.entityId, { ...authored(operation.entityId), transform: operation.transform }); break
|
|
74
101
|
case "set-color": entities.set(operation.entityId, applySpatialEntityOverride(authored(operation.entityId), { entityId: operation.entityId, property: "color", value: operation.color })); break
|
|
75
102
|
case "set-opacity": entities.set(operation.entityId, applySpatialEntityOverride(authored(operation.entityId), { entityId: operation.entityId, property: "opacity", value: operation.opacity })); break
|
|
103
|
+
case "set-emissive": {
|
|
104
|
+
const entity = authored(operation.entityId)
|
|
105
|
+
if (entity.kind !== "mesh" || (entity.material.kind !== "standard" && entity.material.kind !== "pbr")) throw new SpatialSceneError("conflict", "Emissive edits require an authored mesh with a standard or pbr material.", "operations")
|
|
106
|
+
if (!spatialPropertySupported(entity, "color")) throw new SpatialSceneError("conflict", "Source-material mode leaves emissive control to the retained GLB material.", "operations")
|
|
107
|
+
if (entity.material.kind === "pbr") {
|
|
108
|
+
const { emissive: _cleared, ...material } = entity.material
|
|
109
|
+
entities.set(operation.entityId, { ...entity, material: operation.emissive === null ? material : { ...material, emissive: { color: operation.emissive.color, intensity: operation.emissive.intensity } } })
|
|
110
|
+
} else {
|
|
111
|
+
const { emissive: _cleared, ...material } = entity.material
|
|
112
|
+
entities.set(operation.entityId, { ...entity, material: operation.emissive === null ? material : { ...material, emissive: operation.emissive } })
|
|
113
|
+
}
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
case "set-spot": {
|
|
117
|
+
const entity = authored(operation.entityId)
|
|
118
|
+
if (entity.kind !== "light" || entity.light !== "spot") throw new SpatialSceneError("conflict", "Spot cone edits require an authored spot light.", "operations")
|
|
119
|
+
entities.set(operation.entityId, { ...entity, spot: operation.spot })
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
case "set-instances": {
|
|
123
|
+
const entity = authored(operation.entityId)
|
|
124
|
+
if (entity.kind !== "mesh") throw new SpatialSceneError("conflict", "Instance edits apply to authored mesh entities.", "operations")
|
|
125
|
+
const { instances: _cleared, ...rest } = entity
|
|
126
|
+
entities.set(operation.entityId, operation.instances === null ? rest : { ...rest, instances: operation.instances })
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
case "set-mesh-shadow": {
|
|
130
|
+
const entity = authored(operation.entityId)
|
|
131
|
+
if (entity.kind !== "mesh") throw new SpatialSceneError("conflict", "Mesh shadow flags apply to authored mesh entities.", "operations")
|
|
132
|
+
const { castShadow: _cast, receiveShadow: _receive, ...rest } = entity
|
|
133
|
+
entities.set(operation.entityId, {
|
|
134
|
+
...rest,
|
|
135
|
+
...(operation.castShadow === null ? {} : { castShadow: operation.castShadow }),
|
|
136
|
+
...(operation.receiveShadow === null ? {} : { receiveShadow: operation.receiveShadow }),
|
|
137
|
+
})
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
case "set-light-shadow": {
|
|
141
|
+
const entity = authored(operation.entityId)
|
|
142
|
+
if (entity.kind !== "light") throw new SpatialSceneError("conflict", "Light shadow flags apply to authored light entities.", "operations")
|
|
143
|
+
if (entity.light === "ambient" && operation.shadow !== null) throw new SpatialSceneError("conflict", "Ambient lights cannot cast shadows.", "operations")
|
|
144
|
+
const { shadow: _shadow, ...rest } = entity
|
|
145
|
+
entities.set(operation.entityId, operation.shadow === null ? rest : { ...rest, shadow: operation.shadow })
|
|
146
|
+
break
|
|
147
|
+
}
|
|
76
148
|
case "set-camera": cameras.set(operation.camera.cameraId, operation.camera); break
|
|
77
149
|
case "set-channel": animations.set(operation.channel.channelId, operation.channel); break
|
|
78
150
|
case "remove-channel":
|
|
@@ -92,10 +164,34 @@ export function applySpatialScenePatch(sceneInput: unknown, patchInput: unknown)
|
|
|
92
164
|
case "replace-generator-output": {
|
|
93
165
|
const generatorId = operation.generator.generatorId
|
|
94
166
|
replacedGenerators.add(generatorId)
|
|
167
|
+
// Owned assets are swapped atomically with retained output: drop the
|
|
168
|
+
// previous owned set, require the declared new set, then add manifests.
|
|
169
|
+
const previous = generators.get(generatorId)?.assets ?? []
|
|
170
|
+
const provided = operation.assets ?? []
|
|
171
|
+
const declared = operation.generator.assets ?? []
|
|
172
|
+
if ([...provided.map(asset => asset.assetId)].sort().join("") !== [...declared].sort().join("")) {
|
|
173
|
+
throw new SpatialSceneError("conflict", "Generator replacement must carry exactly the manifests its record declares.")
|
|
174
|
+
}
|
|
175
|
+
for (const asset of provided) {
|
|
176
|
+
if (asset.provenance.source !== "generated" && asset.provenance.source !== "derived") {
|
|
177
|
+
throw new SpatialSceneError("conflict", "Generator-owned assets require generated or derived provenance.")
|
|
178
|
+
}
|
|
179
|
+
if (assets.has(asset.assetId) && !previous.includes(asset.assetId)) throw new SpatialSceneError("conflict", `Generator asset ${asset.assetId} collides with an existing asset.`)
|
|
180
|
+
}
|
|
181
|
+
for (const assetId of previous) assets.delete(assetId)
|
|
95
182
|
for (const entity of entities.values()) if (entity.origin.kind === "generated" && entity.origin.generatorId === generatorId) entities.delete(entity.entityId)
|
|
183
|
+
for (const asset of provided) assets.set(asset.assetId, asset)
|
|
184
|
+
const owned = new Set(provided.map(asset => asset.assetId))
|
|
185
|
+
const remainingAssets = new Set(assets.keys())
|
|
96
186
|
for (const entity of operation.entities) {
|
|
97
187
|
if (entity.origin.kind !== "generated" || entity.origin.generatorId !== generatorId) throw new SpatialSceneError("conflict", "Generator replacement must contain only its own retained output.")
|
|
98
188
|
if (entities.has(entity.entityId)) throw new SpatialSceneError("conflict", `Generator output collides with ${entity.entityId}.`)
|
|
189
|
+
const referenced: string[] = entity.kind === "mesh" && entity.geometry.kind === "asset" ? [entity.geometry.assetId]
|
|
190
|
+
: entity.kind === "text" ? [entity.fontAssetId] : "assetId" in entity ? [entity.assetId] : []
|
|
191
|
+
if (entity.kind === "mesh" && entity.material.kind !== "pbr" && entity.material.map !== undefined) referenced.push(entity.material.map)
|
|
192
|
+
for (const assetId of referenced) {
|
|
193
|
+
if (!owned.has(assetId) && !remainingAssets.has(assetId)) throw new SpatialSceneError("conflict", `Generator output references missing asset ${assetId}.`)
|
|
194
|
+
}
|
|
99
195
|
entities.set(entity.entityId, entity)
|
|
100
196
|
}
|
|
101
197
|
generators.set(generatorId, operation.generator)
|
|
@@ -107,25 +203,23 @@ export function applySpatialScenePatch(sceneInput: unknown, patchInput: unknown)
|
|
|
107
203
|
const beforeAssets = new Map(original.assets.map(asset => [asset.assetId, asset]))
|
|
108
204
|
const oldClosure = spatialAssetClosureDigests(original.assets), newClosure = spatialAssetClosureDigests(scene.assets)
|
|
109
205
|
for (const entity of scene.entities) {
|
|
110
|
-
const
|
|
111
|
-
: entity.kind === "text" ? entity.fontAssetId : "assetId" in entity ? entity.assetId :
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
206
|
+
const referenced: string[] = entity.kind === "mesh" && entity.geometry.kind === "asset" ? [entity.geometry.assetId]
|
|
207
|
+
: entity.kind === "text" ? [entity.fontAssetId] : "assetId" in entity ? [entity.assetId] : []
|
|
208
|
+
if (entity.kind === "mesh") {
|
|
209
|
+
if (entity.material.kind === "pbr") referenced.push(...pbrMaterialMapAssetIds(entity.material))
|
|
210
|
+
else if (entity.material.map !== undefined) referenced.push(entity.material.map)
|
|
211
|
+
}
|
|
212
|
+
for (const assetId of referenced) {
|
|
213
|
+
if (entity.origin.kind === "generated" && oldClosure[assetId] !== undefined && oldClosure[assetId] !== newClosure[assetId] && !replacedGenerators.has(entity.origin.generatorId)) {
|
|
214
|
+
throw new SpatialSceneError("conflict", "Changing a generated part's asset closure requires explicit retained generator output replacement.")
|
|
215
|
+
}
|
|
115
216
|
}
|
|
116
217
|
if (entity.kind === "mesh" && entity.geometry.kind === "asset" && (entity.geometry.nodeIndex !== undefined || entity.geometry.clip !== undefined)
|
|
117
|
-
&& beforeAssets.has(assetId) && beforeAssets.get(assetId)!.payload.sha256 !== assets.get(assetId)!.payload.sha256
|
|
218
|
+
&& beforeAssets.has(entity.geometry.assetId) && beforeAssets.get(entity.geometry.assetId)!.payload.sha256 !== assets.get(entity.geometry.assetId)!.payload.sha256
|
|
118
219
|
&& !addressedGeometry.has(entity.entityId) && !(entity.origin.kind === "generated" && replacedGenerators.has(entity.origin.generatorId))) {
|
|
119
220
|
throw new SpatialSceneError("conflict", "Replacing addressed GLB bytes requires explicit set-mesh-geometry with the new local node/clip addresses; internal correspondence is not inferred.")
|
|
120
221
|
}
|
|
121
222
|
}
|
|
122
|
-
const diff =
|
|
123
|
-
...diffCollection("assets", original.assets, scene.assets, asset => asset.assetId),
|
|
124
|
-
...diffCollection("entities", original.entities, scene.entities, entity => entity.entityId),
|
|
125
|
-
...diffCollection("cameras", original.cameras, scene.cameras, camera => camera.cameraId),
|
|
126
|
-
...diffCollection("animations", original.animations, scene.animations, channel => channel.channelId),
|
|
127
|
-
...diffCollection("generators", original.generators, scene.generators, generator => generator.generatorId),
|
|
128
|
-
...diffCollection("overrides", original.overrides, scene.overrides, override => `${override.entityId}:${override.property}`),
|
|
129
|
-
]
|
|
223
|
+
const diff = diffSpatialScenes(original, scene)
|
|
130
224
|
return deepFreezeJson({ scene, sceneSha256: spatialValueSha256(scene), diff })
|
|
131
225
|
}
|