@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,609 @@
1
+ import { z } from "zod"
2
+ import { canonicalJson } from "../code/canonical-json.js"
3
+ import { deepFreezeJson } from "../code/json-snapshot.js"
4
+ import { sha256Hex } from "../code/sha256.js"
5
+
6
+ /**
7
+ * Closed bounded PBR material extensions, atmosphere, and reusable lighting rigs.
8
+ * Every type is portable, effect-free, and deterministic. No arbitrary shaders,
9
+ * runtime downloads, or simulation. Color spaces and channel semantics are
10
+ * explicit so downstream lowering never guesses.
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Shared bounded scalars
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const unit = z.number().finite().min(0).max(1)
18
+ const positiveFinite = z.number().finite().min(0).max(1_000_000)
19
+ const finiteCoordinate = z.number().finite().min(-1_000_000).max(1_000_000)
20
+ const color = z.string().regex(/^#[a-fA-F0-9]{6}$/u)
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Map reference with explicit channel and color-space semantics
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Which channels of the texture carry material data. */
27
+ export const SpatialMapChannelSchema = z.enum([
28
+ "rgb", // sRGB base-color, emissive
29
+ "rgba", // sRGB base-color with alpha
30
+ "r", // single-channel data (roughness, metalness, AO, height, etc.)
31
+ "g", // green channel (ORM layout: roughness in G)
32
+ "b", // blue channel (ORM layout: metalness in B)
33
+ "a", // alpha channel (Three sheen roughness)
34
+ "rg", // two-channel data (anisotropy direction, etc.)
35
+ "orm", // occlusion-roughness-metalness packed R/G/B per glTF 2.0
36
+ "xy-normal", // tangent-space normal in R/G, derived Z; per glTF 2.0
37
+ ])
38
+ export type SpatialMapChannel = z.infer<typeof SpatialMapChannelSchema>
39
+
40
+ /** Declared color space of the texture data as stored. */
41
+ export const SpatialMapColorSpaceSchema = z.enum([
42
+ "srgb", // sRGB transfer function (base-color, emissive)
43
+ "linear", // linear data (normal, ORM, height, clearcoat, transmission, sheen, anisotropy)
44
+ ])
45
+ export type SpatialMapColorSpace = z.infer<typeof SpatialMapColorSpaceSchema>
46
+
47
+ /** UV transform applied to this map's texture coordinates. */
48
+ export const SpatialUvTransformSchema = z.strictObject({
49
+ offset: z.tuple([finiteCoordinate, finiteCoordinate]),
50
+ rotation: z.number().finite().min(-Math.PI).max(Math.PI),
51
+ scale: z.tuple([positiveFinite, positiveFinite]),
52
+ })
53
+ export type SpatialUvTransform = z.infer<typeof SpatialUvTransformSchema>
54
+
55
+ /**
56
+ * A bounded texture map reference with explicit semantics. The `assetId` must
57
+ * resolve to an image asset in the scene. Dimensions are validated against the
58
+ * referenced asset at parse time.
59
+ */
60
+ const assetId = z.string().min(7).max(128).regex(/^asset_[a-zA-Z0-9][a-zA-Z0-9_-]*$/u)
61
+ export const SpatialPbrMapSchema = z.strictObject({
62
+ assetId,
63
+ channel: SpatialMapChannelSchema,
64
+ colorSpace: SpatialMapColorSpaceSchema,
65
+ uvTransform: SpatialUvTransformSchema.optional(),
66
+ })
67
+ export type SpatialPbrMap = z.infer<typeof SpatialPbrMapSchema>
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // PBR material extensions — closed union of reviewed glTF core + KHR fields
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Emissive: sRGB hex color times bounded scalar intensity, plus optional map. */
74
+ export const SpatialPbrEmissiveSchema = z.strictObject({
75
+ color,
76
+ intensity: z.number().finite().min(0).max(100_000),
77
+ map: SpatialPbrMapSchema.optional(),
78
+ })
79
+
80
+ /** Clearcoat layer: isotropic clear lacquer over the base layer. */
81
+ export const SpatialPbrClearcoatSchema = z.strictObject({
82
+ factor: unit,
83
+ roughness: unit,
84
+ map: SpatialPbrMapSchema.optional(),
85
+ roughnessMap: SpatialPbrMapSchema.optional(),
86
+ normalMap: SpatialPbrMapSchema.optional(),
87
+ normalScale: z.number().finite().min(-16).max(16).optional(),
88
+ })
89
+
90
+ /** Transmission: thin-surface light transmission for glass, liquid, etc. */
91
+ export const SpatialPbrTransmissionSchema = z.strictObject({
92
+ factor: unit,
93
+ map: SpatialPbrMapSchema.optional(),
94
+ })
95
+
96
+ /** Sheen: fabric-like reflection at grazing angles. */
97
+ export const SpatialPbrSheenSchema = z.strictObject({
98
+ color,
99
+ roughness: unit,
100
+ colorMap: SpatialPbrMapSchema.optional(),
101
+ roughnessMap: SpatialPbrMapSchema.optional(),
102
+ })
103
+
104
+ /** Anisotropy: directional roughness for brushed metal, hair, etc. */
105
+ export const SpatialPbrAnisotropySchema = z.strictObject({
106
+ strength: z.number().finite().min(-1).max(1),
107
+ rotation: z.number().finite().min(0).max(2 * Math.PI),
108
+ map: SpatialPbrMapSchema.optional(),
109
+ })
110
+
111
+ /**
112
+ * The closed PBR material. Extends the existing `standard` material with every
113
+ * reviewed glTF 2.0 core field and bounded KHR extension. Maps are explicit
114
+ * asset references with channel/color-space semantics. No arbitrary shaders.
115
+ *
116
+ * Color-space convention:
117
+ * - `baseColorMap`, `emissive.map`, `sheen.colorMap`: sRGB
118
+ * - All other maps (normal, ORM, height, clearcoat, transmission, sheen roughness, anisotropy): linear
119
+ *
120
+ * ORM vs explicit: Exactly one of `ormMap` or the combination of individual
121
+ * `roughnessMap`/`metalnessMap`/`aoMap` may be present, never both.
122
+ */
123
+ export const SpatialPbrMaterialSchema = z.strictObject({
124
+ kind: z.literal("pbr"),
125
+
126
+ // Base layer
127
+ color,
128
+ opacity: unit,
129
+ roughness: unit,
130
+ metalness: unit,
131
+ baseColorMap: SpatialPbrMapSchema.optional(),
132
+ normalMap: SpatialPbrMapSchema.optional(),
133
+ normalScale: z.number().finite().min(-16).max(16).optional(),
134
+
135
+ // ORM (combined) or explicit separate maps — mutually exclusive
136
+ ormMap: SpatialPbrMapSchema.optional(),
137
+ roughnessMap: SpatialPbrMapSchema.optional(),
138
+ metalnessMap: SpatialPbrMapSchema.optional(),
139
+ aoMap: SpatialPbrMapSchema.optional(),
140
+ aoMapIntensity: unit.optional(),
141
+
142
+ // Emissive
143
+ emissive: SpatialPbrEmissiveSchema.optional(),
144
+
145
+ // Height/displacement
146
+ heightMap: SpatialPbrMapSchema.optional(),
147
+ heightScale: z.number().finite().min(-10).max(10).optional(),
148
+
149
+ // Extensions
150
+ clearcoat: SpatialPbrClearcoatSchema.optional(),
151
+ transmission: SpatialPbrTransmissionSchema.optional(),
152
+ sheen: SpatialPbrSheenSchema.optional(),
153
+ anisotropy: SpatialPbrAnisotropySchema.optional(),
154
+ ior: z.number().finite().min(1).max(5).optional(),
155
+
156
+ // Alpha
157
+ alphaMode: z.enum(["OPAQUE", "MASK", "BLEND"]).optional(),
158
+ alphaCutoff: unit.optional(),
159
+ doubleSided: z.boolean().optional(),
160
+ }).superRefine((material, context) => {
161
+ // ORM and explicit separate maps are mutually exclusive
162
+ const hasOrm = material.ormMap !== undefined
163
+ const hasSeparate = material.roughnessMap !== undefined || material.metalnessMap !== undefined || material.aoMap !== undefined
164
+ if (hasOrm && hasSeparate) {
165
+ context.addIssue({ code: "custom", message: "PBR materials must use either an ORM map or separate roughness/metalness/AO maps, never both." })
166
+ }
167
+ // Validate channel semantics
168
+ if (material.baseColorMap !== undefined && !["rgb", "rgba"].includes(material.baseColorMap.channel)) {
169
+ context.addIssue({ code: "custom", path: ["baseColorMap", "channel"], message: "Base-color maps must use rgb or rgba channels." })
170
+ }
171
+ if (material.baseColorMap !== undefined && material.baseColorMap.colorSpace !== "srgb") {
172
+ context.addIssue({ code: "custom", path: ["baseColorMap", "colorSpace"], message: "Base-color maps must be sRGB." })
173
+ }
174
+ if (material.normalMap !== undefined && material.normalMap.channel !== "xy-normal") {
175
+ context.addIssue({ code: "custom", path: ["normalMap", "channel"], message: "Normal maps must use xy-normal channel semantics." })
176
+ }
177
+ if (material.normalMap !== undefined && material.normalMap.colorSpace !== "linear") {
178
+ context.addIssue({ code: "custom", path: ["normalMap", "colorSpace"], message: "Normal maps must be linear." })
179
+ }
180
+ if (material.ormMap !== undefined && material.ormMap.channel !== "orm") {
181
+ context.addIssue({ code: "custom", path: ["ormMap", "channel"], message: "ORM maps must use orm channel semantics." })
182
+ }
183
+ if (material.ormMap !== undefined && material.ormMap.colorSpace !== "linear") {
184
+ context.addIssue({ code: "custom", path: ["ormMap", "colorSpace"], message: "ORM maps must be linear." })
185
+ }
186
+ if (material.heightMap !== undefined && material.heightMap.channel !== "r") {
187
+ context.addIssue({ code: "custom", path: ["heightMap", "channel"], message: "Height maps must use single red channel." })
188
+ }
189
+ if (material.heightMap !== undefined && material.heightMap.colorSpace !== "linear") {
190
+ context.addIssue({ code: "custom", path: ["heightMap", "colorSpace"], message: "Height maps must be linear." })
191
+ }
192
+ if (material.emissive?.map !== undefined && material.emissive.map.colorSpace !== "srgb") {
193
+ context.addIssue({ code: "custom", path: ["emissive", "map", "colorSpace"], message: "Emissive maps must be sRGB." })
194
+ }
195
+ for (const [name, map, channels] of [
196
+ ["roughnessMap", material.roughnessMap, ["g"]],
197
+ ["metalnessMap", material.metalnessMap, ["b"]],
198
+ ["aoMap", material.aoMap, ["r"]],
199
+ ["clearcoat.map", material.clearcoat?.map, ["r"]],
200
+ ["clearcoat.roughnessMap", material.clearcoat?.roughnessMap, ["g"]],
201
+ ["transmission.map", material.transmission?.map, ["r"]],
202
+ ["sheen.roughnessMap", material.sheen?.roughnessMap, ["a"]],
203
+ ] as const) {
204
+ if (map !== undefined && map.colorSpace !== "linear") context.addIssue({ code: "custom", path: name.split("."), message: `${name} must be linear.` })
205
+ if (map !== undefined && !(channels as readonly string[]).includes(map.channel)) context.addIssue({ code: "custom", path: [...name.split("."), "channel"], message: `${name} has unsupported channel semantics.` })
206
+ }
207
+ for (const [name, map] of [["clearcoat.normalMap", material.clearcoat?.normalMap], ["anisotropy.map", material.anisotropy?.map]] as const) {
208
+ if (map !== undefined && map.colorSpace !== "linear") context.addIssue({ code: "custom", path: name.split("."), message: `${name} must be linear.` })
209
+ }
210
+ if (material.clearcoat?.normalMap !== undefined && material.clearcoat.normalMap.channel !== "xy-normal") context.addIssue({ code: "custom", path: ["clearcoat", "normalMap", "channel"], message: "Clearcoat normal maps must use xy-normal semantics." })
211
+ if (material.emissive?.map !== undefined && !["rgb", "rgba"].includes(material.emissive.map.channel)) context.addIssue({ code: "custom", path: ["emissive", "map", "channel"], message: "Emissive maps must use rgb or rgba channels." })
212
+ if (material.sheen?.colorMap !== undefined && (material.sheen.colorMap.colorSpace !== "srgb" || !["rgb", "rgba"].includes(material.sheen.colorMap.channel))) context.addIssue({ code: "custom", path: ["sheen", "colorMap"], message: "Sheen color maps must use sRGB rgb or rgba semantics." })
213
+ if (material.anisotropy?.map !== undefined && material.anisotropy.map.channel !== "rgb") context.addIssue({ code: "custom", path: ["anisotropy", "map", "channel"], message: "Anisotropy maps must use RGB direction-and-strength semantics." })
214
+ if (material.normalScale !== undefined && material.normalMap === undefined) context.addIssue({ code: "custom", message: "normalScale requires a normalMap." })
215
+ if ((material.heightScale !== undefined) !== (material.heightMap !== undefined)) context.addIssue({ code: "custom", message: "heightScale and heightMap must be declared together." })
216
+ if (material.clearcoat?.normalScale !== undefined && material.clearcoat.normalMap === undefined) context.addIssue({ code: "custom", message: "Clearcoat normalScale requires a clearcoat normalMap." })
217
+ if (material.aoMapIntensity !== undefined && material.aoMap === undefined && material.ormMap === undefined) context.addIssue({ code: "custom", message: "aoMapIntensity requires an AO or ORM map." })
218
+ // alphaCutoff only meaningful for MASK mode
219
+ if (material.alphaCutoff !== undefined && material.alphaMode !== "MASK") {
220
+ context.addIssue({ code: "custom", message: "alphaCutoff is only applicable to MASK alpha mode." })
221
+ }
222
+ })
223
+ export type SpatialPbrMaterial = z.infer<typeof SpatialPbrMaterialSchema>
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Collect all map asset references from a PBR material
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /** Returns every distinct asset ID referenced by maps in a PBR material. */
230
+ export function pbrMaterialMapAssetIds(material: { readonly [K in keyof SpatialPbrMaterial]?: unknown } & { readonly kind: "pbr" }): readonly string[] {
231
+ const ids = new Set<string>()
232
+ const collect = (map: unknown) => { if (map !== null && typeof map === "object" && map !== undefined && "assetId" in map && typeof (map as { assetId: unknown }).assetId === "string") ids.add((map as { assetId: string }).assetId) }
233
+ const m = material as SpatialPbrMaterial
234
+ collect(m.baseColorMap)
235
+ collect(m.normalMap)
236
+ collect(m.ormMap)
237
+ collect(m.roughnessMap)
238
+ collect(m.metalnessMap)
239
+ collect(m.aoMap)
240
+ collect(m.heightMap)
241
+ collect(m.emissive?.map)
242
+ collect(m.clearcoat?.map)
243
+ collect(m.clearcoat?.roughnessMap)
244
+ collect(m.clearcoat?.normalMap)
245
+ collect(m.transmission?.map)
246
+ collect(m.sheen?.colorMap)
247
+ collect(m.sheen?.roughnessMap)
248
+ collect(m.anisotropy?.map)
249
+ return Object.freeze([...ids])
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Deterministic local derivation candidates
254
+ // ---------------------------------------------------------------------------
255
+
256
+ export const SpatialDerivationMethodSchema = z.enum([
257
+ "sobel-normal-from-height",
258
+ "average-luminance-roughness",
259
+ "luminance-height",
260
+ ])
261
+ export type SpatialDerivationMethod = z.infer<typeof SpatialDerivationMethodSchema>
262
+
263
+ /**
264
+ * A derivation candidate describes a deterministic local operation that can
265
+ * produce a plausible approximation of a map from available authored data.
266
+ * The provenance is always honest: `"derived-candidate"` never claims to
267
+ * equal an authored scan. The host decides whether to accept the candidate.
268
+ */
269
+ export const SpatialDerivationCandidateSchema = z.strictObject({
270
+ method: SpatialDerivationMethodSchema,
271
+ sourceAssetId: assetId,
272
+ outputChannel: SpatialMapChannelSchema,
273
+ outputColorSpace: SpatialMapColorSpaceSchema,
274
+ provenance: z.literal("derived-candidate"),
275
+ description: z.string().min(1).max(1024),
276
+ })
277
+ export type SpatialDerivationCandidate = z.infer<typeof SpatialDerivationCandidateSchema>
278
+
279
+ /** Returns applicable derivation candidates for a PBR material given its declared maps. */
280
+ export function pbrDerivationCandidates(material: SpatialPbrMaterial): readonly SpatialDerivationCandidate[] {
281
+ const candidates: SpatialDerivationCandidate[] = []
282
+ // Normal from height: if we have a height map but no normal map
283
+ if (material.heightMap !== undefined && material.normalMap === undefined) {
284
+ candidates.push({
285
+ method: "sobel-normal-from-height",
286
+ sourceAssetId: material.heightMap.assetId,
287
+ outputChannel: "xy-normal",
288
+ outputColorSpace: "linear",
289
+ provenance: "derived-candidate",
290
+ description: "Sobel-filter tangent-space normal derived from the authored height map. Does not equal a surface scan.",
291
+ })
292
+ }
293
+ // Roughness from base-color: if we have a base-color map but no roughness/ORM
294
+ if (material.baseColorMap !== undefined && material.roughnessMap === undefined && material.ormMap === undefined) {
295
+ candidates.push({
296
+ method: "average-luminance-roughness",
297
+ sourceAssetId: material.baseColorMap.assetId,
298
+ outputChannel: "r",
299
+ outputColorSpace: "linear",
300
+ provenance: "derived-candidate",
301
+ description: "Inverted luminance roughness derived from the base-color map. A heuristic approximation, not a measured surface.",
302
+ })
303
+ }
304
+ // Height from base-color: if we have a base-color map but no height map
305
+ if (material.baseColorMap !== undefined && material.heightMap === undefined) {
306
+ candidates.push({
307
+ method: "luminance-height",
308
+ sourceAssetId: material.baseColorMap.assetId,
309
+ outputChannel: "r",
310
+ outputColorSpace: "linear",
311
+ provenance: "derived-candidate",
312
+ description: "Luminance-based height derived from the base-color map. A visual approximation, not a measured displacement.",
313
+ })
314
+ }
315
+ return deepFreezeJson(candidates)
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Linear and height fog
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export const SpatialFogSchema = z.discriminatedUnion("kind", [
323
+ z.strictObject({
324
+ kind: z.literal("linear"),
325
+ color,
326
+ near: positiveFinite,
327
+ far: positiveFinite,
328
+ }).refine(fog => fog.far > fog.near, "Fog far must exceed near."),
329
+ z.strictObject({
330
+ kind: z.literal("height"),
331
+ color,
332
+ density: z.number().finite().min(0).max(100),
333
+ heightFalloff: z.number().finite().min(0).max(100),
334
+ baseHeight: finiteCoordinate,
335
+ }),
336
+ ])
337
+ export type SpatialFog = z.infer<typeof SpatialFogSchema>
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Typed reusable lighting rigs
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /** The closed set of named lighting rig presets. */
344
+ export const SpatialLightingRigTypeSchema = z.enum([
345
+ "portrait",
346
+ "product",
347
+ "moonlight",
348
+ "golden-hour",
349
+ "neon-noir",
350
+ "interior-window",
351
+ "volumetric-stage",
352
+ ])
353
+ export type SpatialLightingRigType = z.infer<typeof SpatialLightingRigTypeSchema>
354
+
355
+ interface RigLight {
356
+ readonly entityId: string
357
+ readonly name: string
358
+ readonly light: "ambient" | "directional" | "point" | "spot"
359
+ readonly color: string
360
+ readonly intensity: number
361
+ readonly position: readonly [number, number, number]
362
+ readonly rotation: readonly [number, number, number, number]
363
+ readonly spot?: { readonly angle: number; readonly penumbra: number; readonly distance?: number; readonly decay?: number }
364
+ readonly shadow?: boolean
365
+ }
366
+
367
+ const IDENTITY_ROTATION = [0, 0, 0, 1] as const
368
+
369
+ function localMinusZTowardOrigin(position: readonly [number, number, number]): readonly [number, number, number, number] {
370
+ const length = Math.hypot(...position)
371
+ if (length === 0) return IDENTITY_ROTATION
372
+ const to = [-position[0] / length, -position[1] / length, -position[2] / length] as const
373
+ // Unit quaternion rotating local -Z onto the origin direction.
374
+ const dot = -to[2]
375
+ if (dot < -0.999999) return [0, 1, 0, 0]
376
+ const scale = Math.sqrt(2 * (1 + dot))
377
+ return [to[1] / scale, -to[0] / scale, 0, scale / 2]
378
+ }
379
+
380
+ function rigEntity(light: RigLight) {
381
+ const rotation = light.light === "ambient" || light.light === "point" ? light.rotation : localMinusZTowardOrigin(light.position)
382
+ return {
383
+ entityId: light.entityId,
384
+ kind: "light" as const,
385
+ name: light.name,
386
+ parentId: null,
387
+ transform: { position: light.position, rotation, scale: [1, 1, 1] as const },
388
+ placement: { kind: "world" as const },
389
+ origin: { kind: "authored" as const },
390
+ visible: true,
391
+ light: light.light,
392
+ color: light.color,
393
+ intensity: light.intensity,
394
+ ...(light.spot === undefined ? {} : { spot: light.spot }),
395
+ ...(light.shadow === undefined ? {} : { shadow: light.shadow }),
396
+ }
397
+ }
398
+
399
+ const RIGS: Record<SpatialLightingRigType, { readonly description: string; readonly lights: readonly RigLight[] }> = {
400
+ portrait: {
401
+ description: "Classic three-point portrait: warm key at 45\u00b0, cool fill opposite, rim backlight. Soft ambient fill.",
402
+ lights: [
403
+ { entityId: "entity_rig_key", name: "Key", light: "directional", color: "#fff5e6", intensity: 3.2, position: [3, 4, 3], rotation: IDENTITY_ROTATION, shadow: true },
404
+ { entityId: "entity_rig_fill", name: "Fill", light: "directional", color: "#ccd8ff", intensity: 1.2, position: [-3, 2, 3], rotation: IDENTITY_ROTATION },
405
+ { entityId: "entity_rig_rim", name: "Rim", light: "directional", color: "#ffffff", intensity: 2.0, position: [0, 3, -4], rotation: IDENTITY_ROTATION },
406
+ { entityId: "entity_rig_ambient", name: "Ambient", light: "ambient", color: "#e8ecf0", intensity: 0.4, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
407
+ ],
408
+ },
409
+ product: {
410
+ description: "Even product lighting: overhead softbox, two symmetric side fills, warm ambient. Minimal shadows for clean presentation.",
411
+ lights: [
412
+ { entityId: "entity_rig_top", name: "Top", light: "directional", color: "#ffffff", intensity: 3.0, position: [0, 5, 0], rotation: IDENTITY_ROTATION, shadow: true },
413
+ { entityId: "entity_rig_left", name: "Left fill", light: "directional", color: "#f5f5ff", intensity: 1.5, position: [-4, 2, 3], rotation: IDENTITY_ROTATION },
414
+ { entityId: "entity_rig_right", name: "Right fill", light: "directional", color: "#f5f5ff", intensity: 1.5, position: [4, 2, 3], rotation: IDENTITY_ROTATION },
415
+ { entityId: "entity_rig_ambient", name: "Ambient", light: "ambient", color: "#f0ece8", intensity: 0.8, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
416
+ ],
417
+ },
418
+ moonlight: {
419
+ description: "Cool blue directional moonlight with deep ambient. High contrast, strong shadows.",
420
+ lights: [
421
+ { entityId: "entity_rig_moon", name: "Moon", light: "directional", color: "#b4c8e8", intensity: 2.0, position: [-2, 8, -3], rotation: IDENTITY_ROTATION, shadow: true },
422
+ { entityId: "entity_rig_ambient", name: "Ambient", light: "ambient", color: "#1a2040", intensity: 0.15, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
423
+ ],
424
+ },
425
+ "golden-hour": {
426
+ description: "Warm low-angle sunlight with long shadows. Complementary cool sky ambient.",
427
+ lights: [
428
+ { entityId: "entity_rig_sun", name: "Sun", light: "directional", color: "#ffb347", intensity: 4.0, position: [6, 1.5, 3], rotation: IDENTITY_ROTATION, shadow: true },
429
+ { entityId: "entity_rig_sky", name: "Sky ambient", light: "ambient", color: "#8cb4d8", intensity: 0.5, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
430
+ ],
431
+ },
432
+ "neon-noir": {
433
+ description: "High-contrast colored point lights simulating neon signage. Deep shadows, no ambient fill.",
434
+ lights: [
435
+ { entityId: "entity_rig_neon_pink", name: "Neon pink", light: "point", color: "#ff1493", intensity: 8.0, position: [-3, 2, 1], rotation: IDENTITY_ROTATION },
436
+ { entityId: "entity_rig_neon_cyan", name: "Neon cyan", light: "point", color: "#00e5ff", intensity: 6.0, position: [3, 1, -2], rotation: IDENTITY_ROTATION },
437
+ { entityId: "entity_rig_neon_accent", name: "Neon accent", light: "point", color: "#7b68ee", intensity: 4.0, position: [0, 4, 0], rotation: IDENTITY_ROTATION },
438
+ { entityId: "entity_rig_ambient", name: "Ambient", light: "ambient", color: "#0a0a14", intensity: 0.05, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
439
+ ],
440
+ },
441
+ "interior-window": {
442
+ description: "Daylight from a single window direction. Warm interior ambient fill, exterior-facing key.",
443
+ lights: [
444
+ { entityId: "entity_rig_window", name: "Window", light: "directional", color: "#e8f0ff", intensity: 3.5, position: [5, 3, 0], rotation: IDENTITY_ROTATION, shadow: true },
445
+ { entityId: "entity_rig_bounce", name: "Bounce", light: "directional", color: "#fff0e0", intensity: 0.8, position: [-3, 1, 2], rotation: IDENTITY_ROTATION },
446
+ { entityId: "entity_rig_ambient", name: "Ambient", light: "ambient", color: "#f5e8d8", intensity: 0.35, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
447
+ ],
448
+ },
449
+ "volumetric-stage": {
450
+ description: "Theatrical spot lights with tight cones and visible falloff. Low ambient for dramatic effect.",
451
+ lights: [
452
+ { entityId: "entity_rig_spot_center", name: "Center spot", light: "spot", color: "#ffffff", intensity: 12.0, position: [0, 6, 0], rotation: IDENTITY_ROTATION, spot: { angle: 0.5, penumbra: 0.6, distance: 20, decay: 2 }, shadow: true },
453
+ { entityId: "entity_rig_spot_left", name: "Left spot", light: "spot", color: "#ffccaa", intensity: 8.0, position: [-4, 5, 2], rotation: IDENTITY_ROTATION, spot: { angle: 0.4, penumbra: 0.5, distance: 15, decay: 2 } },
454
+ { entityId: "entity_rig_spot_right", name: "Right spot", light: "spot", color: "#aaccff", intensity: 8.0, position: [4, 5, 2], rotation: IDENTITY_ROTATION, spot: { angle: 0.4, penumbra: 0.5, distance: 15, decay: 2 } },
455
+ { entityId: "entity_rig_ambient", name: "Ambient", light: "ambient", color: "#1a1a2e", intensity: 0.08, position: [0, 0, 0], rotation: IDENTITY_ROTATION },
456
+ ],
457
+ },
458
+ }
459
+
460
+ /** Returns the typed entity descriptors for a lighting rig preset. */
461
+ export function lightingRig(type: SpatialLightingRigType): { readonly description: string; readonly entities: readonly ReturnType<typeof rigEntity>[] } {
462
+ const rig = RIGS[type]
463
+ return deepFreezeJson({ description: rig.description, entities: rig.lights.map(rigEntity) })
464
+ }
465
+
466
+ /** Returns the description only, without allocating entity descriptors. */
467
+ export function lightingRigDescription(type: SpatialLightingRigType): string {
468
+ return RIGS[type].description
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Material probe gallery planning
473
+ // ---------------------------------------------------------------------------
474
+
475
+ /** Geometry choices for material probe galleries. */
476
+ export const SpatialProbeGeometrySchema = z.enum(["plane", "sphere", "hero"])
477
+ export type SpatialProbeGeometry = z.infer<typeof SpatialProbeGeometrySchema>
478
+
479
+ /**
480
+ * A material probe gallery plan: describes the set of geometry, lighting, and
481
+ * camera configurations needed to evaluate a candidate material. The plan is
482
+ * data only; the host builds actual scenes from it.
483
+ */
484
+ export interface SpatialMaterialProbeGalleryPlan {
485
+ readonly kind: "slopcamera.spatial-material-probe-gallery"
486
+ readonly schemaVersion: 1
487
+ readonly gallerySha256: string
488
+ readonly materialSha256: string
489
+ readonly material: SpatialPbrMaterial
490
+ readonly heroAsset?: { readonly assetId: string; readonly manifestSha256: string; readonly bounds: { readonly min: readonly [number, number, number]; readonly max: readonly [number, number, number] } }
491
+ readonly geometries: readonly SpatialProbeGeometry[]
492
+ readonly lightingRigs: readonly SpatialLightingRigType[]
493
+ readonly cameraDistances: readonly number[]
494
+ readonly cells: readonly { readonly cellId: string; readonly geometry: SpatialProbeGeometry; readonly lightingRig: SpatialLightingRigType; readonly cameraDistance: number }[]
495
+ readonly description: string
496
+ }
497
+
498
+ const DEFAULT_PROBE_GEOMETRIES: readonly SpatialProbeGeometry[] = Object.freeze(["plane", "sphere", "hero"])
499
+ const DEFAULT_PROBE_RIGS: readonly SpatialLightingRigType[] = Object.freeze(["product", "golden-hour", "moonlight"])
500
+ export const ORIGINAL_MATERIAL_HERO_FIXTURE = deepFreezeJson({ assetId: "asset_material_hero_original", manifestSha256: sha256Hex("slopcamera-original-material-hero-v1"), bounds: { min: [-0.8, -1, -0.55] as [number, number, number], max: [0.8, 1, 0.55] as [number, number, number] } })
501
+ const DEFAULT_CAMERA_DISTANCES: readonly number[] = Object.freeze([3, 6])
502
+
503
+ /**
504
+ * Plans a material probe gallery. Each combination of geometry and lighting rig
505
+ * produces one probe scene; the host renders them all to compare the material
506
+ * under controlled conditions.
507
+ */
508
+ export function planMaterialProbeGallery(input: {
509
+ readonly material: SpatialPbrMaterial
510
+ readonly geometries?: readonly SpatialProbeGeometry[]
511
+ readonly lightingRigs?: readonly SpatialLightingRigType[]
512
+ readonly cameraDistances?: readonly number[]
513
+ readonly heroAsset?: { readonly assetId: string; readonly manifestSha256: string; readonly bounds: { readonly min: readonly [number, number, number]; readonly max: readonly [number, number, number] } }
514
+ }): SpatialMaterialProbeGalleryPlan {
515
+ const material = SpatialPbrMaterialSchema.parse(input.material)
516
+ const geometries = input.geometries ?? DEFAULT_PROBE_GEOMETRIES
517
+ const rigs = input.lightingRigs ?? DEFAULT_PROBE_RIGS
518
+ const distances = input.cameraDistances ?? DEFAULT_CAMERA_DISTANCES
519
+ const heroAsset = input.heroAsset ?? ORIGINAL_MATERIAL_HERO_FIXTURE
520
+ for (const geometry of geometries) SpatialProbeGeometrySchema.parse(geometry)
521
+ for (const rig of rigs) SpatialLightingRigTypeSchema.parse(rig)
522
+ for (const distance of distances) {
523
+ if (!Number.isFinite(distance) || distance <= 0 || distance > 1_000_000) {
524
+ throw new RangeError("Camera distances must be positive finite values.")
525
+ }
526
+ }
527
+ if (geometries.length < 1 || rigs.length < 1 || distances.length < 1 || geometries.length * rigs.length * distances.length > 64) throw new RangeError("Material probe gallery requires 1–64 bounded cells.")
528
+ if (geometries.includes("hero")) {
529
+ if (!/^[a-f0-9]{64}$/u.test(heroAsset.manifestSha256)) throw new RangeError("Hero probe geometry requires an exact hero asset manifest binding.")
530
+ for (let axis = 0; axis < 3; axis++) if (!Number.isFinite(heroAsset.bounds.min[axis]) || !Number.isFinite(heroAsset.bounds.max[axis]) || heroAsset.bounds.min[axis]! > heroAsset.bounds.max[axis]!) throw new RangeError("Hero probe geometry requires finite ordered bounds.")
531
+ }
532
+ const extensions: string[] = []
533
+ if (material.clearcoat !== undefined) extensions.push("clearcoat")
534
+ if (material.transmission !== undefined) extensions.push("transmission")
535
+ if (material.sheen !== undefined) extensions.push("sheen")
536
+ if (material.anisotropy !== undefined) extensions.push("anisotropy")
537
+ const extensionNote = extensions.length > 0 ? ` Extensions: ${extensions.join(", ")}.` : ""
538
+ const mapCount = pbrMaterialMapAssetIds(material).length
539
+ const description = `Probe gallery: ${geometries.length} geometr${geometries.length === 1 ? "y" : "ies"} \u00d7 ${rigs.length} rig${rigs.length === 1 ? "" : "s"} \u00d7 ${distances.length} distance${distances.length === 1 ? "" : "s"} = ${geometries.length * rigs.length * distances.length} probe${geometries.length * rigs.length * distances.length === 1 ? "" : "s"}. ${mapCount} texture map${mapCount === 1 ? "" : "s"} bound.${extensionNote}`
540
+ const materialSha256 = sha256Hex(canonicalJson({ domain: "slopcamera.spatial-material/v1", material }))
541
+ const cells = geometries.flatMap(geometry => rigs.flatMap(lightingRig => distances.map(cameraDistance => ({ cellId: sha256Hex(canonicalJson({ domain: "slopcamera.spatial-material-probe-cell/v1", materialSha256, geometry, lightingRig, cameraDistance, ...(geometry === "hero" ? { heroAsset } : {}) })), geometry, lightingRig, cameraDistance }))))
542
+ const body = { kind: "slopcamera.spatial-material-probe-gallery" as const, schemaVersion: 1 as const, materialSha256, material, ...(geometries.includes("hero") ? { heroAsset } : {}), geometries: [...geometries], lightingRigs: [...rigs], cameraDistances: [...distances], cells, description }
543
+ return deepFreezeJson({ ...body, gallerySha256: sha256Hex(canonicalJson({ domain: "slopcamera.spatial-material-probe-gallery/v1", body })) })
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Strict pre-render validation
548
+ // ---------------------------------------------------------------------------
549
+
550
+ export interface SpatialPbrValidationResult {
551
+ readonly valid: boolean
552
+ readonly errors: readonly string[]
553
+ }
554
+
555
+ /**
556
+ * Validates a PBR material against available scene assets. Rejects missing
557
+ * maps, mismatched dimensions, unsupported channels, and pixel overflow before
558
+ * any rendering occurs.
559
+ */
560
+ export function validatePbrMaterial(
561
+ material: SpatialPbrMaterial,
562
+ assetLookup: ReadonlyMap<string, { readonly width: number; readonly height: number; readonly kind: string }>,
563
+ pixelBudget = 67_108_864,
564
+ ): SpatialPbrValidationResult {
565
+ const errors: string[] = []
566
+ let totalPixels = 0
567
+ let expectedDimensions: readonly [number, number] | undefined
568
+ const countedAssets = new Set<string>()
569
+ const validateMap = (map: SpatialPbrMap | undefined, label: string) => {
570
+ if (map === undefined) return
571
+ const asset = assetLookup.get(map.assetId)
572
+ if (asset === undefined) {
573
+ errors.push(`${label}: references missing asset ${map.assetId}.`)
574
+ return
575
+ }
576
+ if (asset.kind !== "image") {
577
+ errors.push(`${label}: asset ${map.assetId} is ${asset.kind}, not an image.`)
578
+ return
579
+ }
580
+ if (!Number.isInteger(asset.width) || !Number.isInteger(asset.height) || asset.width < 1 || asset.height < 1) {
581
+ errors.push(`${label}: asset ${map.assetId} has invalid decoded dimensions.`)
582
+ return
583
+ }
584
+ if (expectedDimensions !== undefined && (asset.width !== expectedDimensions[0] || asset.height !== expectedDimensions[1])) errors.push(`${label}: decoded dimensions do not match the other PBR maps.`)
585
+ expectedDimensions ??= [asset.width, asset.height]
586
+ if (!countedAssets.has(map.assetId)) {
587
+ countedAssets.add(map.assetId)
588
+ totalPixels += asset.width * asset.height
589
+ if (totalPixels > pixelBudget) errors.push(`${label}: total decoded pixels (${totalPixels}) exceed budget (${pixelBudget}).`)
590
+ }
591
+ }
592
+ validateMap(material.baseColorMap, "baseColorMap")
593
+ validateMap(material.normalMap, "normalMap")
594
+ validateMap(material.ormMap, "ormMap")
595
+ validateMap(material.roughnessMap, "roughnessMap")
596
+ validateMap(material.metalnessMap, "metalnessMap")
597
+ validateMap(material.aoMap, "aoMap")
598
+ validateMap(material.heightMap, "heightMap")
599
+ validateMap(material.emissive?.map, "emissive.map")
600
+ validateMap(material.clearcoat?.map, "clearcoat.map")
601
+ validateMap(material.clearcoat?.roughnessMap, "clearcoat.roughnessMap")
602
+ validateMap(material.clearcoat?.normalMap, "clearcoat.normalMap")
603
+ validateMap(material.transmission?.map, "transmission.map")
604
+ validateMap(material.sheen?.colorMap, "sheen.colorMap")
605
+ validateMap(material.sheen?.roughnessMap, "sheen.roughnessMap")
606
+ validateMap(material.anisotropy?.map, "anisotropy.map")
607
+
608
+ return deepFreezeJson({ valid: errors.length === 0, errors })
609
+ }
@@ -21,6 +21,8 @@ export type Perspective = View & Readonly<{ kind: "perspective"; fx: number; fy:
21
21
  export type Orthographic = View & Readonly<{ kind: "orthographic"; left: number; right: number; bottom: number; top: number }>;
22
22
  export type Projection = Perspective | Orthographic;
23
23
  export type Camera = Readonly<{ projection: Projection; cameraToWorld: Mat4 }>;
24
+ /** A validated camera plus its world-to-camera inverse, shared by batch projections. */
25
+ export type PreparedCameraView = Camera & Readonly<{ worldToCamera: Mat4 }>;
24
26
  export type ProjectedPoint = Readonly<{ pixel: Vec2; depthMeters: number; insideImage: boolean; insideClip: boolean }>;
25
27
  export type Ray = Readonly<{ origin: Vec3; direction: Vec3; nearDistanceMeters: number; farDistanceMeters: number }>;
26
28
  export type Bounds = Readonly<{ min: Vec3; max: Vec3 }>;
@@ -204,18 +206,28 @@ function camera(camera: Camera): void {
204
206
  if (determinant <= 0) throw new RangeError("camera pose must preserve handedness");
205
207
  }
206
208
 
209
+ /**
210
+ * Validates a camera once and precomputes its world-to-camera inverse so batch
211
+ * projections through `projectPreparedPoint` share both. The rigid, handed
212
+ * camera composeTransform produces always inverts cleanly, so callers observe
213
+ * the same failures `projectPoint` raised per point.
214
+ */
215
+ export function prepareCameraView(view: Camera): PreparedCameraView {
216
+ camera(view);
217
+ return Object.freeze({ ...view, worldToCamera: invertTransform(view.cameraToWorld) });
218
+ }
219
+
207
220
  /**
208
221
  * Returns null on/behind the camera plane. Positive-depth offscreen or clipped
209
222
  * points remain projectable, with separate flags. Image bounds are half-open;
210
223
  * near/far depth boundaries are inclusive. No rasterization epsilon is implied.
211
224
  */
212
- export function projectPoint(view: Camera, worldPoint: Vec3): ProjectedPoint | null {
213
- camera(view);
225
+ export function projectPreparedPoint(prepared: PreparedCameraView, worldPoint: Vec3): ProjectedPoint | null {
214
226
  values(worldPoint, 3, "world point");
215
- const local = apply(invertTransform(view.cameraToWorld), worldPoint, true);
227
+ const local = apply(prepared.worldToCamera, worldPoint, true);
216
228
  const depthMeters = -local[2];
217
229
  if (depthMeters <= 0) return null;
218
- const p = view.projection;
230
+ const p = prepared.projection;
219
231
  const pixel = p.kind === "perspective"
220
232
  ? vec2(p.fx*local[0]/depthMeters+p.cx, p.cy-p.fy*local[1]/depthMeters)
221
233
  : vec2((local[0]-p.left)/(p.right-p.left)*p.width, (p.top-local[1])/(p.top-p.bottom)*p.height);
@@ -224,6 +236,11 @@ export function projectPoint(view: Camera, worldPoint: Vec3): ProjectedPoint | n
224
236
  insideClip: depthMeters >= p.near && depthMeters <= p.far });
225
237
  }
226
238
 
239
+ /** Single-point convenience over `prepareCameraView` + `projectPreparedPoint`. */
240
+ export function projectPoint(view: Camera, worldPoint: Vec3): ProjectedPoint | null {
241
+ return projectPreparedPoint(prepareCameraView(view), worldPoint);
242
+ }
243
+
227
244
  /** Pixel and depth may lie outside image/clip bounds; depth must remain positive. */
228
245
  export function unprojectPixel(view: Camera, pixel: Vec2, depthMeters: number): Vec3 {
229
246
  camera(view);