@hraness/slopcamera 3.2.7 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +65 -20
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +6 -9
  6. package/apps/desktop/application/default-registry.ts +26 -10
  7. package/apps/desktop/application/operation.ts +14 -5
  8. package/apps/desktop/application/operations/index.ts +4 -1
  9. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  10. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  11. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  12. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  13. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  14. package/apps/desktop/application/registry.ts +2 -2
  15. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  16. package/apps/desktop/application/spatial-assets.ts +160 -31
  17. package/apps/desktop/application/spatial-render.ts +217 -20
  18. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  19. package/apps/desktop/application/spatial-review.ts +361 -0
  20. package/apps/desktop/application/spatial-spz.ts +48 -3
  21. package/apps/desktop/application/spatial-world-import.ts +26 -5
  22. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  23. package/apps/desktop/cli/args.ts +528 -97
  24. package/apps/desktop/cli/capability-manifest.ts +374 -0
  25. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  26. package/apps/desktop/cli/command-host-resources.ts +31 -2
  27. package/apps/desktop/cli/commands.ts +1215 -70
  28. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  29. package/apps/desktop/cli/help.ts +183 -23
  30. package/apps/desktop/cli/html-scene.ts +139 -28
  31. package/apps/desktop/cli/io.ts +2 -1
  32. package/apps/desktop/cli/main.ts +26 -50
  33. package/apps/desktop/cli/menubar.ts +244 -0
  34. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  35. package/apps/desktop/cli/portable-surface.ts +18 -5
  36. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  37. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  38. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  39. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  40. package/apps/desktop/cli/workflow-code.ts +1 -1
  41. package/apps/desktop/code/application-node-planner.ts +0 -47
  42. package/apps/desktop/code/host-source-layout.ts +46 -0
  43. package/apps/desktop/code/public.ts +0 -1
  44. package/apps/desktop/code/runtime-identity.ts +50 -8
  45. package/apps/desktop/code/semantic-builder.ts +24 -43
  46. package/apps/desktop/code/source-bundle.ts +23 -6
  47. package/apps/desktop/code/source-typecheck.ts +131 -43
  48. package/apps/desktop/code/worker-client.ts +14 -2
  49. package/apps/desktop/contracts/cinema.ts +736 -0
  50. package/apps/desktop/contracts/index.ts +1 -0
  51. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  52. package/apps/desktop/contracts/spatial-world.ts +26 -3
  53. package/apps/desktop/core/cinema-plan.ts +1320 -0
  54. package/apps/desktop/core/index.ts +1 -0
  55. package/apps/desktop/core/music-analysis.ts +124 -1
  56. package/apps/desktop/core/storage.ts +75 -0
  57. package/apps/desktop/dist/cli/main.js +737 -291
  58. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  59. package/apps/desktop/html-overlay/index.ts +9 -0
  60. package/apps/desktop/html-overlay/runtime.ts +2 -0
  61. package/apps/desktop/html-overlay/scene.ts +29 -0
  62. package/apps/desktop/html-overlay/spatial.ts +619 -57
  63. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  64. package/apps/desktop/workflows/index.ts +6 -0
  65. package/dist/cli.js +32 -434
  66. package/dist/code/advanced.js +1 -269
  67. package/dist/code/index.js +16 -2239
  68. package/dist/generate.js +1 -23
  69. package/dist/host-resources.js +1 -25
  70. package/dist/index-0bbesn4m.js +35 -0
  71. package/dist/index-296qhpf2.js +3 -0
  72. package/dist/index-3cxf2c58.js +3 -0
  73. package/dist/index-77fjfg9f.js +3 -0
  74. package/dist/index-80a36bc0.js +3 -0
  75. package/dist/index-a3vvs3rf.js +3 -0
  76. package/dist/index-b3geqczn.js +5 -0
  77. package/dist/index-cgpkyjqa.js +3 -0
  78. package/dist/index-f0ptv8dm.js +7 -0
  79. package/dist/index-grk47tgs.js +3 -0
  80. package/dist/index-qry58nj2.js +10 -0
  81. package/dist/index.js +1 -240
  82. package/dist/operations.js +1 -28
  83. package/dist/skill-install-gx7afxsd.js +2 -0
  84. package/dist/vectorize/worker.js +1 -141
  85. package/dist/workflow.js +1 -15
  86. package/docs/README.md +5 -1
  87. package/docs/studio.md +1 -1
  88. package/package.json +11 -24
  89. package/skills/slopcamera/SKILL.md +14 -1
  90. package/skills/slopcamera/references/diagrams.md +5 -14
  91. package/skills/slopcamera/references/directed-scenes.md +22 -2
  92. package/skills/slopcamera/references/gateway-media.md +15 -1
  93. package/skills/slopcamera/references/image-galleries.md +166 -0
  94. package/skills/slopcamera/references/install.md +2 -2
  95. package/skills/slopcamera/references/music-video.md +23 -3
  96. package/skills/slopcamera/references/parametric-design.md +14 -0
  97. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  98. package/skills/slopcamera/references/scene-building.md +32 -0
  99. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  100. package/skills/slopcamera/references/support.md +25 -0
  101. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  102. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  103. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  104. package/src/capability-manifest.ts +361 -0
  105. package/src/cli.ts +250 -102
  106. package/src/code/canonical-json.ts +9 -0
  107. package/src/generate.ts +2 -2
  108. package/src/icon.ts +1111 -0
  109. package/src/image-gallery.ts +1049 -0
  110. package/src/index.ts +11 -23
  111. package/src/mcp/boundary.ts +11 -11
  112. package/src/mcp/index.ts +3 -0
  113. package/src/mcp/server.ts +2 -1
  114. package/src/mcp/tools.ts +1642 -130
  115. package/src/mcp/types.ts +19 -0
  116. package/src/operations.ts +412 -0
  117. package/src/portable-capability-manifest.ts +67 -0
  118. package/src/scene-gallery.ts +184 -0
  119. package/src/spatial-scene/asset-admission.ts +183 -0
  120. package/src/spatial-scene/audit-rendered.ts +692 -0
  121. package/src/spatial-scene/audit.ts +617 -0
  122. package/src/spatial-scene/behavior-audit.ts +252 -0
  123. package/src/spatial-scene/behavior-bake.ts +233 -0
  124. package/src/spatial-scene/behavior-fns.ts +585 -0
  125. package/src/spatial-scene/behavior-gallery.ts +109 -0
  126. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  127. package/src/spatial-scene/behavior-trace.ts +325 -0
  128. package/src/spatial-scene/behavior.ts +618 -0
  129. package/src/spatial-scene/build.ts +632 -0
  130. package/src/spatial-scene/camera-rig.ts +125 -0
  131. package/src/spatial-scene/camera-track.ts +4 -2
  132. package/src/spatial-scene/character.ts +124 -0
  133. package/src/spatial-scene/contracts.ts +75 -7
  134. package/src/spatial-scene/design-templates.ts +351 -0
  135. package/src/spatial-scene/design.ts +327 -0
  136. package/src/spatial-scene/direction-compile.ts +584 -0
  137. package/src/spatial-scene/direction.ts +122 -0
  138. package/src/spatial-scene/effects.ts +149 -0
  139. package/src/spatial-scene/evaluate.ts +109 -29
  140. package/src/spatial-scene/gallery.ts +277 -0
  141. package/src/spatial-scene/generate.ts +239 -0
  142. package/src/spatial-scene/geometry-native.ts +165 -0
  143. package/src/spatial-scene/geometry.ts +1216 -0
  144. package/src/spatial-scene/gltf.ts +600 -77
  145. package/src/spatial-scene/identity.ts +55 -2
  146. package/src/spatial-scene/index.ts +36 -0
  147. package/src/spatial-scene/inspect.ts +35 -6
  148. package/src/spatial-scene/material-lighting.ts +609 -0
  149. package/src/spatial-scene/math.ts +21 -4
  150. package/src/spatial-scene/motion-evidence.ts +88 -0
  151. package/src/spatial-scene/parametric.ts +765 -0
  152. package/src/spatial-scene/particle-preparation.ts +183 -0
  153. package/src/spatial-scene/particle.ts +195 -0
  154. package/src/spatial-scene/patch.ts +109 -15
  155. package/src/spatial-scene/performance.ts +1613 -0
  156. package/src/spatial-scene/probe.ts +137 -0
  157. package/src/spatial-scene/recipe-pack.ts +86 -0
  158. package/src/spatial-scene/render-effects.ts +240 -0
  159. package/src/spatial-scene/review.ts +639 -0
  160. package/src/spatial-scene/simulation-bake.ts +206 -0
  161. package/src/spatial-scene/simulation.ts +187 -0
  162. package/src/spatial-scene/solve.ts +366 -0
  163. package/src/spatial-scene/temporal-audit.ts +354 -0
  164. package/src/support-completion.ts +16 -0
  165. package/src/support.ts +42 -0
  166. package/src/version.ts +1 -1
  167. package/apps/desktop/application/operations/recording/index.ts +0 -5
  168. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  169. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  170. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  171. package/apps/desktop/application/operations/recording/start.ts +0 -169
  172. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  173. package/apps/desktop/capture/protocol.ts +0 -689
  174. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  175. package/apps/desktop/cli/recording-controller.ts +0 -1567
  176. package/apps/desktop/cli/recording-daemon.ts +0 -931
  177. package/dist/index-42zsesc1.js +0 -2257
  178. package/dist/index-7308egqr.js +0 -48
  179. package/dist/index-8txs6fkn.js +0 -712
  180. package/dist/index-mcy8z0br.js +0 -192
  181. package/dist/index-p63wavx0.js +0 -2029
  182. package/dist/index-r7gdhmsp.js +0 -424
  183. package/dist/index-rvghzt7b.js +0 -2013
  184. package/dist/index-sh6xbav6.js +0 -1219
  185. package/dist/index-z1w83f81.js +0 -4
  186. package/dist/index-z7239b4h.js +0 -2363
  187. package/dist/skill-install-dh0ntqba.js +0 -10
  188. package/src/desktop.ts +0 -253
