@hraness/slopcamera 3.2.8 → 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -9
- package/apps/desktop/application/context.ts +6 -0
- package/apps/desktop/application/default-registry.ts +26 -0
- package/apps/desktop/application/operation.ts +14 -1
- package/apps/desktop/application/operations/index.ts +4 -0
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +514 -11
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +29 -1
- package/apps/desktop/cli/commands.ts +1188 -4
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +172 -6
- package/apps/desktop/cli/main.ts +3 -3
- package/apps/desktop/cli/portable-surface.ts +6 -1
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +271 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/semantic-builder.ts +24 -0
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +725 -273
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +25 -429
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-gs48afpt.js +7 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index-zhg36tf9.js +3 -0
- package/dist/index.js +1 -226
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +12 -0
- package/package.json +7 -5
- package/skills/slopcamera/SKILL.md +7 -1
- package/skills/slopcamera/references/directed-scenes.md +48 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/music-video.md +4 -0
- package/skills/slopcamera/references/native-studio.md +19 -0
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +172 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +222 -4
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -0
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1649 -132
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +232 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +108 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +617 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/version.ts +1 -1
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7131pg1c.js +0 -1820
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-fava6pge.js +0 -192
- package/dist/index-h1k0fnjq.js +0 -2363
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-zfnddgay.js +0 -2029
- package/dist/skill-install-dh0ntqba.js +0 -10
|
@@ -0,0 +1,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
|
|
213
|
-
camera(view);
|
|
225
|
+
export function projectPreparedPoint(prepared: PreparedCameraView, worldPoint: Vec3): ProjectedPoint | null {
|
|
214
226
|
values(worldPoint, 3, "world point");
|
|
215
|
-
const local = apply(
|
|
227
|
+
const local = apply(prepared.worldToCamera, worldPoint, true);
|
|
216
228
|
const depthMeters = -local[2];
|
|
217
229
|
if (depthMeters <= 0) return null;
|
|
218
|
-
const p =
|
|
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);
|