@hraness/slopcamera 3.2.8 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-cgpkyjqa.js +3 -0
  59. package/dist/index-f0ptv8dm.js +7 -0
  60. package/dist/index-grk47tgs.js +3 -0
  61. package/dist/index-qry58nj2.js +10 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/package.json +7 -5
  69. package/skills/slopcamera/SKILL.md +6 -1
  70. package/skills/slopcamera/references/directed-scenes.md +22 -2
  71. package/skills/slopcamera/references/gateway-media.md +15 -1
  72. package/skills/slopcamera/references/image-galleries.md +166 -0
  73. package/skills/slopcamera/references/install.md +1 -1
  74. package/skills/slopcamera/references/parametric-design.md +14 -0
  75. package/skills/slopcamera/references/scene-building.md +32 -0
  76. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  77. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  78. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  79. package/src/capability-manifest.ts +361 -0
  80. package/src/cli.ts +222 -4
  81. package/src/code/canonical-json.ts +9 -0
  82. package/src/generate.ts +2 -2
  83. package/src/icon.ts +1111 -0
  84. package/src/image-gallery.ts +1049 -0
  85. package/src/index.ts +11 -0
  86. package/src/mcp/boundary.ts +11 -11
  87. package/src/mcp/index.ts +3 -0
  88. package/src/mcp/server.ts +2 -1
  89. package/src/mcp/tools.ts +1642 -130
  90. package/src/mcp/types.ts +19 -0
  91. package/src/operations.ts +412 -0
  92. package/src/portable-capability-manifest.ts +67 -0
  93. package/src/scene-gallery.ts +184 -0
  94. package/src/spatial-scene/asset-admission.ts +183 -0
  95. package/src/spatial-scene/audit-rendered.ts +692 -0
  96. package/src/spatial-scene/audit.ts +617 -0
  97. package/src/spatial-scene/behavior-audit.ts +252 -0
  98. package/src/spatial-scene/behavior-bake.ts +233 -0
  99. package/src/spatial-scene/behavior-fns.ts +585 -0
  100. package/src/spatial-scene/behavior-gallery.ts +109 -0
  101. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  102. package/src/spatial-scene/behavior-trace.ts +325 -0
  103. package/src/spatial-scene/behavior.ts +618 -0
  104. package/src/spatial-scene/build.ts +632 -0
  105. package/src/spatial-scene/camera-rig.ts +125 -0
  106. package/src/spatial-scene/camera-track.ts +4 -2
  107. package/src/spatial-scene/character.ts +124 -0
  108. package/src/spatial-scene/contracts.ts +75 -7
  109. package/src/spatial-scene/design-templates.ts +351 -0
  110. package/src/spatial-scene/design.ts +327 -0
  111. package/src/spatial-scene/direction-compile.ts +584 -0
  112. package/src/spatial-scene/direction.ts +122 -0
  113. package/src/spatial-scene/effects.ts +149 -0
  114. package/src/spatial-scene/evaluate.ts +109 -29
  115. package/src/spatial-scene/gallery.ts +277 -0
  116. package/src/spatial-scene/generate.ts +239 -0
  117. package/src/spatial-scene/geometry-native.ts +165 -0
  118. package/src/spatial-scene/geometry.ts +1216 -0
  119. package/src/spatial-scene/gltf.ts +600 -77
  120. package/src/spatial-scene/identity.ts +55 -2
  121. package/src/spatial-scene/index.ts +36 -0
  122. package/src/spatial-scene/inspect.ts +35 -6
  123. package/src/spatial-scene/material-lighting.ts +609 -0
  124. package/src/spatial-scene/math.ts +21 -4
  125. package/src/spatial-scene/motion-evidence.ts +88 -0
  126. package/src/spatial-scene/parametric.ts +765 -0
  127. package/src/spatial-scene/particle-preparation.ts +183 -0
  128. package/src/spatial-scene/particle.ts +195 -0
  129. package/src/spatial-scene/patch.ts +109 -15
  130. package/src/spatial-scene/performance.ts +1613 -0
  131. package/src/spatial-scene/probe.ts +137 -0
  132. package/src/spatial-scene/recipe-pack.ts +86 -0
  133. package/src/spatial-scene/render-effects.ts +240 -0
  134. package/src/spatial-scene/review.ts +639 -0
  135. package/src/spatial-scene/simulation-bake.ts +206 -0
  136. package/src/spatial-scene/simulation.ts +187 -0
  137. package/src/spatial-scene/solve.ts +366 -0
  138. package/src/spatial-scene/temporal-audit.ts +354 -0
  139. package/src/version.ts +1 -1
  140. package/dist/index-42zsesc1.js +0 -2257
  141. package/dist/index-7131pg1c.js +0 -1820
  142. package/dist/index-7308egqr.js +0 -48
  143. package/dist/index-8txs6fkn.js +0 -712
  144. package/dist/index-fava6pge.js +0 -192
  145. package/dist/index-h1k0fnjq.js +0 -2363
  146. package/dist/index-r7gdhmsp.js +0 -424
  147. package/dist/index-sh6xbav6.js +0 -1219
  148. package/dist/index-z1w83f81.js +0 -4
  149. package/dist/index-zfnddgay.js +0 -2029
  150. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -0,0 +1,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 assetId = entity.kind === "mesh" && entity.geometry.kind === "asset" ? entity.geometry.assetId
111
- : entity.kind === "text" ? entity.fontAssetId : "assetId" in entity ? entity.assetId : undefined
112
- if (assetId === undefined) continue
113
- if (entity.origin.kind === "generated" && oldClosure[assetId] !== undefined && oldClosure[assetId] !== newClosure[assetId] && !replacedGenerators.has(entity.origin.generatorId)) {
114
- throw new SpatialSceneError("conflict", "Changing a generated part's asset closure requires explicit retained generator output replacement.")
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
  }