@hyprune/schema 0.5.2 → 0.7.0
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/CHANGELOG.md +156 -0
- package/README.md +73 -4
- package/bin/validate.mjs +15 -5
- package/examples/library-releases.json +11 -0
- package/examples/library.json +18 -0
- package/examples/object-character.json +69 -0
- package/examples/object-machine.json +338 -0
- package/examples/object-seat.json +59 -0
- package/examples/object.json +148 -0
- package/extensions/blueprint.schema.json +29 -0
- package/lib/validate.d.mts +29 -2
- package/lib/validate.mjs +494 -8
- package/package.json +1 -1
- package/v0.10/library.schema.json +93 -0
- package/v0.10/object.schema.json +1713 -0
- package/v0.20/ipc.schema.json +11800 -0
- package/v0.21/focus-history.schema.json +82 -0
- package/v0.21/guidance.schema.json +118 -0
- package/v0.21/ipc.schema.json +15726 -0
- package/v0.21/quests-personal.schema.json +448 -0
- package/v0.22/ipc.schema.json +17466 -0
- package/v0.8/common.schema.json +478 -0
- package/v0.8/world.schema.json +1261 -0
- package/v0.9/library-releases.schema.json +53 -0
- package/v0.9/library.schema.json +93 -0
- package/v0.9/object.schema.json +739 -0
package/lib/validate.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import Ajv2020 from 'ajv/dist/2020.js';
|
|
|
3
3
|
import parseSpdx from 'spdx-expression-parse';
|
|
4
4
|
|
|
5
5
|
const names = ['common', 'world', 'extension', 'screen-type', 'ipc'];
|
|
6
|
+
// Object, library and library release record schemas (RFC-0011) live in v0.N directories only.
|
|
7
|
+
const manifestKinds = ['object', 'library', 'library-releases'];
|
|
6
8
|
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
7
9
|
const base = 'https://hyprune.com/schema/v0/';
|
|
8
10
|
for (const name of names) {
|
|
@@ -14,7 +16,7 @@ const root = new URL('../', import.meta.url);
|
|
|
14
16
|
const byVersion = (a, b) => { const [x, y] = [a, b].map(v => v.split('.').map(Number)); return x[0] - y[0] || x[1] - y[1]; };
|
|
15
17
|
const versions = readdirSync(root).filter(d => /^v0\.\d+$/.test(d)).map(d => d.slice(1)).sort(byVersion);
|
|
16
18
|
const has = (version, name) => existsSync(new URL(`v${version}/${name}.schema.json`, root));
|
|
17
|
-
for (const v of versions) for (const name of ['common', 'world', 'ipc'])
|
|
19
|
+
for (const v of versions) for (const name of ['common', 'world', 'ipc', ...manifestKinds])
|
|
18
20
|
if (has(v, name)) ajv.addSchema(JSON.parse(readFileSync(new URL(`v${v}/${name}.schema.json`, root))));
|
|
19
21
|
const validators = new Map();
|
|
20
22
|
|
|
@@ -31,16 +33,41 @@ export const worldFormats = ['0.1', ...versions.filter(v => has(v, 'world'))];
|
|
|
31
33
|
// versions are all of them (AGENTS rule 6: no hand-kept version lists).
|
|
32
34
|
export const ipcProtocols = ['0.1', ...versions.filter(v => has(v, 'ipc'))];
|
|
33
35
|
export const contractVersions = ['0.1', ...versions];
|
|
36
|
+
// Object manifest formats (RFC-0011): every v0.N directory with an object schema.
|
|
37
|
+
// They share the world format numbering and start at 0.9.
|
|
38
|
+
export const objectFormats = versions.filter(v => has(v, 'object'));
|
|
34
39
|
// glTF extensions a package may require, by world format (RFC-0004).
|
|
35
40
|
export function sceneExtensions(version) {
|
|
36
41
|
const base = ['KHR_materials_unlit','KHR_texture_transform','KHR_materials_emissive_strength'];
|
|
37
42
|
return atLeast(version, '0.7') ? [...base,'KHR_texture_basisu','EXT_meshopt_compression','KHR_mesh_quantization'] : base;
|
|
38
43
|
}
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// World 0.8 object asset rules that need the asset's glTF (RFC-0004 "world 0.8"):
|
|
45
|
+
// referenced node names exist once, animated subtrees do not nest, and the
|
|
46
|
+
// collision proxy has a mesh and lies outside every animated subtree.
|
|
47
|
+
function validateObjectAsset(gltf, asset, fail, label = `objectAssets ${asset.id}`) {
|
|
48
|
+
const nodes = gltf.nodes || [], parents = new Map();
|
|
49
|
+
nodes.forEach((n, i) => { for (const c of n.children || []) parents.set(c, i); });
|
|
50
|
+
const find = name => {
|
|
51
|
+
const hits = nodes.flatMap((n, i) => n.name === name ? [i] : []);
|
|
52
|
+
if (hits.length !== 1) fail(`${label}: node "${name}" ${hits.length ? 'is not unique' : 'not found'} in the asset`);
|
|
53
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
54
|
+
};
|
|
55
|
+
const ancestors = node => { const out = []; for (let p = parents.get(node), n = 0; p !== undefined && n <= nodes.length; p = parents.get(p), n++) out.push(p); return out; };
|
|
56
|
+
const targets = new Map();
|
|
57
|
+
for (const a of asset.animations || []) { const i = find(a.node); if (i !== undefined) targets.set(i, a.node); }
|
|
58
|
+
for (const [node, name] of targets) for (const p of ancestors(node))
|
|
59
|
+
if (targets.has(p)) fail(`${label}: animation "${name}" is inside animated node "${targets.get(p)}"`);
|
|
60
|
+
if (asset.collision) {
|
|
61
|
+
const i = find(asset.collision.node);
|
|
62
|
+
if (i !== undefined) {
|
|
63
|
+
if (nodes[i].mesh === undefined) fail(`${label}: collision node "${asset.collision.node}" has no mesh`);
|
|
64
|
+
for (const p of [i, ...ancestors(i)]) if (targets.has(p)) fail(`${label}: collision node "${asset.collision.node}" is inside animated node "${targets.get(p)}"`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// glTF extension rules shared by scene.gltf, 0.8 object assets and object manifests:
|
|
69
|
+
// extensionsRequired within the format's list, KTX2 and meshopt listed when used.
|
|
70
|
+
function sceneExtensionRules(gltf, version, fail) {
|
|
44
71
|
const allowed = new Set(sceneExtensions(version)), required = new Set(gltf.extensionsRequired || []);
|
|
45
72
|
for (const id of required) if (!allowed.has(id)) fail(`extensionsRequired: ${id} is not allowed in world ${version}`);
|
|
46
73
|
const textures = gltf.textures || [], images = gltf.images || [];
|
|
@@ -55,6 +82,22 @@ function validateScene(gltf, version, world) {
|
|
|
55
82
|
if ((gltf.bufferViews || []).some(v => v.extensions?.EXT_meshopt_compression) && !required.has('EXT_meshopt_compression'))
|
|
56
83
|
fail(allowed.has('EXT_meshopt_compression') ? 'extensionsRequired: EXT_meshopt_compression must be listed when buffer views use it'
|
|
57
84
|
: 'bufferViews: EXT_meshopt_compression needs world 0.7');
|
|
85
|
+
}
|
|
86
|
+
// scene.gltf rules that depend on the world format: extensionsRequired, and
|
|
87
|
+
// lightmap textures (world.lightmaps[].texture) stay PNG in every v0 format.
|
|
88
|
+
// With options.asset, `gltf` is that world 0.8 object asset's glTF instead: the
|
|
89
|
+
// same extension rules, the asset rules above, and no lightmaps (assets are never baked).
|
|
90
|
+
function validateScene(gltf, version, world, assetId) {
|
|
91
|
+
const errors = [], fail = m => errors.push(m);
|
|
92
|
+
if (!worldFormats.includes(version)) throw new Error(`Unknown world format: ${version}`);
|
|
93
|
+
sceneExtensionRules(gltf, version, fail);
|
|
94
|
+
const textures = gltf.textures || [], images = gltf.images || [];
|
|
95
|
+
if (assetId !== undefined) {
|
|
96
|
+
const asset = atLeast(version, '0.8') ? (world?.objectAssets || []).find(a => a.id === assetId) : undefined;
|
|
97
|
+
if (!asset) fail(`objectAssets: unknown asset ${assetId} for world ${version}`);
|
|
98
|
+
else validateObjectAsset(gltf, asset, fail);
|
|
99
|
+
return { valid: errors.length === 0, errors };
|
|
100
|
+
}
|
|
58
101
|
for (const lightmap of world?.lightmaps || []) {
|
|
59
102
|
const texture = textures[lightmap.texture];
|
|
60
103
|
if (!texture) { fail(`lightmaps: texture ${lightmap.texture} missing from scene`); continue; }
|
|
@@ -65,13 +108,436 @@ function validateScene(gltf, version, world) {
|
|
|
65
108
|
return { valid: errors.length === 0, errors };
|
|
66
109
|
}
|
|
67
110
|
|
|
111
|
+
// Object manifests, libraries and library release records (RFC-0011). Origin
|
|
112
|
+
// conventions: floor, wall and ceiling objects within 1 cm, a character's
|
|
113
|
+
// rest-pose feet within 5 cm. The manifest-only rules always run; options add the rules that need more than
|
|
114
|
+
// object.json: `gltf` (the parsed scene glTF, or a GLB's JSON chunk),
|
|
115
|
+
// `directory` (its directory name), `files` ([{path, size}] of the directory),
|
|
116
|
+
// `thumbnail` ({width, height, bytes}) and `measured` (values only a geometry
|
|
117
|
+
// decoder knows: exact rest `bounds`, `maxTextureSize`, `gpuBytes`).
|
|
118
|
+
// Directory and asset limits (RFC-0011 "Budgets and limits", provisional).
|
|
119
|
+
export const objectLimits = Object.freeze({
|
|
120
|
+
files: 64, directoryBytes: 64 * 1048576, fileBytes: 32 * 1048576,
|
|
121
|
+
triangles: 50000, characterTriangles: 16000, itemTriangles: 2000,
|
|
122
|
+
textureSize: 2048, characterTextureSize: 1024, gpuBytes: 48 * 1048576, itemGpuBytes: 1048576,
|
|
123
|
+
collisionTriangles: 2000, thumbnailSize: 256, thumbnailBytes: 262144,
|
|
124
|
+
libraryFiles: 4000, libraryBytes: 512 * 1048576, boundsTolerance: 0.01, characterOriginTolerance: 0.05, slotMargin: 0.5,
|
|
125
|
+
});
|
|
126
|
+
// Which traits may share an asset (RFC-0011 "Allowed combinations"; machine per RFC-0013).
|
|
127
|
+
const traitPairs = {
|
|
128
|
+
decorative: ['collidable', 'movable', 'seat', 'mover', 'machine'], collidable: ['decorative', 'movable', 'seat', 'machine'],
|
|
129
|
+
movable: ['decorative', 'collidable', 'seat', 'machine'], character: [], mover: ['decorative'],
|
|
130
|
+
seat: ['decorative', 'collidable', 'movable'], machine: ['decorative', 'collidable', 'movable'],
|
|
131
|
+
};
|
|
132
|
+
function reservedTraits(version) {
|
|
133
|
+
const reserved = { mount: 'reserved for props that hold a window (RFC-0011 P4)', interactive: 'reserved for use actions (RFC-0011 P4)' };
|
|
134
|
+
if (!atLeast(version, '0.10')) Object.assign(reserved, { mover: 'needs object format 0.10', machine: 'reserved for RFC-0013 live objects (object format 0.10)' });
|
|
135
|
+
return reserved;
|
|
136
|
+
}
|
|
137
|
+
const componentMax = { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 };
|
|
138
|
+
function nodeMatrix(n) {
|
|
139
|
+
if (n.matrix) return n.matrix.slice();
|
|
140
|
+
const [x, y, z, w] = n.rotation || [0, 0, 0, 1], [sx, sy, sz] = n.scale || [1, 1, 1], t = n.translation || [0, 0, 0];
|
|
141
|
+
return [(1 - 2 * (y * y + z * z)) * sx, 2 * (x * y + z * w) * sx, 2 * (x * z - y * w) * sx, 0,
|
|
142
|
+
2 * (x * y - z * w) * sy, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z + x * w) * sy, 0,
|
|
143
|
+
2 * (x * z + y * w) * sz, 2 * (y * z - x * w) * sz, (1 - 2 * (x * x + y * y)) * sz, 0, t[0], t[1], t[2], 1];
|
|
144
|
+
}
|
|
145
|
+
const mul = (a, b) => Array.from({ length: 16 }, (_, i) => { const c = i >> 2, r = i & 3; return a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]; });
|
|
146
|
+
const apply = (m, p) => [0, 1, 2].map(r => m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]);
|
|
147
|
+
function primitiveTriangles(gltf, p) {
|
|
148
|
+
const count = gltf.accessors?.[p.indices ?? p.attributes?.POSITION]?.count || 0, mode = p.mode ?? 4;
|
|
149
|
+
return mode === 4 ? Math.floor(count / 3) : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0;
|
|
150
|
+
}
|
|
151
|
+
// The drawn scene graph: world matrix per reachable node (first visit wins; cycles stop).
|
|
152
|
+
function sceneNodes(gltf) {
|
|
153
|
+
const nodes = gltf.nodes || [], scene = gltf.scenes?.[gltf.scene ?? 0], out = new Map();
|
|
154
|
+
const visit = (i, parent, depth) => {
|
|
155
|
+
if (out.has(i) || !nodes[i] || depth > 64) return;
|
|
156
|
+
const m = mul(parent, nodeMatrix(nodes[i])); out.set(i, m);
|
|
157
|
+
for (const c of nodes[i].children || []) visit(c, m, depth + 1);
|
|
158
|
+
};
|
|
159
|
+
for (const i of scene?.nodes || []) visit(i, nodeMatrix({}), 0);
|
|
160
|
+
return { scene, out };
|
|
161
|
+
}
|
|
162
|
+
// Rest-pose bounds from accessor min/max: exact when every mesh's world matrix is
|
|
163
|
+
// axis-aligned, else a box that contains the true bounds. Skinned meshes: unknown.
|
|
164
|
+
function accessorBounds(gltf, drawn) {
|
|
165
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
166
|
+
let exact = true, skinned = false;
|
|
167
|
+
for (const [i, m] of drawn) {
|
|
168
|
+
const node = gltf.nodes[i], mesh = gltf.meshes?.[node.mesh];
|
|
169
|
+
if (!mesh) continue;
|
|
170
|
+
if (node.skin !== undefined) { skinned = true; continue; }
|
|
171
|
+
for (let c = 0; c < 3; c++) if ([0, 1, 2].filter(r => Math.abs(m[c * 4 + r]) > 1e-6).length > 1) exact = false;
|
|
172
|
+
for (const p of mesh.primitives || []) {
|
|
173
|
+
const a = gltf.accessors?.[p.attributes?.POSITION];
|
|
174
|
+
if (!a?.min || !a?.max) { skinned = true; continue; }
|
|
175
|
+
const scale = a.normalized && [...a.min, ...a.max].some(v => Math.abs(v) > 1) ? componentMax[a.componentType] || 1 : 1;
|
|
176
|
+
const [mn, mx] = [a.min, a.max].map(v => v.map(x => Math.max(x / scale, a.normalized ? -1 : -Infinity)));
|
|
177
|
+
for (let k = 0; k < 8; k++) {
|
|
178
|
+
const w = apply(m, [k & 1 ? mx[0] : mn[0], k & 2 ? mx[1] : mn[1], k & 4 ? mx[2] : mn[2]]);
|
|
179
|
+
for (let j = 0; j < 3; j++) { lo[j] = Math.min(lo[j], w[j]); hi[j] = Math.max(hi[j], w[j]); }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return skinned || lo[0] === Infinity ? undefined : { min: lo, max: hi, exact };
|
|
184
|
+
}
|
|
185
|
+
function validateObjectGltf(value, version, gltf, options, fail, warn) {
|
|
186
|
+
const traits = value.traits, slots = value.slots || {}, nodes = gltf.nodes || [], materials = gltf.materials || [];
|
|
187
|
+
sceneExtensionRules(gltf, version, fail);
|
|
188
|
+
const { scene, out: drawn } = sceneNodes(gltf);
|
|
189
|
+
if (!scene) { fail('scene: the glTF has no scene'); return; }
|
|
190
|
+
const byName = new Map();
|
|
191
|
+
nodes.forEach((n, i) => { if (n.name !== undefined) byName.set(n.name, [...(byName.get(n.name) || []), i]); });
|
|
192
|
+
const node = (name, label, { inScene = true } = {}) => {
|
|
193
|
+
const hits = byName.get(name) || [];
|
|
194
|
+
if (hits.length !== 1) { fail(`${label}: node "${name}" ${hits.length ? 'is not unique' : 'not found'} in the glTF`); return undefined; }
|
|
195
|
+
if (inScene && !drawn.has(hits[0])) fail(`${label}: node "${name}" is not in the scene`);
|
|
196
|
+
return hits[0];
|
|
197
|
+
};
|
|
198
|
+
// The 0.8 asset rules: animation and collision node names, nesting, a static proxy mesh.
|
|
199
|
+
validateObjectAsset(gltf, { animations: traits.decorative?.animations, collision: traits.collidable }, fail, 'traits');
|
|
200
|
+
for (const a of traits.decorative?.animations || []) if ((byName.get(a.node) || []).length === 1 && !drawn.has(byName.get(a.node)[0])) fail(`traits.decorative: node "${a.node}" is not in the scene`);
|
|
201
|
+
// Slots: names exist once; a material or node belongs to one states slot; variants map to palette ids.
|
|
202
|
+
const materialIndex = name => {
|
|
203
|
+
const hits = materials.flatMap((m, i) => m.name === name ? [i] : []);
|
|
204
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
205
|
+
};
|
|
206
|
+
const owner = new Map(), slotPrimitives = new Map();
|
|
207
|
+
const subtree = i => { const seen = new Set(), stack = [i]; while (stack.length) { const n = stack.pop(); if (seen.has(n) || !nodes[n]) continue; seen.add(n); stack.push(...(nodes[n].children || [])); } return [...seen]; };
|
|
208
|
+
for (const slot of slots.states || []) {
|
|
209
|
+
const label = `slots.states ${slot.id}`, prims = [];
|
|
210
|
+
for (const name of slot.materials || []) {
|
|
211
|
+
const hits = materials.filter(m => m.name === name).length;
|
|
212
|
+
if (hits !== 1) { fail(`${label}: material "${name}" ${hits ? 'is not unique' : 'not found'} in the glTF`); continue; }
|
|
213
|
+
const i = materialIndex(name);
|
|
214
|
+
if (owner.has('m' + i)) fail(`${label}: material "${name}" is also in states slot ${owner.get('m' + i)}`); owner.set('m' + i, slot.id);
|
|
215
|
+
for (const [n] of drawn) for (const p of gltf.meshes?.[nodes[n].mesh]?.primitives || []) if (p.material === i) prims.push(p);
|
|
216
|
+
if (!prims.length) warn(`${label}: material "${name}" is not used by the scene`);
|
|
217
|
+
}
|
|
218
|
+
for (const name of slot.nodes || []) {
|
|
219
|
+
const i = node(name, label);
|
|
220
|
+
if (i === undefined) continue;
|
|
221
|
+
if (owner.has('n' + i)) fail(`${label}: node "${name}" is also in states slot ${owner.get('n' + i)}`); owner.set('n' + i, slot.id);
|
|
222
|
+
const meshes = subtree(i).filter(n => nodes[n].mesh !== undefined);
|
|
223
|
+
if (!meshes.length) fail(`${label}: node "${name}" has no mesh in its subtree`);
|
|
224
|
+
for (const n of meshes) prims.push(...(gltf.meshes?.[nodes[n].mesh]?.primitives || []));
|
|
225
|
+
}
|
|
226
|
+
slotPrimitives.set(slot.id, prims);
|
|
227
|
+
}
|
|
228
|
+
const variants = gltf.extensions?.KHR_materials_variants?.variants;
|
|
229
|
+
if (variants) {
|
|
230
|
+
const names = variants.map(v => v.name);
|
|
231
|
+
if (new Set(names).size !== names.length) fail('KHR_materials_variants: duplicate variant names');
|
|
232
|
+
(gltf.meshes || []).forEach((mesh, m) => (mesh.primitives || []).forEach((p, k) => {
|
|
233
|
+
const seen = new Set();
|
|
234
|
+
for (const mapping of p.extensions?.KHR_materials_variants?.mappings || []) {
|
|
235
|
+
if (!materials[mapping.material]) fail(`meshes/${m}/primitives/${k}: variant mapping to unknown material ${mapping.material}`);
|
|
236
|
+
for (const v of mapping.variants || []) {
|
|
237
|
+
if (!variants[v]) fail(`meshes/${m}/primitives/${k}: unknown variant ${v}`);
|
|
238
|
+
if (seen.has(v)) fail(`meshes/${m}/primitives/${k}: variant ${v} is mapped twice`); seen.add(v);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}));
|
|
242
|
+
for (const slot of slots.states || []) {
|
|
243
|
+
const palette = new Set(slot.palette.map(e => e.id));
|
|
244
|
+
for (const p of slotPrimitives.get(slot.id) || []) for (const mapping of p.extensions?.KHR_materials_variants?.mappings || [])
|
|
245
|
+
for (const v of mapping.variants || []) if (variants[v] && !palette.has(variants[v].name))
|
|
246
|
+
fail(`slots.states ${slot.id}: variant "${variants[v].name}" is not a palette id of the slot`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const s of slots.stacks || []) node(s.node, `slots.stacks ${s.id}`);
|
|
250
|
+
for (const t of slots.toggles || []) for (const name of t.nodes) {
|
|
251
|
+
if (name === traits.collidable?.node) fail(`slots.toggles ${t.id}: the collision proxy can't be toggled`);
|
|
252
|
+
node(name, `slots.toggles ${t.id}`);
|
|
253
|
+
}
|
|
254
|
+
// Characters are skinned and carry their clips; other objects animate declaratively.
|
|
255
|
+
const clips = new Map((gltf.animations || []).map(a => [a.name, a]));
|
|
256
|
+
if (traits.character) {
|
|
257
|
+
if (!(gltf.skins || []).length) fail('traits.character: the glTF has no skin');
|
|
258
|
+
for (const [name, measured] of Object.entries(traits.character.clips)) {
|
|
259
|
+
const clip = clips.get(name);
|
|
260
|
+
if (!clip) { fail(`traits.character: clip "${name}" not found in the glTF`); continue; }
|
|
261
|
+
if (measured.speed !== undefined) {
|
|
262
|
+
const speed = clip.extras?.speed;
|
|
263
|
+
if (typeof speed !== 'number') fail(`traits.character: clip "${name}" has no extras.speed`);
|
|
264
|
+
else if (Math.abs(measured.speed - speed) > 0.02 * speed) fail(`traits.character: clip "${name}" speed ${measured.speed} differs from extras.speed ${speed} by more than 2%`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
if ((gltf.skins || []).length) fail('skins: only characters may be skinned');
|
|
269
|
+
if ((gltf.animations || []).length) warn('animations: glTF animations are ignored outside characters; use traits.decorative');
|
|
270
|
+
}
|
|
271
|
+
// Triangle budgets (drawn scene; proxy separately) and bounds.
|
|
272
|
+
let triangles = 0;
|
|
273
|
+
for (const [i] of drawn) for (const p of gltf.meshes?.[nodes[i].mesh]?.primitives || []) triangles += primitiveTriangles(gltf, p);
|
|
274
|
+
const item = traits.mover?.item === true;
|
|
275
|
+
const cap = traits.character ? objectLimits.characterTriangles : item ? objectLimits.itemTriangles : objectLimits.triangles;
|
|
276
|
+
if (triangles > cap) fail(`triangles: ${triangles} exceeds ${cap}`);
|
|
277
|
+
const proxy = traits.collidable && (byName.get(traits.collidable.node) || [])[0];
|
|
278
|
+
if (proxy !== undefined) {
|
|
279
|
+
const n = (gltf.meshes?.[nodes[proxy].mesh]?.primitives || []).reduce((s, p) => s + primitiveTriangles(gltf, p), 0);
|
|
280
|
+
if (n > objectLimits.collisionTriangles) fail(`traits.collidable: proxy has ${n} triangles, more than ${objectLimits.collisionTriangles}`);
|
|
281
|
+
}
|
|
282
|
+
if (!options.measured?.bounds) {
|
|
283
|
+
const b = accessorBounds(gltf, drawn), t = objectLimits.boundsTolerance;
|
|
284
|
+
if (!b) warn('bounds: not checked (skinned or unbounded geometry needs measured bounds)');
|
|
285
|
+
else for (let j = 0; j < 3; j++) {
|
|
286
|
+
const axis = 'xyz'[j];
|
|
287
|
+
if (b.exact ? Math.abs(value.bounds.min[j] - b.min[j]) > t : value.bounds.min[j] < b.min[j] - t) fail(`bounds: min ${axis} ${value.bounds.min[j]} does not match the scene (${+b.min[j].toFixed(4)})`);
|
|
288
|
+
if (b.exact ? Math.abs(value.bounds.max[j] - b.max[j]) > t : value.bounds.max[j] > b.max[j] + t) fail(`bounds: max ${axis} ${value.bounds.max[j]} does not match the scene (${+b.max[j].toFixed(4)})`);
|
|
289
|
+
}
|
|
290
|
+
if (b && !b.exact) warn('bounds: checked against a containing box only (rotated meshes); pass measured bounds for the 1 cm check');
|
|
291
|
+
}
|
|
292
|
+
return { triangles };
|
|
293
|
+
}
|
|
294
|
+
// Typed machine inputs, drives and recipes (RFC-0013, object format 0.10 draft).
|
|
295
|
+
function validateMachine(value, fail) {
|
|
296
|
+
const machine = value.traits.machine, slots = value.slots || {}, label = 'traits.machine';
|
|
297
|
+
const inputs = new Map();
|
|
298
|
+
for (const input of machine.inputs) {
|
|
299
|
+
if (inputs.has(input.id)) fail(`${label}: duplicate input ${input.id}`); inputs.set(input.id, input);
|
|
300
|
+
const d = input.default, il = `${label} input ${input.id}`;
|
|
301
|
+
if (['int', 'number'].includes(input.type)) {
|
|
302
|
+
if (input.min >= input.max) fail(`${il}: min must be below max`);
|
|
303
|
+
if (input.type === 'int' && ![input.min, input.max].every(Number.isInteger)) fail(`${il}: int bounds must be integers`);
|
|
304
|
+
}
|
|
305
|
+
if (d === undefined) continue;
|
|
306
|
+
const ok = { enum: () => input.values.includes(d), int: () => Number.isInteger(d) && d >= input.min && d <= input.max,
|
|
307
|
+
number: () => typeof d === 'number' && d >= input.min && d <= input.max, bool: () => typeof d === 'boolean',
|
|
308
|
+
text: () => typeof d === 'string' && d.length <= (input.maxLength ?? 64), event: () => false, items: () => false }[input.type];
|
|
309
|
+
if (!ok()) fail(`${il}: default ${JSON.stringify(d)} is not a valid ${input.type} value`);
|
|
310
|
+
}
|
|
311
|
+
const count = machine.inputs.reduce((n, i) => n + (i.repeat || 1), 0);
|
|
312
|
+
if (count > 16) fail(`${label}: ${count} inputs counting repeats, more than 16`);
|
|
313
|
+
if (machine.primary !== undefined && !inputs.has(machine.primary)) fail(`${label}: primary names unknown input ${machine.primary}`);
|
|
314
|
+
const resolve = (ref, where) => {
|
|
315
|
+
const [id, n] = ref.split('.'), input = inputs.get(id);
|
|
316
|
+
if (!input) { fail(`${where}: unknown input ${ref}`); return undefined; }
|
|
317
|
+
if (input.repeat ? n === undefined || Number(n) > input.repeat : n !== undefined) { fail(`${where}: ${ref} ${input.repeat ? `needs an index 1-${input.repeat}` : 'takes no index'}`); return undefined; }
|
|
318
|
+
return input;
|
|
319
|
+
};
|
|
320
|
+
// What a mapping can produce from an input: a set of scalars, a numeric range, or a kind.
|
|
321
|
+
const outputs = (map, input) => {
|
|
322
|
+
if (!map || !Object.keys(map).length) {
|
|
323
|
+
if (input.type === 'enum') return { values: input.values };
|
|
324
|
+
if (input.type === 'bool') return { values: [true, false] };
|
|
325
|
+
if (input.type === 'int' || input.type === 'number') return { range: [input.min, input.max], integer: input.type === 'int' };
|
|
326
|
+
return { kind: input.type };
|
|
327
|
+
}
|
|
328
|
+
if (map.cases || map.thresholds) return { values: [...(map.cases || map.thresholds).map(c => c.to), ...('else' in map ? [map.else] : [])] };
|
|
329
|
+
if (map.scale) return map.clamp === false ? { kind: 'unbounded' } : { range: [Math.min(...map.scale.to), Math.max(...map.scale.to)], integer: !!map.round };
|
|
330
|
+
if ('becomes' in map || map.counter) return { kind: 'event' };
|
|
331
|
+
if (map.format) return { kind: 'text' };
|
|
332
|
+
return { kind: 'unknown' };
|
|
333
|
+
};
|
|
334
|
+
const animated = new Set((value.traits.decorative?.animations || []).map(a => a.node));
|
|
335
|
+
const positions = new Set((machine.positions || []).map(p => p.node));
|
|
336
|
+
const slotIds = kind => new Set((slots[kind] || []).map(s => s.id));
|
|
337
|
+
const palettes = new Map((slots.states || []).map(s => [s.id, new Set(s.palette.map(e => e.id))]));
|
|
338
|
+
const within = (o, lo, hi, where) => {
|
|
339
|
+
if (o.values) { if (o.values.some(v => typeof v !== 'number' || v < lo || v > hi)) fail(`${where}: outputs must be numbers ${lo}-${hi}`); }
|
|
340
|
+
else if (o.range) { if (o.range[0] < lo || o.range[1] > hi) fail(`${where}: range ${o.range.join('-')} exceeds ${lo}-${hi}`); }
|
|
341
|
+
else fail(`${where}: needs a numeric mapping`);
|
|
342
|
+
};
|
|
343
|
+
for (const drive of machine.drives) {
|
|
344
|
+
const where = `${label} drive ${drive.input} -> ${drive.target}`, input = resolve(drive.input, where);
|
|
345
|
+
const [kind, name] = [drive.target.slice(0, drive.target.indexOf(':')), drive.target.slice(drive.target.indexOf(':') + 1)];
|
|
346
|
+
const known = { animation: () => animated.has(name), position: () => positions.has(name), state: () => palettes.has(name), emission: () => palettes.has(name),
|
|
347
|
+
label: () => slotIds('labels').has(name), visible: () => slotIds('toggles').has(name), count: () => slotIds('stacks').has(name),
|
|
348
|
+
spawn: () => slotIds('spawns').has(name), effect: () => slotIds('effects').has(name) }[kind];
|
|
349
|
+
if (!known()) { fail(`${where}: unknown ${kind} target "${name}"`); continue; }
|
|
350
|
+
if (!input) continue;
|
|
351
|
+
const o = outputs(drive.map, input), event = o.kind === 'event' || (input.type === 'event' && !drive.map);
|
|
352
|
+
if (kind === 'state') {
|
|
353
|
+
if (!o.values) fail(`${where}: needs palette ids (an enum input or cases)`);
|
|
354
|
+
else for (const v of o.values) if (!palettes.get(name).has(v)) fail(`${where}: "${v}" is not a palette id of ${name}`);
|
|
355
|
+
} else if (kind === 'animation') within(o, 0, 10, where);
|
|
356
|
+
else if (kind === 'emission') within(o, 0, 16, where);
|
|
357
|
+
else if (kind === 'position') within(o, 0, 1, where);
|
|
358
|
+
else if (kind === 'count') { within(o, 0, Infinity, where); if (o.range && !o.integer) fail(`${where}: counts need an int input or a rounded scale`); }
|
|
359
|
+
else if (kind === 'visible') { if (!o.values || o.values.some(v => typeof v !== 'boolean')) fail(`${where}: needs boolean outputs`); }
|
|
360
|
+
else if (kind === 'spawn' || kind === 'effect') { if (!event) fail(`${where}: needs an event (an event input, counter or becomes)`); }
|
|
361
|
+
else if (kind === 'label' && event) fail(`${where}: events can't be shown as text`);
|
|
362
|
+
}
|
|
363
|
+
for (const p of machine.positions || []) {
|
|
364
|
+
if (!animated.has(p.node)) fail(`${label}: position "${p.node}" is not a decorative animation`);
|
|
365
|
+
if (p.range[0] >= p.range[1]) fail(`${label}: position "${p.node}" range must increase`);
|
|
366
|
+
}
|
|
367
|
+
for (const recipe of machine.recipes || []) {
|
|
368
|
+
for (const ref of Object.keys(recipe.fields)) resolve(ref, `${label} recipe ${recipe.name}`);
|
|
369
|
+
for (const [ref, map] of Object.entries(recipe.maps || {})) {
|
|
370
|
+
const input = resolve(ref, `${label} recipe ${recipe.name}`);
|
|
371
|
+
if (input?.type === 'enum') for (const v of outputs(map, { type: 'text' }).values || [])
|
|
372
|
+
if (!input.values.includes(v)) fail(`${label} recipe ${recipe.name}: ${ref} maps to "${v}", not a value of the input`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function validateObjectManifest(value, version, options, fail, warn) {
|
|
377
|
+
const traits = value.traits, slots = value.slots || {}, names = Object.keys(traits), b = value.bounds;
|
|
378
|
+
if (options.directory !== undefined && options.directory !== value.id) fail(`id: "${value.id}" differs from its directory "${options.directory}"`);
|
|
379
|
+
for (const a of names) for (const c of names) if (a < c && !(traitPairs[a] || []).includes(c)) fail(`traits: ${a} and ${c} can't be combined`);
|
|
380
|
+
for (let j = 0; j < 3; j++) if (b.min[j] >= b.max[j]) fail(`bounds: min ${'xyz'[j]} must be below max`);
|
|
381
|
+
const t = objectLimits.boundsTolerance, surfaces = new Set(traits.movable ? traits.movable.surfaces || ['floor'] : []);
|
|
382
|
+
const one = surfaces.size === 1 ? [...surfaces][0] : undefined;
|
|
383
|
+
if (one === 'floor' && Math.abs(b.min[1]) > t) fail(`bounds: min y must be 0 (the base at the origin), is ${b.min[1]}`);
|
|
384
|
+
// A character's rest (bind) pose is not the pose it stands in, so its feet get a looser check.
|
|
385
|
+
if (traits.character && Math.abs(b.min[1]) > objectLimits.characterOriginTolerance) fail(`bounds: min y must be within ${objectLimits.characterOriginTolerance} m of 0 (the feet at the origin), is ${b.min[1]}`);
|
|
386
|
+
if (one === 'wall' && Math.abs(b.min[2]) > t) fail(`bounds: min z must be 0 (the back against the wall), is ${b.min[2]}`);
|
|
387
|
+
if (one === 'ceiling' && Math.abs(b.max[1]) > t) fail(`bounds: max y must be 0 (the top at the origin), is ${b.max[1]}`);
|
|
388
|
+
const m = objectLimits.slotMargin, inside = (p, where, grow = m) => {
|
|
389
|
+
if (p.some((v, j) => v < b.min[j] - grow || v > b.max[j] + grow)) fail(`${where}: lies outside the bounds grown by ${grow} m`);
|
|
390
|
+
};
|
|
391
|
+
const quat = (q, where) => { if (Math.abs(Math.hypot(...q) - 1) > 0.002) fail(`${where}: quaternion must be normalized`); };
|
|
392
|
+
const unique = (items, field, label) => { const ids = items.map(v => v[field]); if (new Set(ids).size !== ids.length) fail(`${label}: duplicate ${field}`); };
|
|
393
|
+
// Traits.
|
|
394
|
+
const animations = traits.decorative?.animations || [];
|
|
395
|
+
unique(animations, 'node', 'traits.decorative animations');
|
|
396
|
+
for (const n of animations) {
|
|
397
|
+
if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`traits.decorative: animation "${n.node}" axis must be a unit vector`);
|
|
398
|
+
if (n.type === 'bob' ? n.amplitude === undefined : n.pivot === undefined) fail(`traits.decorative: ${n.type} animation "${n.node}" needs ${n.type === 'bob' ? 'an amplitude' : 'a pivot'}`);
|
|
399
|
+
if (n.type === 'bob' ? n.pivot !== undefined : n.amplitude !== undefined) fail(`traits.decorative: ${n.type} animation "${n.node}" takes no ${n.type === 'bob' ? 'pivot' : 'amplitude'}`);
|
|
400
|
+
}
|
|
401
|
+
if (traits.collidable && animations.some(n => n.node === traits.collidable.node)) fail(`traits.collidable: node "${traits.collidable.node}" is animated`);
|
|
402
|
+
const scale = traits.movable?.scale;
|
|
403
|
+
if (scale && !(scale[0] <= 1 && 1 <= scale[1])) fail('traits.movable: scale must be [min, max] with min <= 1 <= max');
|
|
404
|
+
if (traits.character) unique(traits.character.interactions || [], 'id', 'traits.character interactions');
|
|
405
|
+
if (traits.seat) {
|
|
406
|
+
unique(traits.seat.anchors, 'id', 'traits.seat anchors');
|
|
407
|
+
if ((traits.seat.capacity ?? 0) > traits.seat.anchors.length) fail('traits.seat: capacity exceeds the number of anchors');
|
|
408
|
+
for (const a of traits.seat.anchors) inside(a.position, `traits.seat anchor ${a.id}`);
|
|
409
|
+
}
|
|
410
|
+
if (traits.mover?.offset) inside(traits.mover.offset, 'traits.mover offset');
|
|
411
|
+
// Slots: ids unique per kind, poses inside the grown bounds, palettes with unique ids.
|
|
412
|
+
for (const [kind, list] of Object.entries(slots)) {
|
|
413
|
+
unique(list, 'id', `slots.${kind}`);
|
|
414
|
+
for (const s of list) {
|
|
415
|
+
if (s.pose) { quat(s.pose.rotation, `slots.${kind} ${s.id}`); inside(s.pose.position, `slots.${kind} ${s.id}`); }
|
|
416
|
+
if (s.palette) unique(s.palette, 'id', `slots.${kind} ${s.id} palette`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const tracks = new Map((slots.tracks || []).map(tr => [tr.id, tr]));
|
|
420
|
+
for (const tr of slots.tracks || []) tr.points.forEach((p, i) => {
|
|
421
|
+
inside(p.position, `slots.tracks ${tr.id} point ${i}`);
|
|
422
|
+
const prev = tr.points[i - 1]?.position;
|
|
423
|
+
if (p.type === 'arc' && prev && Math.hypot(p.position[0] - prev[0], p.position[2] - prev[2]) < 0.1) fail(`slots.tracks ${tr.id} point ${i}: an arc needs a horizontal chord of at least 0.1 m`);
|
|
424
|
+
});
|
|
425
|
+
for (const s of slots.spawns || []) if (s.track !== undefined && s.track !== 'port') {
|
|
426
|
+
const tr = tracks.get(s.track);
|
|
427
|
+
if (!tr) fail(`slots.spawns ${s.id}: unknown track ${s.track}`);
|
|
428
|
+
else for (const a of s.items || []) if (tr.items && !tr.items.assets.includes(a)) fail(`slots.spawns ${s.id}: item ${a} is not in track ${tr.id}'s items`);
|
|
429
|
+
}
|
|
430
|
+
for (const s of slots.stacks || []) {
|
|
431
|
+
if (s.cap > s.layout.grid.reduce((x, y) => x * y, 1)) fail(`slots.stacks ${s.id}: cap exceeds the layout grid`);
|
|
432
|
+
s.layout.grid.forEach((n, j) => { if (n > 1 && !(s.layout.spacing[j] > 0)) fail(`slots.stacks ${s.id}: spacing ${'xyz'[j]} must be positive for ${n} cells`); });
|
|
433
|
+
}
|
|
434
|
+
if (traits.machine) validateMachine(value, fail);
|
|
435
|
+
// Directory contents, thumbnail and measured values.
|
|
436
|
+
const files = options.files;
|
|
437
|
+
if (files) {
|
|
438
|
+
const total = files.reduce((n, f) => n + f.size, 0), paths = new Set(files.map(f => f.path));
|
|
439
|
+
if (files.length > objectLimits.files) fail(`directory: ${files.length} files, more than ${objectLimits.files}`);
|
|
440
|
+
if (total > objectLimits.directoryBytes) fail(`directory: ${total} bytes, more than ${objectLimits.directoryBytes}`);
|
|
441
|
+
for (const f of files) {
|
|
442
|
+
if (f.size > objectLimits.fileBytes) fail(`directory: ${f.path} exceeds ${objectLimits.fileBytes} bytes`);
|
|
443
|
+
if (!value.attribution.some(a => a.path === '.' || a.path === f.path || f.path.startsWith(a.path + '/'))) fail(`attribution: ${f.path} is not covered`);
|
|
444
|
+
}
|
|
445
|
+
for (const [field, path] of [['scene', value.scene], ['thumbnail', value.thumbnail]]) if (path !== undefined && !paths.has(path)) fail(`${field}: ${path} not found in the directory`);
|
|
446
|
+
}
|
|
447
|
+
if (options.thumbnail) {
|
|
448
|
+
const { width, height, bytes } = options.thumbnail, s = objectLimits.thumbnailSize;
|
|
449
|
+
if (width !== s || height !== s) fail(`thumbnail: ${width} x ${height}, must be ${s} x ${s}`);
|
|
450
|
+
if (bytes > objectLimits.thumbnailBytes) fail(`thumbnail: ${bytes} bytes, more than ${objectLimits.thumbnailBytes}`);
|
|
451
|
+
}
|
|
452
|
+
const measured = options.measured || {};
|
|
453
|
+
if (measured.bounds) for (const end of ['min', 'max']) for (let j = 0; j < 3; j++)
|
|
454
|
+
if (Math.abs(b[end][j] - measured.bounds[end][j]) > t) fail(`bounds: ${end} ${'xyz'[j]} ${b[end][j]} is more than 1 cm from the scene's ${+measured.bounds[end][j].toFixed(4)}`);
|
|
455
|
+
const texture = traits.character ? objectLimits.characterTextureSize : objectLimits.textureSize;
|
|
456
|
+
if (measured.maxTextureSize > texture) fail(`textures: ${measured.maxTextureSize} px exceeds ${texture} px`);
|
|
457
|
+
const gpu = traits.mover?.item ? objectLimits.itemGpuBytes : objectLimits.gpuBytes;
|
|
458
|
+
if (measured.gpuBytes > gpu) fail(`gpu: ${measured.gpuBytes} bytes exceeds ${gpu}`);
|
|
459
|
+
if (options.gltf) return validateObjectGltf(value, version, options.gltf, options, fail, warn);
|
|
460
|
+
return {};
|
|
461
|
+
}
|
|
462
|
+
// library.json: listed object directories, their formats and the library's own files.
|
|
463
|
+
function validateLibraryManifest(value, options, fail) {
|
|
464
|
+
const objects = options.objects;
|
|
465
|
+
if (objects) {
|
|
466
|
+
for (const id of value.objects) {
|
|
467
|
+
const o = objects[id];
|
|
468
|
+
if (!o) { fail(`objects: ${id} has no objects/${id}/object.json`); continue; }
|
|
469
|
+
if (o.id !== id) fail(`objects: objects/${id}/object.json has id ${o.id}`);
|
|
470
|
+
if (typeof o.formatVersion !== 'string' || !objectFormats.includes(o.formatVersion)) fail(`objects: ${id} has no known object formatVersion (${JSON.stringify(o.formatVersion)})`);
|
|
471
|
+
else if (!atLeast(value.formatVersion, o.formatVersion)) fail(`objects: ${id} uses object format ${o.formatVersion}, newer than the library's ${value.formatVersion}`);
|
|
472
|
+
}
|
|
473
|
+
for (const id of Object.keys(objects)) if (!value.objects.includes(id)) fail(`objects: objects/${id} is not listed`);
|
|
474
|
+
}
|
|
475
|
+
const files = options.files;
|
|
476
|
+
if (files) {
|
|
477
|
+
const total = files.reduce((n, f) => n + f.size, 0);
|
|
478
|
+
if (files.length > objectLimits.libraryFiles) fail(`library: ${files.length} files, more than ${objectLimits.libraryFiles}`);
|
|
479
|
+
if (total > objectLimits.libraryBytes) fail(`library: ${total} bytes, more than ${objectLimits.libraryBytes}`);
|
|
480
|
+
for (const required of ['library.json', 'LICENSE', 'ATTRIBUTION.md']) if (!files.some(f => f.path === required)) fail(`library: missing ${required}`);
|
|
481
|
+
for (const f of files) if (!f.path.startsWith('objects/') && !value.attribution.some(a => a.path === '.' || a.path === f.path || f.path.startsWith(a.path + '/'))) fail(`attribution: ${f.path} is not covered`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// libraries/releases.json: one pinned archive per id@version.
|
|
485
|
+
function validateLibraryReleases(value, fail) {
|
|
486
|
+
for (const [key, r] of Object.entries(value)) {
|
|
487
|
+
if (key !== `${r.id}@${r.version}`) fail(`${key}: key must be id@version (${r.id}@${r.version})`);
|
|
488
|
+
if (!objectFormats.includes(r.formatVersion)) fail(`${key}: formatVersion ${r.formatVersion} is not a known object format`);
|
|
489
|
+
if (!r.url.endsWith(`/${r.id}-${r.version}.tar.zst`)) fail(`${key}: url must end with /${r.id}-${r.version}.tar.zst`);
|
|
490
|
+
if (btoa(String.fromCharCode(...r.sha256.match(/../g).map(h => parseInt(h, 16)))) !== r.nixHash.slice(7)) fail(`${key}: nixHash does not encode sha256`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Object and library manifests default to their own formatVersion; the release
|
|
495
|
+
// record to the newest directory that defines it. Results add `warnings`.
|
|
496
|
+
function validateManifestKind(kind, value, method, version, options) {
|
|
497
|
+
if (method) throw new Error('Method context is only valid for IPC');
|
|
498
|
+
const formats = kind === 'library-releases' ? versions.filter(v => has(v, kind)) : objectFormats;
|
|
499
|
+
version ??= kind === 'library-releases' ? formats.at(-1) : formats.includes(value?.formatVersion) ? value.formatVersion : formats.at(-1);
|
|
500
|
+
if (!formats.includes(version)) throw new Error(`Unknown ${kind} format: ${version}`);
|
|
501
|
+
const key = `${version}:${kind}:`;
|
|
502
|
+
if (!validators.has(key)) validators.set(key, ajv.getSchema(`https://hyprune.com/schema/v${version}/${kind}.schema.json`));
|
|
503
|
+
const check = validators.get(key), errors = [], warnings = [], fail = m => errors.push(m), warn = m => warnings.push(m);
|
|
504
|
+
// Name reserved traits and unknown formats plainly; the schema alone would only say
|
|
505
|
+
// "must NOT be valid" or "must be equal to constant".
|
|
506
|
+
const reserved = kind === 'object' && value?.traits && typeof value.traits === 'object'
|
|
507
|
+
? Object.entries(reservedTraits(version)).filter(([name]) => Object.hasOwn(value.traits, name)) : [];
|
|
508
|
+
for (const [name, why] of reserved) fail(`traits.${name}: ${why}; rejected in object format ${version}`);
|
|
509
|
+
if (kind !== 'library-releases' && value?.formatVersion !== version)
|
|
510
|
+
fail(`formatVersion: ${JSON.stringify(value?.formatVersion)} is not ${formats.includes(value?.formatVersion) ? `the requested ${version}` : `an object format (${formats.join(', ')})`}`);
|
|
511
|
+
if (!check(value)) {
|
|
512
|
+
const said = new Set(reserved.map(([name]) => `/traits/${name}`));
|
|
513
|
+
const schemaErrors = check.errors.filter(e => !(e.keyword === 'not' && said.has(e.instancePath)) && !(e.keyword === 'const' && e.instancePath === '/formatVersion'));
|
|
514
|
+
return { valid: false, errors: [...errors, ...schemaErrors.map(e => `${e.instancePath || '/'} ${e.message}`)], warnings };
|
|
515
|
+
}
|
|
516
|
+
if (errors.length) return { valid: false, errors, warnings };
|
|
517
|
+
const spdx = (text, label) => { try { parseSpdx(text); } catch { fail(`${label}: invalid SPDX expression`); } };
|
|
518
|
+
if (kind !== 'library-releases') {
|
|
519
|
+
const prerelease = value.version.split('+')[0].split('-').slice(1).join('-');
|
|
520
|
+
if (prerelease.split('.').some(p => /^0\d+$/.test(p))) fail('version: numeric prerelease identifiers cannot have leading zeros');
|
|
521
|
+
spdx(value.license, 'license');
|
|
522
|
+
const paths = value.attribution.map(a => a.path);
|
|
523
|
+
if (new Set(paths).size !== paths.length) fail('attribution: duplicate path');
|
|
524
|
+
for (const a of value.attribution) spdx(a.license, `attribution ${a.path}`);
|
|
525
|
+
}
|
|
526
|
+
let stats = {};
|
|
527
|
+
if (kind === 'object') stats = validateObjectManifest(value, version, options, fail, warn) || {};
|
|
528
|
+
if (kind === 'library') validateLibraryManifest(value, options, fail);
|
|
529
|
+
if (kind === 'library-releases') validateLibraryReleases(value, fail);
|
|
530
|
+
return { valid: errors.length === 0, errors, warnings, ...stats };
|
|
531
|
+
}
|
|
68
532
|
// The optional method binds a response to the caller's outstanding request.
|
|
69
533
|
// An envelope alone cannot know whether a valid result belongs to that method.
|
|
70
|
-
export function validate(kind, value, method, version
|
|
534
|
+
export function validate(kind, value, method, version, options = {}) {
|
|
71
535
|
if (kind === 'scene') {
|
|
72
536
|
if (method) throw new Error('Method context is only valid for IPC');
|
|
73
|
-
return validateScene(value, version, options.world);
|
|
537
|
+
return validateScene(value, version ?? '0.1', options.world, options.asset);
|
|
74
538
|
}
|
|
539
|
+
if (manifestKinds.includes(kind)) return validateManifestKind(kind, value, method, version, options);
|
|
540
|
+
version ??= '0.1';
|
|
75
541
|
if (!names.includes(kind) || kind === 'common') throw new Error(`Unknown schema: ${kind}`);
|
|
76
542
|
if (!contractVersions.includes(version)) throw new Error(`Unknown contract: ${version}`);
|
|
77
543
|
const key = `${version}:${kind}:${method || ''}`;
|
|
@@ -172,6 +638,26 @@ export function validate(kind, value, method, version = '0.1', options = {}) {
|
|
|
172
638
|
if (Math.abs(Math.hypot(...c.facing)-1) > .001 || Math.abs(axis.reduce((v,n,i)=>v+n*c.facing[i],0)/length) > .001) fail(`${c.id}: facing must be unit and perpendicular to segment`);
|
|
173
639
|
for (const end of ['top','bottom']) if (Math.hypot(...c.exits[end].map((v,i)=>v-c[end][i])) > 3) fail(`${c.id}: exit too far from segment`);
|
|
174
640
|
}
|
|
641
|
+
if (atLeast(value.formatVersion, '0.8')) {
|
|
642
|
+
const assets = value.objectAssets || [], objects = value.objects || [];
|
|
643
|
+
unique(assets, 'id', 'objectAssets'); unique(objects, 'id', 'objects');
|
|
644
|
+
const ids = new Set(assets.map(a => a.id));
|
|
645
|
+
for (const a of assets) {
|
|
646
|
+
const label = `objectAssets ${a.id}`, animations = a.animations || [];
|
|
647
|
+
if (a.library !== undefined) fail(`${label}: library references are reserved for a later world format; use a package scene`);
|
|
648
|
+
unique(animations, 'node', `${label} animations`);
|
|
649
|
+
for (const n of animations) {
|
|
650
|
+
if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`${label}: animation "${n.node}" axis must be a unit vector`);
|
|
651
|
+
if (n.type === 'bob' ? n.amplitude === undefined : n.pivot === undefined) fail(`${label}: ${n.type} animation "${n.node}" needs ${n.type === 'bob' ? 'an amplitude' : 'a pivot'}`);
|
|
652
|
+
if (n.type === 'bob' ? n.pivot !== undefined : n.amplitude !== undefined) fail(`${label}: ${n.type} animation "${n.node}" takes no ${n.type === 'bob' ? 'pivot' : 'amplitude'}`);
|
|
653
|
+
}
|
|
654
|
+
if (a.collision && animations.some(n => n.node === a.collision.node)) fail(`${label}: collision node "${a.collision.node}" is animated`);
|
|
655
|
+
}
|
|
656
|
+
for (const o of objects) {
|
|
657
|
+
if (!ids.has(o.asset)) fail(`objects ${o.id}: unknown asset ${o.asset}`);
|
|
658
|
+
quat(o.pose.rotation, `objects ${o.id}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
175
661
|
unique(value.attribution, 'path', 'attribution');
|
|
176
662
|
for (const a of value.attribution) license(a.license, `attribution ${a.path}`);
|
|
177
663
|
}
|
package/package.json
CHANGED