@@ -0,0 +1,765 @@
1
+ import { z } from "zod"
2
+ import { canonicalJson } from "../code/canonical-json.js"
3
+ import { deepFreezeJson } from "../code/json-snapshot.js"
4
+ import { createSha256HexHasher } from "../code/sha256.js"
5
+ import {
6
+ SpatialAssetManifestSchema,
7
+ SpatialEntitySchema,
8
+ SpatialGeneratorIdSchema,
9
+ SpatialMaterialSchema,
10
+ SpatialTransformSchema,
11
+ type SpatialAssetManifest,
12
+ type SpatialEntity,
13
+ type SpatialGenerator,
14
+ type SpatialMaterial,
15
+ type SpatialSceneV1,
16
+ type SpatialTransform,
17
+ } from "./contracts.js"
18
+ import { SpatialAssetFactsV1Schema, SpatialCollisionProxySchema, type SpatialAssetFactsV1, type SpatialCollisionProxy, type SpatialRetainedArtifact } from "./asset-admission.js"
19
+ import { mulberry32 } from "./build.js"
20
+ import {
21
+ SPATIAL_GEOMETRY_PROFILE, SpatialGeometryGraphSchema, emitSpatialGeometryGlb, evaluateSpatialGeometry, estimateSpatialGeometryGraph, parseSpatialGeometryGraph,
22
+ type SpatialGeometryMesh,
23
+ } from "./geometry.js"
24
+ import {
25
+ validateSpatialGeometryNativeReceipt, verifySpatialGeometryNativeOutputs,
26
+ spatialGeometryNativeRequestSha256, spatialGeometryNativeReceiptSha256,
27
+ } from "./geometry-native.js"
28
+ import {
29
+ generatedSpatialEntityId, parseSpatialScene, parseSpatialValue, SpatialSceneError,
30
+ spatialAssetManifestSha256, spatialValueSha256,
31
+ } from "./identity.js"
32
+ import {
33
+ buildSpatialGeneratorRecord, deriveSpatialGeneratorSeed, mergeSpatialGeneratorOutput,
34
+ spatialGeneratorParametersSha256,
35
+ } from "./generate.js"
36
+ import { evaluateSpatialScene } from "./evaluate.js"
37
+ import { transformBounds, type Bounds, type Mat4 } from "./math.js"
38
+
39
+ /**
40
+ * Bounded parametric architecture generators. Each kind compiles authored
41
+ * semantic parameters (width, archCount, bevelRadius, seed, …) into the
42
+ * geometry DAG of geometry.ts, evaluates it deterministically, emits a closed
43
+ * GLB payload per LOD level, stamps stable generated entity ids and generator
44
+ * keys, and binds everything into the same retained-asset contract as admitted
45
+ * GLBs: owned asset manifests, derived facts manifests (generator identity,
46
+ * spec/parameter digests, LOD set, collision proxies, retained artifacts) and a
47
+ * content-addressed emission receipt. Identical specs produce byte-identical
48
+ * payloads and receipts; scatter uses only authored seeds through the
49
+ * checked-in mulberry32 stream. Nothing here executes source, reads files, or
50
+ * touches a provider.
51
+ */
52
+ export const SPATIAL_PARAMETRIC_LIMITS = Object.freeze({
53
+ openings: 8,
54
+ pathPoints: 64,
55
+ scatterCount: 1_024,
56
+ exclusions: 64,
57
+ panes: 8,
58
+ risers: 200,
59
+ archCount: 64,
60
+ parts: 64,
61
+ scatterAttempts: 128,
62
+ })
63
+
64
+ const meter = z.number().finite().min(0.000001).max(1_000_000)
65
+ const coordinate = z.number().finite().min(-1_000_000).max(1_000_000)
66
+ const specVec2 = z.tuple([coordinate, coordinate])
67
+ const specVec3 = z.tuple([coordinate, coordinate, coordinate])
68
+ const editable = z.array(z.enum(["color", "opacity", "transform"])).max(3).optional()
69
+ const partPlacement = { transform: SpatialTransformSchema.optional(), editable, castShadow: z.boolean().optional(), receiveShadow: z.boolean().optional() }
70
+
71
+ const opening = z.discriminatedUnion("kind", [
72
+ z.strictObject({ kind: z.literal("rect"), center: specVec2, width: meter, height: meter }),
73
+ z.strictObject({ kind: z.literal("arch"), center: specVec2, width: meter, height: meter }),
74
+ ])
75
+
76
+ export const SpatialParametricSpecSchema = z.discriminatedUnion("kind", [
77
+ z.strictObject({ ...partPlacement, kind: z.literal("geometry"), graph: SpatialGeometryGraphSchema,
78
+ materials: z.array(SpatialMaterialSchema).min(1).max(16) }),
79
+ z.strictObject({ ...partPlacement, kind: z.literal("wall"), length: meter, height: meter, thickness: meter,
80
+ openings: z.array(opening).max(SPATIAL_PARAMETRIC_LIMITS.openings).optional(), material: SpatialMaterialSchema }),
81
+ z.strictObject({ ...partPlacement, kind: z.literal("floor"), width: meter, depth: meter, thickness: meter, material: SpatialMaterialSchema }),
82
+ z.strictObject({ ...partPlacement, kind: z.literal("stairs"), width: meter, risers: z.number().int().min(2).max(SPATIAL_PARAMETRIC_LIMITS.risers),
83
+ riserHeight: meter, treadDepth: meter, material: SpatialMaterialSchema }),
84
+ z.strictObject({ ...partPlacement, kind: z.literal("arch"), width: meter, height: meter, springline: meter, depth: meter,
85
+ count: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.archCount).optional(), spacing: meter.optional(), material: SpatialMaterialSchema }),
86
+ z.strictObject({ ...partPlacement, kind: z.literal("column"), height: meter, radius: meter,
87
+ taper: z.number().finite().min(0.25).max(1).optional(), capital: z.enum(["none", "doric"]).optional(),
88
+ segments: z.number().int().min(8).max(128).optional(), material: SpatialMaterialSchema }),
89
+ z.strictObject({ ...partPlacement, kind: z.literal("window"), width: meter, height: meter, frameWidth: meter, depth: meter,
90
+ panesX: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.panes).optional(), panesY: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.panes).optional(),
91
+ sill: z.boolean().optional(), material: SpatialMaterialSchema, glassMaterial: SpatialMaterialSchema.optional() }),
92
+ z.strictObject({ ...partPlacement, kind: z.literal("roof"), style: z.enum(["gable", "hip", "shed"]), width: meter, depth: meter,
93
+ rise: meter, overhang: meter.optional(), material: SpatialMaterialSchema }),
94
+ z.strictObject({ ...partPlacement, kind: z.literal("pipe"), radius: meter, segments: z.number().int().min(4).max(64).optional(),
95
+ path: z.array(specVec3).min(2).max(SPATIAL_PARAMETRIC_LIMITS.pathPoints), material: SpatialMaterialSchema }),
96
+ z.strictObject({ ...partPlacement, kind: z.literal("trim"), length: meter, size: meter, profile: z.enum(["square", "cove", "chamfer"]),
97
+ material: SpatialMaterialSchema }),
98
+ z.strictObject({ ...partPlacement, kind: z.literal("scatter"), count: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.scatterCount),
99
+ area: z.strictObject({ width: meter, depth: meter }), seed: z.number().int().safe().min(0).max(0xffff_ffff),
100
+ subject: z.discriminatedUnion("shape", [
101
+ z.strictObject({ shape: z.literal("box"), size: z.tuple([meter, meter, meter]) }),
102
+ z.strictObject({ shape: z.literal("cylinder"), radius: meter, height: meter, segments: z.number().int().min(8).max(64).optional() }),
103
+ z.strictObject({ shape: z.literal("sphere"), radius: meter, segments: z.number().int().min(8).max(64).optional() }),
104
+ ]),
105
+ exclusions: z.array(z.strictObject({ center: specVec2, halfExtents: specVec2 })).max(SPATIAL_PARAMETRIC_LIMITS.exclusions).optional(),
106
+ material: SpatialMaterialSchema }),
107
+ ])
108
+ export type SpatialParametricSpec = Readonly<z.infer<typeof SpatialParametricSpecSchema>>
109
+
110
+ export const SpatialParametricRequestSchema = z.strictObject({
111
+ kind: z.literal("slopcamera.spatial-parametric-request"),
112
+ schemaVersion: z.literal(1),
113
+ generatorId: SpatialGeneratorIdSchema,
114
+ /** Semantic display name; generated identity and payload bytes remain independent of labels. */
115
+ name: z.string().min(1).max(160).optional(),
116
+ spec: SpatialParametricSpecSchema,
117
+ /** Authored seed override; absent derives deterministically from the spec kind source. */
118
+ seed: z.number().int().safe().min(0).max(0xffff_ffff).optional(),
119
+ /** Collision proxies override the per-part automatic proxies (validated against retained artifacts). */
120
+ collision: z.array(SpatialCollisionProxySchema).max(16).optional(),
121
+ /** Optional verified retained-native exchange bound into every part's facts. */
122
+ native: z.strictObject({ request: z.unknown(), receipt: z.unknown(), outputs: z.array(z.instanceof(Uint8Array)).min(1).max(16) }).optional(),
123
+ })
124
+ export type SpatialParametricRequest = Readonly<z.infer<typeof SpatialParametricRequestSchema>>
125
+
126
+ const IDENTITY_TRANSFORM: SpatialTransform = { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }
127
+
128
+ function pfail(message: string, path = "parametric"): never {
129
+ throw new SpatialSceneError("invalid-data", `${SPATIAL_GEOMETRY_PROFILE}: ${message}`, path)
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Part planning: one part per spec (multi-slot parts share one GLB)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ interface PartLod { readonly level: number; readonly switchDistanceM: number; readonly graph: unknown }
137
+ interface PartPlan {
138
+ readonly key: string
139
+ readonly transform: SpatialTransform
140
+ readonly editable: readonly ("color" | "opacity" | "transform")[]
141
+ readonly materials: readonly SpatialMaterial[]
142
+ readonly collision: readonly SpatialCollisionProxy[]
143
+ readonly lods: readonly PartLod[]
144
+ readonly instances?: readonly SpatialTransform[]
145
+ }
146
+
147
+ const graph = (nodes: readonly unknown[], output: string) => ({ kind: "slopcamera.spatial-geometry-graph", schemaVersion: 1, nodes, output })
148
+
149
+ function boxBounds(bounds: Bounds): SpatialCollisionProxy {
150
+ return {
151
+ kind: "box",
152
+ center: [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2],
153
+ halfExtents: [Math.max(0.0005, (bounds.max[0] - bounds.min[0]) / 2), Math.max(0.0005, (bounds.max[1] - bounds.min[1]) / 2), Math.max(0.0005, (bounds.max[2] - bounds.min[2]) / 2)],
154
+ }
155
+ }
156
+
157
+ const LOD1_DISTANCE_M = 25
158
+
159
+ function planWall(spec: Extract<SpatialParametricSpec, { kind: "wall" }>): PartPlan[] {
160
+ const { length, height, thickness } = spec
161
+ const openings = spec.openings ?? []
162
+ const cutterNodes: unknown[] = [], cutterIds: string[] = []
163
+ for (const [index, item] of openings.entries()) {
164
+ const cx = item.center[0], baseY = item.center[1]
165
+ if (cx - item.width / 2 < -length / 2 || cx + item.width / 2 > length / 2 || baseY < 0 || baseY + item.height > height) {
166
+ pfail(`Wall opening ${index} must stay inside the wall rectangle.`, "spec.openings")
167
+ }
168
+ const rectId = `cut${index}`
169
+ cutterNodes.push({ id: rectId, kind: "box", size: [item.width, item.height, thickness * 4] })
170
+ cutterNodes.push({ id: `${rectId}p`, kind: "transform", input: rectId, transform: { position: [cx, baseY + item.height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
171
+ cutterIds.push(`${rectId}p`)
172
+ if (item.kind === "arch") {
173
+ const radius = item.width / 2
174
+ if (radius > item.height) pfail(`Arch opening ${index} rise exceeds its height.`, "spec.openings")
175
+ const cylId = `cyl${index}`
176
+ // Half-cylinder lintel: cylinder rotated so its axis runs through the wall thickness.
177
+ cutterNodes.push({ id: cylId, kind: "cylinder", radius, height: thickness * 4, segments: 24 })
178
+ cutterNodes.push({ id: `${cylId}r`, kind: "transform", input: cylId, transform: { position: [cx, baseY + item.height - radius, 0], rotation: [Math.SQRT1_2, 0, 0, Math.SQRT1_2], scale: [1, 1, 1] } })
179
+ cutterIds.push(`${cylId}r`)
180
+ }
181
+ }
182
+ const nodes: unknown[] = [{ id: "body", kind: "box", size: [length, height, thickness] }]
183
+ let output = "body"
184
+ if (cutterIds.length > 0) {
185
+ nodes.push(...cutterNodes)
186
+ nodes.push({ id: "wall", kind: "boolean", operation: "difference", a: "body", cutters: cutterIds })
187
+ output = "wall"
188
+ }
189
+ // Base at y=0: lift the centered box by half the height.
190
+ nodes.push({ id: "lift", kind: "transform", input: output, transform: { position: [0, height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
191
+ const lod1 = graph([
192
+ { id: "proxy", kind: "box", size: [length, height, thickness] },
193
+ { id: "lift", kind: "transform", input: "proxy", transform: { position: [0, height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
194
+ ], "lift")
195
+ return [{
196
+ key: "wall", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
197
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "lift") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
198
+ collision: [],
199
+ }]
200
+ }
201
+
202
+ function planFloor(spec: Extract<SpatialParametricSpec, { kind: "floor" }>): PartPlan[] {
203
+ const nodes = [
204
+ { id: "slab", kind: "box", size: [spec.width, spec.thickness, spec.depth] },
205
+ { id: "drop", kind: "transform", input: "slab", transform: { position: [0, -spec.thickness / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
206
+ ]
207
+ return [{
208
+ key: "floor", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
209
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "drop") }],
210
+ collision: [],
211
+ }]
212
+ }
213
+
214
+ function planStairs(spec: Extract<SpatialParametricSpec, { kind: "stairs" }>): PartPlan[] {
215
+ const points: [number, number][] = [[0, 0]]
216
+ let x = 0, y = 0
217
+ for (let step = 0; step < spec.risers; step++) {
218
+ y += spec.riserHeight; points.push([x, y])
219
+ x += spec.treadDepth; points.push([x, y])
220
+ }
221
+ points.push([x, 0])
222
+ const run = spec.risers * spec.treadDepth, rise = spec.risers * spec.riserHeight
223
+ const nodes = [
224
+ { id: "flight", kind: "profile", points },
225
+ { id: "mesh", kind: "extrude", profile: "flight", depth: spec.width },
226
+ // Recenter the extrusion (depth axis is centered already) so the run starts at x=0 stays centered.
227
+ { id: "shift", kind: "transform", input: "mesh", transform: { position: [-run / 2, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
228
+ ]
229
+ const lod1 = graph([
230
+ { id: "proxy", kind: "box", size: [run, rise, spec.width] },
231
+ { id: "lift", kind: "transform", input: "proxy", transform: { position: [0, rise / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
232
+ ], "lift")
233
+ return [{
234
+ key: "stairs", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
235
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "shift") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
236
+ collision: [],
237
+ }]
238
+ }
239
+
240
+ function archProfile(width: number, height: number, springline: number, leg: number, arcSegments: number): [number, number][] {
241
+ const half = width / 2, outer = half + leg
242
+ const points: [number, number][] = [[-outer, 0], [-outer, height], [outer, height], [outer, 0], [half, 0], [half, springline]]
243
+ for (let index = 1; index <= arcSegments; index++) {
244
+ const angle = Math.PI * index / arcSegments
245
+ points.push([half * Math.cos(angle), springline + half * Math.sin(angle)])
246
+ }
247
+ points.push([-half, 0])
248
+ return points
249
+ }
250
+
251
+ function planArch(spec: Extract<SpatialParametricSpec, { kind: "arch" }>): PartPlan[] {
252
+ const count = spec.count ?? 1, spacing = spec.spacing ?? spec.width
253
+ if (spec.springline >= spec.height) pfail("Arch springline must stay below its apex height.", "spec.springline")
254
+ // Equality pinches the polygon at the crown and creates a self-intersection.
255
+ if (spec.height - spec.springline <= spec.width / 2 + 1e-9) {
256
+ pfail("Arch apex height must leave positive material above the opening radius.", "spec.height")
257
+ }
258
+ const leg = Math.min(spec.width * 0.25, spec.depth)
259
+ const points = archProfile(spec.width, spec.height, spec.springline, leg, 16)
260
+ const nodes: unknown[] = [
261
+ { id: "profile", kind: "profile", points },
262
+ { id: "one", kind: "extrude", profile: "profile", depth: spec.depth },
263
+ ]
264
+ let output = "one"
265
+ const span = spec.width + 2 * leg
266
+ if (count > 1) nodes.push({ id: "row", kind: "array", input: "one", count, step: [span + spacing, 0, 0] })
267
+ if (count > 1) output = "row"
268
+ nodes.push({ id: "center", kind: "transform", input: output, transform: { position: [-((count - 1) * (span + spacing)) / 2, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
269
+ const lod1 = graph([
270
+ { id: "proxy", kind: "box", size: [span * count + spacing * (count - 1), spec.height, spec.depth] },
271
+ { id: "lift", kind: "transform", input: "proxy", transform: { position: [0, spec.height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
272
+ ], "lift")
273
+ return [{
274
+ key: "arch", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
275
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "center") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
276
+ collision: [],
277
+ }]
278
+ }
279
+
280
+ function planColumn(spec: Extract<SpatialParametricSpec, { kind: "column" }>): PartPlan[] {
281
+ const segments = spec.segments ?? 32, taper = spec.taper ?? 0.85, capital = spec.capital ?? "doric"
282
+ const r = spec.radius, h = spec.height
283
+ const profile: [number, number][] = capital === "doric"
284
+ ? [[r * 1.15, 0], [r * 1.15, h * 0.05], [r, h * 0.08], [r * taper, h * 0.9], [r * taper, h * 0.94], [r * 1.18, h * 0.97], [r * 1.18, h], [0, h], [0, 0]]
285
+ : [[r * 1.1, 0], [r * 1.1, h * 0.05], [r, h * 0.08], [r * taper, h * 0.96], [r * taper, h], [0, h], [0, 0]]
286
+ const nodes = [
287
+ { id: "silhouette", kind: "profile", points: profile },
288
+ { id: "shaft", kind: "revolve", profile: "silhouette", segments },
289
+ ]
290
+ const lod1Profile: [number, number][] = [[r * 1.18, 0], [r * 1.18, h], [0, h], [0, 0]]
291
+ const lod1 = graph([
292
+ { id: "silhouette", kind: "profile", points: lod1Profile },
293
+ { id: "shaft", kind: "revolve", profile: "silhouette", segments: Math.max(8, Math.floor(segments / 4)) },
294
+ ], "shaft")
295
+ return [{
296
+ key: "column", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
297
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "shaft") }, { level: 1, switchDistanceM: 20, graph: lod1 }],
298
+ collision: [],
299
+ }]
300
+ }
301
+
302
+ function planWindow(spec: Extract<SpatialParametricSpec, { kind: "window" }>): PartPlan[] {
303
+ const { width, height, frameWidth, depth } = spec
304
+ const panesX = spec.panesX ?? 2, panesY = spec.panesY ?? 1
305
+ const nodes: unknown[] = []
306
+ const box = (id: string, size: [number, number, number], position: [number, number, number]) => {
307
+ nodes.push({ id, kind: "box", size })
308
+ nodes.push({ id: `${id}t`, kind: "transform", input: id, transform: { position, rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
309
+ return `${id}t`
310
+ }
311
+ const hw = width / 2, hh = height / 2, fw = frameWidth / 2
312
+ const frame = [
313
+ box("top", [width, frameWidth, depth], [0, hh - fw, 0]),
314
+ box("bottom", [width, frameWidth, depth], [0, -hh + fw, 0]),
315
+ box("left", [frameWidth, height - 2 * frameWidth, depth], [-hw + fw, 0, 0]),
316
+ box("right", [frameWidth, height - 2 * frameWidth, depth], [hw - fw, 0, 0]),
317
+ ]
318
+ if (spec.sill) frame.push(box("sill", [width + 2 * frameWidth, frameWidth, depth * 1.5], [0, -hh - frameWidth / 2, 0]))
319
+ const bars: string[] = []
320
+ for (let index = 1; index < panesX; index++) bars.push(box(`vx${index}`, [frameWidth * 0.5, height - 2 * frameWidth, depth * 0.5], [-hw + frameWidth + index * (width - 2 * frameWidth) / panesX, 0, 0]))
321
+ for (let index = 1; index < panesY; index++) bars.push(box(`hy${index}`, [width - 2 * frameWidth, frameWidth * 0.5, depth * 0.5], [0, -hh + frameWidth + index * (height - 2 * frameWidth) / panesY, 0]))
322
+ nodes.push({ id: "frame", kind: "merge", inputs: [...frame, ...bars] })
323
+ nodes.push({ id: "glass", kind: "box", size: [width - 2 * frameWidth, height - 2 * frameWidth, depth * 0.15] })
324
+ nodes.push({ id: "slot1", kind: "material-slot", input: "glass", slot: 1 })
325
+ nodes.push({ id: "all", kind: "merge", inputs: ["frame", "slot1"] })
326
+ nodes.push({ id: "lift", kind: "transform", input: "all", transform: { position: [0, hh, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
327
+ const lod1 = graph([
328
+ { id: "proxy", kind: "box", size: [width, height + (spec.sill ? frameWidth : 0), depth * (spec.sill ? 1.5 : 1)] },
329
+ { id: "lift", kind: "transform", input: "proxy", transform: { position: [0, (height + (spec.sill ? frameWidth : 0)) / 2 - (spec.sill ? frameWidth : 0), 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
330
+ ], "lift")
331
+ const glass = spec.glassMaterial ?? { kind: "standard" as const, color: "#a8c8e0", opacity: 0.4, roughness: 0.1, metalness: 0 }
332
+ return [{
333
+ key: "window", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
334
+ materials: [spec.material, glass], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "lift") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
335
+ collision: [],
336
+ }]
337
+ }
338
+
339
+ function planRoof(spec: Extract<SpatialParametricSpec, { kind: "roof" }>): PartPlan[] {
340
+ const { width, depth, rise } = spec, overhang = spec.overhang ?? 0
341
+ const w = width + 2 * overhang, d = depth + 2 * overhang
342
+ let nodes: unknown[]
343
+ if (spec.style === "gable") {
344
+ nodes = [
345
+ { id: "face", kind: "profile", points: [[-w / 2, 0], [w / 2, 0], [0, rise]] },
346
+ { id: "prism", kind: "extrude", profile: "face", depth: d },
347
+ ]
348
+ } else if (spec.style === "shed") {
349
+ nodes = [
350
+ { id: "face", kind: "profile", points: [[-w / 2, 0], [w / 2, 0], [w / 2, rise]] },
351
+ { id: "prism", kind: "extrude", profile: "face", depth: d },
352
+ ]
353
+ } else {
354
+ const ridge = Math.max(0.02, Math.abs(w - d))
355
+ const alongX = w >= d
356
+ nodes = [
357
+ { id: "eave", kind: "rect", width: w, height: d },
358
+ { id: "ridge", kind: "rect", width: alongX ? ridge : 0.02, height: alongX ? 0.02 : ridge },
359
+ { id: "hip", kind: "loft", bottom: "eave", top: "ridge", height: rise },
360
+ ]
361
+ }
362
+ const lod1 = graph([
363
+ { id: "proxy", kind: "box", size: [w, rise, d] },
364
+ { id: "lift", kind: "transform", input: "proxy", transform: { position: [0, rise / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
365
+ ], "lift")
366
+ return [{
367
+ key: "roof", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
368
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, spec.style === "hip" ? "hip" : "prism") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
369
+ collision: [],
370
+ }]
371
+ }
372
+
373
+ function planPipe(spec: Extract<SpatialParametricSpec, { kind: "pipe" }>): PartPlan[] {
374
+ const segments = spec.segments ?? 12
375
+ const nodes = [
376
+ { id: "section", kind: "ellipse", radiusX: spec.radius, radiusY: spec.radius, segments },
377
+ { id: "run", kind: "sweep", profile: "section", path: spec.path },
378
+ ]
379
+ const lod1 = graph([
380
+ { id: "section", kind: "ellipse", radiusX: spec.radius, radiusY: spec.radius, segments: Math.max(4, Math.floor(segments / 4)) },
381
+ { id: "run", kind: "sweep", profile: "section", path: spec.path },
382
+ ], "run")
383
+ const collision: SpatialCollisionProxy[] = []
384
+ const path = spec.path
385
+ for (let index = 0; index + 1 < path.length && collision.length < 16; index++) {
386
+ const a = path[index]!, c = path[index + 1]!
387
+ const delta = [c[0] - a[0], c[1] - a[1], c[2] - a[2]] as const
388
+ const length = Math.hypot(delta[0], delta[1], delta[2])
389
+ if (length < 1e-9) continue
390
+ const axisIndex = [Math.abs(delta[0]), Math.abs(delta[1]), Math.abs(delta[2])].reduce((best, value, axis) => value > Math.abs(delta[best]!) ? axis : best, 0)
391
+ collision.push({
392
+ kind: "capsule",
393
+ center: [(a[0] + c[0]) / 2, (a[1] + c[1]) / 2, (a[2] + c[2]) / 2],
394
+ axis: (["x", "y", "z"] as const)[axisIndex]!, radius: spec.radius,
395
+ halfLength: length / 2 + spec.radius,
396
+ })
397
+ }
398
+ return [{
399
+ key: "pipe", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
400
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "run") }, { level: 1, switchDistanceM: 20, graph: lod1 }],
401
+ collision,
402
+ }]
403
+ }
404
+
405
+ function planTrim(spec: Extract<SpatialParametricSpec, { kind: "trim" }>): PartPlan[] {
406
+ const s = spec.size
407
+ const points: [number, number][] = spec.profile === "chamfer"
408
+ ? [[0, 0], [s, 0], [s, s - s * 0.3], [s - s * 0.3, s], [0, s]]
409
+ : spec.profile === "cove"
410
+ ? [[0, 0], [s, 0], [s, s * 0.35], [s * 0.85, s * 0.5], [s * 0.6, s * 0.68], [s * 0.35, s], [0, s]]
411
+ : [[0, 0], [s, 0], [s, s], [0, s]]
412
+ const nodes = [
413
+ { id: "section", kind: "profile", points },
414
+ { id: "rail", kind: "extrude", profile: "section", depth: spec.length },
415
+ ]
416
+ const lod1 = graph([
417
+ { id: "proxy", kind: "box", size: [s, s, spec.length] },
418
+ { id: "lift", kind: "transform", input: "proxy", transform: { position: [s / 2, s / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
419
+ ], "lift")
420
+ return [{
421
+ key: "trim", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
422
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "rail") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
423
+ collision: [],
424
+ }]
425
+ }
426
+
427
+ function yawQuaternion(radians: number): readonly [number, number, number, number] {
428
+ return [0, Math.sin(radians / 2), 0, Math.cos(radians / 2)]
429
+ }
430
+
431
+ function planScatter(spec: Extract<SpatialParametricSpec, { kind: "scatter" }>): PartPlan[] {
432
+ const subject = spec.subject
433
+ const subjectNode = subject.shape === "box"
434
+ ? { id: "subject", kind: "box", size: subject.size }
435
+ : subject.shape === "cylinder"
436
+ ? { id: "subject", kind: "cylinder", radius: subject.radius, height: subject.height, segments: subject.segments ?? 16 }
437
+ : { id: "subject", kind: "sphere", radius: subject.radius, segments: subject.segments ?? 16 }
438
+ const subjectBounds = subject.shape === "box"
439
+ ? { x: subject.size[0] / 2, y: subject.size[1] / 2, z: subject.size[2] / 2, yBase: 0 }
440
+ : subject.shape === "cylinder"
441
+ ? { x: subject.radius, y: subject.height / 2, z: subject.radius, yBase: 0 }
442
+ : { x: subject.radius, y: subject.radius, z: subject.radius, yBase: 0 }
443
+ const lod1Node = subject.shape === "box" ? subjectNode
444
+ : subject.shape === "cylinder" ? { id: "subject", kind: "cylinder", radius: subject.radius, height: subject.height, segments: 8 }
445
+ : { id: "subject", kind: "sphere", radius: subject.radius, segments: 8 }
446
+ const exclusions = spec.exclusions ?? []
447
+ const random = mulberry32(spec.seed)
448
+ const instances: SpatialTransform[] = []
449
+ const halfW = spec.area.width / 2, halfD = spec.area.depth / 2
450
+ let attempts = 0
451
+ while (instances.length < spec.count && ++attempts <= SPATIAL_PARAMETRIC_LIMITS.scatterAttempts * Math.min(spec.count, 64)) {
452
+ const x = (random() * 2 - 1) * Math.max(0, halfW - subjectBounds.x)
453
+ const z = (random() * 2 - 1) * Math.max(0, halfD - subjectBounds.z)
454
+ const blocked = exclusions.some(zone =>
455
+ x + subjectBounds.x > zone.center[0] - zone.halfExtents[0] && x - subjectBounds.x < zone.center[0] + zone.halfExtents[0]
456
+ && z + subjectBounds.z > zone.center[1] - zone.halfExtents[1] && z - subjectBounds.z < zone.center[1] + zone.halfExtents[1])
457
+ if (blocked) continue
458
+ const yaw = random() * Math.PI * 2
459
+ const scale = 0.85 + random() * 0.3
460
+ instances.push({ position: [x, subjectBounds.yBase, z], rotation: [...yawQuaternion(yaw)] as [number, number, number, number], scale: [scale, scale, scale] })
461
+ }
462
+ if (instances.length < spec.count) {
463
+ pfail(`Scatter placed ${instances.length} of ${spec.count} subjects; relax exclusions or shrink the subject.`, "spec.exclusions")
464
+ }
465
+ const nodes = [subjectNode]
466
+ const lod1 = graph([lod1Node], "subject")
467
+ return [{
468
+ key: "scatter", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
469
+ materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "subject") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
470
+ collision: [], instances,
471
+ }]
472
+ }
473
+
474
+ const PLANNERS: { [K in SpatialParametricSpec["kind"]]: (spec: Extract<SpatialParametricSpec, { kind: K }>) => PartPlan[] } = {
475
+ geometry: spec => [{ key: "geometry", transform: spec.transform ?? IDENTITY_TRANSFORM,
476
+ editable: spec.editable ?? ["transform"], materials: spec.materials, collision: [],
477
+ lods: [{ level: 0, switchDistanceM: 0, graph: spec.graph }] }],
478
+ wall: planWall, floor: planFloor, stairs: planStairs, arch: planArch, column: planColumn,
479
+ window: planWindow, roof: planRoof, pipe: planPipe, trim: planTrim, scatter: planScatter,
480
+ }
481
+
482
+ /** Retained GLB entities support the closed untextured material profile. */
483
+ function validatePartMaterials(plans: readonly PartPlan[]): void {
484
+ for (const part of plans) {
485
+ for (const material of part.materials) {
486
+ if (material.kind === "pbr" || material.map !== undefined) {
487
+ pfail("Retained parametric geometry requires untextured standard or unlit materials; PBR extension materials and texture maps are unsupported.", "spec.materials")
488
+ }
489
+ }
490
+ for (const lod of part.lods) {
491
+ const parsed = parseSpatialGeometryGraph(lod.graph)
492
+ for (const node of parsed.nodes) {
493
+ const slots = ["slot" in node ? node.slot : undefined, "sideSlot" in node ? node.sideSlot : undefined, "capSlot" in node ? node.capSlot : undefined]
494
+ if (slots.some(slot => slot !== undefined && slot >= part.materials.length)) pfail("Geometry material slot has no declared material.", "spec.materials")
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ /** Conservative aggregate retained-output budget, computed before mesh evaluation. */
501
+ export interface SpatialParametricEstimate {
502
+ readonly parts: number
503
+ readonly assets: number
504
+ readonly vertices: number
505
+ readonly triangles: number
506
+ readonly bytes: number
507
+ }
508
+
509
+ export function estimateSpatialParametric(input: unknown): SpatialParametricEstimate {
510
+ const spec = parseSpatialValue(SpatialParametricSpecSchema, input, "parametric spec")
511
+ const plans = PLANNERS[spec.kind](spec as never)
512
+ if (plans.length > SPATIAL_PARAMETRIC_LIMITS.parts) pfail("Spec expands beyond the part budget.", "spec")
513
+ validatePartMaterials(plans)
514
+ let assets = 0, vertices = 0, triangles = 0, bytes = 0
515
+ for (const part of plans) {
516
+ assets += part.lods.length + 1
517
+ // Includes GLB framing, material JSON, facts, and per-part instance metadata.
518
+ bytes += 65_536 + (part.instances?.length ?? 0) * 512
519
+ for (const lod of part.lods) {
520
+ const estimate = estimateSpatialGeometryGraph(lod.graph)
521
+ vertices += estimate.vertices
522
+ triangles += estimate.triangles
523
+ bytes += estimate.bytes + 65_536
524
+ }
525
+ }
526
+ return deepFreezeJson({ parts: plans.length, assets, vertices, triangles, bytes })
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Emission: graphs → meshes → GLB payloads → manifests → entities → facts
531
+ // ---------------------------------------------------------------------------
532
+
533
+ export interface SpatialParametricArtifact { readonly assetId: string; readonly path: string; readonly bytes: Uint8Array }
534
+ export interface SpatialParametricFacts { readonly manifest: SpatialAssetManifest; readonly facts: SpatialAssetFactsV1 }
535
+ export interface SpatialParametricReceipt {
536
+ readonly name?: string
537
+ readonly kind: "slopcamera.spatial-parametric-receipt"
538
+ readonly schemaVersion: 1
539
+ readonly generatorId: string
540
+ readonly specSha256: string
541
+ readonly parametersSha256: string
542
+ readonly seed: number
543
+ readonly profile: typeof SPATIAL_GEOMETRY_PROFILE
544
+ readonly assets: readonly { assetId: string; sha256: string }[]
545
+ readonly entities: readonly string[]
546
+ readonly native: readonly { requestSha256: string; receiptSha256: string }[]
547
+ }
548
+ export interface SpatialParametricOutput {
549
+ readonly generator: SpatialGenerator
550
+ readonly entities: readonly SpatialEntity[]
551
+ readonly manifests: readonly SpatialAssetManifest[]
552
+ readonly artifacts: readonly SpatialParametricArtifact[]
553
+ readonly facts: readonly SpatialParametricFacts[]
554
+ readonly receipt: SpatialParametricReceipt
555
+ readonly receiptSha256: string
556
+ }
557
+
558
+ function sha256Bytes(bytes: Uint8Array): string {
559
+ const hasher = createSha256HexHasher(); hasher.update(bytes); return hasher.digestHex()
560
+ }
561
+
562
+ const GLTF_INTERPRETATION = { kind: "gltf" as const, format: "glb" as const, metersPerUnit: 1, sourceUp: "y" as const }
563
+
564
+ /**
565
+ * Compiles a bounded parametric spec into retained generator output: stamped
566
+ * entities, GLB payloads and manifests per LOD level, derived facts manifests,
567
+ * and a content-addressed receipt. Deterministic and effect-free.
568
+ */
569
+ export function emitSpatialParametric(input: unknown): SpatialParametricOutput {
570
+ const request = parseSpatialValue(SpatialParametricRequestSchema, input, "parametric request")
571
+ const { generatorId, spec } = request
572
+ const retainedArtifacts: SpatialRetainedArtifact[] = []
573
+ if (request.native !== undefined) {
574
+ const receipt = validateSpatialGeometryNativeReceipt({ request: request.native.request, receipt: request.native.receipt })
575
+ const outputs = verifySpatialGeometryNativeOutputs(receipt, request.native.outputs)
576
+ retainedArtifacts.push({
577
+ operation: receipt.operation,
578
+ requestSha256: spatialGeometryNativeRequestSha256(request.native.request),
579
+ receiptSha256: spatialGeometryNativeReceiptSha256(receipt),
580
+ outputs: [...outputs],
581
+ })
582
+ }
583
+ const sourceSha256 = spatialValueSha256({ domain: "slopcamera.parametric-source.v1", profile: SPATIAL_GEOMETRY_PROFILE, kind: spec.kind })
584
+ const parametersSha256 = spatialGeneratorParametersSha256(spec)
585
+ const specSha256 = spatialValueSha256({ domain: "slopcamera.parametric-spec.v1", spec })
586
+ const seed = request.seed ?? deriveSpatialGeneratorSeed(sourceSha256)
587
+ const runtimeSha256 = spatialValueSha256({ domain: "slopcamera.parametric-runtime.v1", profile: SPATIAL_GEOMETRY_PROFILE })
588
+ const plans = PLANNERS[spec.kind](spec as never)
589
+ if (plans.length > SPATIAL_PARAMETRIC_LIMITS.parts) pfail("Spec expands beyond the part budget.", "spec")
590
+ validatePartMaterials(plans)
591
+ const manifests: SpatialAssetManifest[] = []
592
+ const artifacts: SpatialParametricArtifact[] = []
593
+ const entities: SpatialEntity[] = []
594
+ const factsList: SpatialParametricFacts[] = []
595
+ const editableKeys: { key: string; properties: ("color" | "opacity" | "transform")[] }[] = []
596
+ const receiptAssets: { assetId: string; sha256: string }[] = []
597
+ const lod0ByPart = new Map<string, { assetId: string; manifest: SpatialAssetManifest; mesh: SpatialGeometryMesh; part: PartPlan }>()
598
+ for (const part of plans) {
599
+ const lods: { level: number; assetId: string; sha256: string; switchDistanceM: number }[] = []
600
+ for (const lod of part.lods) {
601
+ const evaluation = evaluateSpatialGeometry(lod.graph)
602
+ const bytes = emitSpatialGeometryGlb(evaluation.mesh, part.materials)
603
+ const sha256 = sha256Bytes(bytes)
604
+ const assetId = `asset_${spatialValueSha256({ domain: "slopcamera.parametric-asset.v1", generatorId, key: part.key, level: lod.level, sha256 }).slice(0, 32)}`
605
+ const path = `generated/${generatorId}/${part.key}-lod${lod.level}.glb`
606
+ const manifest = parseSpatialValue(SpatialAssetManifestSchema, {
607
+ assetId, payload: { path, sha256, bytes: bytes.byteLength },
608
+ interpretation: GLTF_INTERPRETATION, dependencies: [],
609
+ provenance: { source: "generated", description: `Parametric ${spec.kind} part "${part.key}" LOD ${lod.level}.`, receiptSha256: "0".repeat(64) },
610
+ }, `parametric asset ${part.key}`)
611
+ manifests.push(manifest)
612
+ artifacts.push({ assetId, path, bytes })
613
+ lods.push({ level: lod.level, assetId, sha256, switchDistanceM: lod.switchDistanceM })
614
+ if (lod.level === 0) lod0ByPart.set(part.key, { assetId, manifest, mesh: evaluation.mesh, part })
615
+ }
616
+ lods.sort((a, b) => a.level - b.level)
617
+ const lod0 = lod0ByPart.get(part.key)!
618
+ if (part.lods[0] === undefined) pfail("Every part requires a level-0 LOD.", "spec")
619
+ // Facts document for the level-0 subject.
620
+ const subject = lod0.manifest.payload
621
+ const collision = request.collision !== undefined ? [...request.collision]
622
+ : part.collision.length > 0 ? [...part.collision]
623
+ : [boxBounds(lod0.mesh.bounds)]
624
+ const materialFacts = part.materials.map(material => ({
625
+ alphaMode: (material.opacity < 1 ? "BLEND" : "OPAQUE") as "BLEND" | "OPAQUE",
626
+ doubleSided: false, maps: [] as [],
627
+ ...(material.kind === "standard" && material.emissive !== undefined
628
+ ? { emissiveLinear: [1, 1, 1].map(() => 0) as [number, number, number] } : {}),
629
+ }))
630
+ const facts = parseSpatialValue(SpatialAssetFactsV1Schema, {
631
+ kind: "slopcamera.spatial-asset-facts", schemaVersion: 1,
632
+ subject: { path: subject.path, sha256: subject.sha256, bytes: subject.bytes },
633
+ subjectManifestSha256: spatialAssetManifestSha256(lod0.manifest),
634
+ profile: SPATIAL_GEOMETRY_PROFILE, nodeCount: 1, clipDurationsSeconds: [],
635
+ bounds: { modelSpace: lod0.mesh.bounds, sceneSpace: lod0.mesh.bounds },
636
+ materials: materialFacts,
637
+ generator: {
638
+ generatorId, parametricKind: spec.kind, specSha256, parametersSha256,
639
+ lods: lods.map(lod => ({ level: lod.level, assetId: lod.assetId, sha256: lod.sha256, switchDistanceM: lod.switchDistanceM })),
640
+ collision,
641
+ ...(retainedArtifacts.length === 0 ? {} : { retainedArtifacts }),
642
+ },
643
+ }, `parametric facts ${part.key}`)
644
+ const factsText = `${canonicalJson(facts)}\n`
645
+ const factsBytes = new TextEncoder().encode(factsText)
646
+ const factsSha256 = sha256Bytes(factsBytes)
647
+ const factsManifest = parseSpatialValue(SpatialAssetManifestSchema, {
648
+ assetId: `asset_${spatialValueSha256({ domain: "slopcamera.parametric-facts.v1", generatorId, key: part.key, sha256: factsSha256 }).slice(0, 32)}`,
649
+ payload: { path: `generated/${generatorId}/${part.key}-facts.json`, sha256: factsSha256, bytes: factsBytes.byteLength },
650
+ interpretation: { kind: "metadata", format: "json", schema: "slopcamera.spatial-asset-facts" },
651
+ dependencies: [lod0.assetId],
652
+ provenance: { source: "derived", description: `Derived parametric facts for ${lod0.assetId}.`, receiptSha256: "0".repeat(64) },
653
+ }, `parametric facts manifest ${part.key}`)
654
+ manifests.push(factsManifest)
655
+ artifacts.push({ assetId: factsManifest.assetId, path: factsManifest.payload.path, bytes: factsBytes })
656
+ factsList.push({ manifest: factsManifest, facts })
657
+ const entityId = generatedSpatialEntityId(generatorId, part.key)
658
+ const multiSlot = part.materials.length > 1
659
+ const entity = parseSpatialValue(SpatialEntitySchema, {
660
+ entityId, kind: "mesh", name: request.name === undefined ? `${spec.kind}:${part.key}` : plans.length === 1 ? request.name : `${request.name}:${part.key}`, parentId: null,
661
+ transform: part.transform, placement: { kind: "world" },
662
+ origin: { kind: "generated", generatorId, key: part.key }, visible: true,
663
+ geometry: { kind: "asset", assetId: lod0.assetId, materialMode: multiSlot ? "source" : "entity" },
664
+ material: part.materials[0]!,
665
+ ...(spec.castShadow === undefined ? {} : { castShadow: spec.castShadow }),
666
+ ...(spec.receiveShadow === undefined ? {} : { receiveShadow: spec.receiveShadow }),
667
+ ...(part.instances === undefined ? {} : { instances: [...part.instances] }),
668
+ }, `parametric entity ${part.key}`)
669
+ entities.push(entity)
670
+ editableKeys.push({ key: part.key, properties: multiSlot ? ["transform"] : [...part.editable] })
671
+ receiptAssets.push(...lods.map(lod => ({ assetId: lod.assetId, sha256: lod.sha256 })))
672
+ receiptAssets.push({ assetId: factsManifest.assetId, sha256: factsSha256 })
673
+ }
674
+ const closureSha256 = spatialValueSha256({ domain: "slopcamera.parametric-closure.v1", sourceSha256, parametersSha256, seed, specSha256 })
675
+ const assetIds = manifests.map(manifest => manifest.assetId).sort()
676
+ const record = buildSpatialGeneratorRecord({
677
+ generatorId, sourceSha256, closureSha256, parametersSha256, seed, runtimeSha256,
678
+ entities, editableKeys, assets: assetIds,
679
+ })
680
+ const receipt = deepFreezeJson({
681
+ kind: "slopcamera.spatial-parametric-receipt" as const, schemaVersion: 1 as const,
682
+ generatorId, specSha256, parametersSha256, seed,
683
+ ...(request.name === undefined ? {} : { name: request.name }),
684
+ profile: SPATIAL_GEOMETRY_PROFILE,
685
+ assets: receiptAssets,
686
+ entities: entities.map(entity => entity.entityId).sort(),
687
+ native: retainedArtifacts.map(artifact => ({ requestSha256: artifact.requestSha256, receiptSha256: artifact.receiptSha256 })),
688
+ })
689
+ const receiptSha256 = spatialValueSha256({ domain: "slopcamera.spatial-parametric-receipt.v1", receipt })
690
+ // Bind the receipt digest into generated provenance now that it exists.
691
+ const bound = manifests.map(manifest => manifest.provenance.source === "generated"
692
+ ? parseSpatialValue(SpatialAssetManifestSchema, { ...manifest, provenance: { ...manifest.provenance, receiptSha256 } }, `parametric asset ${manifest.assetId}`)
693
+ : manifest)
694
+ // Payload buffers are owned binary outputs, outside the canonical JSON domain.
695
+ const metadata = deepFreezeJson({ generator: record, entities, manifests: bound, facts: factsList, receipt, receiptSha256 })
696
+ return Object.freeze({ ...metadata, artifacts: Object.freeze(artifacts.map(artifact => Object.freeze(artifact))) })
697
+ }
698
+
699
+ /** Merges one emission into a scene through the retained-output contract. */
700
+ export function mergeSpatialParametricOutput(scene: SpatialSceneV1 | undefined, output: SpatialParametricOutput): SpatialSceneV1 {
701
+ return mergeSpatialGeneratorOutput(scene, output.generator, output.entities, output.manifests)
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // LOD selection and audit
706
+ // ---------------------------------------------------------------------------
707
+
708
+ /** Deterministic LOD rule: the greatest level whose switch distance is met. */
709
+ export function selectSpatialLod(lods: readonly { level: number; assetId: string; switchDistanceM: number }[], distanceM: number): { level: number; assetId: string; switchDistanceM: number } {
710
+ let selected = lods[0]!
711
+ for (const lod of lods) {
712
+ if (lod.level === 0) selected = lod
713
+ else if (distanceM >= lod.switchDistanceM && lod.level > selected.level) selected = lod
714
+ }
715
+ return selected
716
+ }
717
+
718
+ export interface SpatialParametricAuditEntry {
719
+ readonly entityId: string
720
+ readonly key: string
721
+ readonly generatorId: string
722
+ readonly distanceM: number
723
+ readonly lodAssetId: string
724
+ readonly lodLevel: number
725
+ readonly collision: readonly string[]
726
+ readonly retainedArtifacts: number
727
+ }
728
+
729
+ /**
730
+ * Reports which LOD/proxy each parametric entity resolves under one camera:
731
+ * distance from the camera position to the transformed bounds center selects
732
+ * the deterministic level, and collision kinds + retained artifact counts come
733
+ * from the asset facts documents.
734
+ */
735
+ export function auditSpatialParametricScene(sceneInput: unknown, factsInput: readonly unknown[], options: { cameraId: string; timeUs?: number }): readonly SpatialParametricAuditEntry[] {
736
+ const scene = parseSpatialScene(sceneInput)
737
+ const factsBySubject = new Map<string, SpatialAssetFactsV1>()
738
+ for (const input of factsInput) {
739
+ const facts = parseSpatialValue(SpatialAssetFactsV1Schema, input, "asset facts")
740
+ if (facts.generator !== undefined) factsBySubject.set(facts.subject.sha256, facts)
741
+ }
742
+ const snapshot = evaluateSpatialScene(scene, { cameraId: options.cameraId, timeUs: options.timeUs ?? 0 })
743
+ const camera = snapshot.camera.pose.position
744
+ const assetsById = new Map(scene.assets.map(asset => [asset.assetId, asset]))
745
+ const entries: SpatialParametricAuditEntry[] = []
746
+ for (const item of snapshot.entities) {
747
+ const { entity, worldMatrix } = item
748
+ if (entity.origin.kind !== "generated" || entity.kind !== "mesh" || entity.geometry.kind !== "asset") continue
749
+ const asset = assetsById.get(entity.geometry.assetId)
750
+ if (asset === undefined) continue
751
+ const facts = factsBySubject.get(asset.payload.sha256)
752
+ if (facts === undefined || facts.generator === undefined) continue
753
+ const bounds = transformBounds(worldMatrix as Mat4, facts.bounds.modelSpace)
754
+ const center = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2] as const
755
+ const distance = Math.hypot(center[0] - camera[0], center[1] - camera[1], center[2] - camera[2])
756
+ const lod = selectSpatialLod(facts.generator.lods, distance)
757
+ entries.push({
758
+ entityId: entity.entityId, key: entity.origin.key, generatorId: entity.origin.generatorId,
759
+ distanceM: distance, lodAssetId: lod.assetId, lodLevel: lod.level,
760
+ collision: facts.generator.collision.map(proxy => proxy.kind),
761
+ retainedArtifacts: facts.generator.retainedArtifacts?.length ?? 0,
762
+ })
763
+ }
764
+ return deepFreezeJson(entries)
765
+ }